From ce43e1220f0c7d3ed750309b30a444c7bd75e14c Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Thu, 22 Jan 2026 21:47:54 +0300 Subject: [PATCH 1/3] feat(operations): sorting by date, description and amount --- .../operations-table.component.html | 32 ++++++++++++-- .../operations-table.component.less | 29 ++++++++++++ .../operations-table.component.ts | 44 +++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 0d5254e3..6e4dc007 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -2,9 +2,33 @@ - - - + + + @if (showActions) { @@ -13,7 +37,7 @@ - @for (operation of operations; track operation.id) { + @for (operation of displayedOperations; track operation.id) { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index 7f75a177..2a4c82b7 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -134,6 +134,29 @@ gap: 0.35rem; } + .notes-inline { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .notes-display { + cursor: text; + } + + .notes-text.empty { + color: var(--tui-text-tertiary); + font-style: italic; + } + + .notes-edit-button { + opacity: 0.6; + } + + .notes-display:hover .notes-edit-button { + opacity: 1; + } + .description-text { white-space: pre-wrap; } @@ -155,6 +178,21 @@ color: var(--tui-text-primary); } } + + .note-status { + min-width: 0.9rem; + font-size: 0.8rem; + line-height: 1; + color: var(--tui-text-tertiary); + + &[data-status='saved'] { + color: var(--tui-success-fill); + } + + &[data-status='error'] { + color: var(--tui-error-fill); + } + } } &.col-amount { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 685b1a12..dea3e235 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -56,6 +56,9 @@ export class OperationsTableComponent { sortField: 'amount' | 'timestamp' | 'description' | null = null; sortDirection: 'asc' | 'desc' | null = null; noteDrafts: Record = {}; + noteSaveStatus: Record = {}; + private pendingNoteSaves: Record = {}; + noteEditingId: string | null = null; get displayedOperations(): OperationResponse[] { if (!this.sortField || !this.sortDirection) return this.operations; @@ -191,6 +194,12 @@ export class OperationsTableComponent { if (!(operation.id in this.noteDrafts)) { this.noteDrafts[operation.id] = operation.notes ?? ''; } + this.noteSaveStatus[operation.id] = 'idle'; + } + + startNoteEdit(operation: OperationResponse): void { + this.noteEditingId = operation.id; + this.beginNotesEdit(operation); } saveNotes(operation: OperationResponse): void { @@ -201,6 +210,8 @@ export class OperationsTableComponent { } this.noteDrafts[operation.id] = nextNotes; + this.pendingNoteSaves[operation.id] = nextNotes; + this.noteSaveStatus[operation.id] = 'saving'; this.operationNoteUpdated.emit({ ...operation, notes: nextNotes @@ -211,13 +222,35 @@ export class OperationsTableComponent { const seenIds = new Set(operations.map(operation => operation.id)); for (const operation of operations) { this.noteDrafts[operation.id] = operation.notes ?? ''; + if (this.pendingNoteSaves[operation.id] !== undefined) { + if (operation.notes === this.pendingNoteSaves[operation.id]) { + this.noteSaveStatus[operation.id] = 'saved'; + delete this.pendingNoteSaves[operation.id]; + } else { + this.noteSaveStatus[operation.id] = 'error'; + delete this.pendingNoteSaves[operation.id]; + } + } } for (const id of Object.keys(this.noteDrafts)) { if (!seenIds.has(id)) { delete this.noteDrafts[id]; + delete this.noteSaveStatus[id]; + delete this.pendingNoteSaves[id]; } } } + + onNoteEnter(operation: OperationResponse, event: Event): void { + event.preventDefault(); + this.saveNotes(operation); + (event.target as HTMLInputElement).blur(); + } + + onNoteBlur(operation: OperationResponse): void { + this.saveNotes(operation); + this.noteEditingId = null; + } }
Date & TimeDescriptionAmount + + + + + + Tags Attributes
{{ operation.timestamp | dateFormat }} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index 1cdd8618..f80c7132 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -42,10 +42,39 @@ min-width: 300px; } + .column-sort { + display: inline-flex; + align-items: center; + gap: 0.25rem; + justify-content: flex-start; + width: 100%; + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + + &:hover { + color: var(--tui-text-primary); + } + } + + .sort-indicator { + font-size: 0.75rem; + line-height: 1; + color: var(--tui-text-action); + } + &.col-amount { width: 150px; text-align: right; white-space: nowrap; + + .column-sort { + justify-content: flex-end; + } } &.col-tags { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 046e7ffe..5b83289e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -43,6 +43,50 @@ export class OperationsTableComponent { expandedOperationId: string | null = null; editingOperationId: string | null = null; editingOperation: EditableOperation | null = null; + sortField: 'amount' | 'timestamp' | 'description' | null = null; + sortDirection: 'asc' | 'desc' | null = null; + + get displayedOperations(): OperationResponse[] { + if (!this.sortField || !this.sortDirection) return this.operations; + + const directionMultiplier = this.sortDirection === 'asc' ? 1 : -1; + + return [...this.operations].sort((left, right) => { + switch (this.sortField) { + case 'amount': + return (left.amount.value - right.amount.value) * directionMultiplier; + case 'timestamp': + return (Date.parse(left.timestamp) - Date.parse(right.timestamp)) * directionMultiplier; + case 'description': + return left.description.localeCompare(right.description, undefined, { sensitivity: 'base' }) * directionMultiplier; + } + + return 0; + }); + } + + toggleSort(field: 'amount' | 'timestamp' | 'description'): void { + if (this.sortField !== field) { + this.sortField = field; + this.sortDirection = 'desc'; + return; + } + + if (!this.sortDirection) { + this.sortDirection = 'desc'; + return; + } + + this.sortDirection = this.sortDirection === 'desc' ? 'asc' : null; + if (!this.sortDirection) { + this.sortField = null; + } + } + + getSortIndicator(field: 'amount' | 'timestamp' | 'description'): string { + if (this.sortField !== field || !this.sortDirection) return '↕'; + return this.sortDirection === 'asc' ? '↑' : '↓'; + } toggleOperationDetails(operationId: string): void { this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; From 657166604be42912badd35b64f24550558c0c8b3 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Fri, 23 Jan 2026 09:45:33 +0300 Subject: [PATCH 2/3] eat(operations): add notes support Persist notes across the domain, API, and storage, and allow inline note edits without reloading operation lists. --- .../Entities/Accounting/TrackedOperation.cs | 5 +- .../Accounting/UnregisteredOperation.cs | 2 +- .../DuplicatesDetectorShould.cs | 3 +- .../Fakes/FakeOperationsRepository.cs | 3 +- .../Budgets/MergeBudgetsRequestHandler.cs | 2 +- .../Accounting/Exchange/MoneyConverter.cs | 3 +- .../Services/Accounting/Reckon/Reckoner.cs | 4 +- .../Models/OperationResponse.cs | 3 + .../Utils/OperationMapper.cs | 3 + .../Entities/Operations/Operation.cs | 6 +- .../Entities/Transactions/Transfer.cs | 4 +- .../budget-client/src/app/budget/models.ts | 2 + .../duplicates-list.component.html | 3 +- .../duplicates-list.component.ts | 38 + .../logbook-group.component.html | 3 +- .../logbook-group/logbook-group.component.ts | 30 + .../operations-list.component.html | 3 +- .../operations-list.component.ts | 30 + .../operations-table.component.html | 28 +- .../operations-table.component.less | 29 + .../operations-table.component.ts | 49 +- .../Entities/StoredOperation.cs | 7 +- ...260123061901_AddOperationNotes.Designer.cs | 1520 +++++++++++++++++ .../20260123061901_AddOperationNotes.cs | 31 + .../Migrations/BudgetContextModelSnapshot.cs | 4 + .../Repositories/OperationsRepository.cs | 10 +- 26 files changed, 1797 insertions(+), 28 deletions(-) create mode 100644 src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.Designer.cs create mode 100644 src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.cs diff --git a/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/TrackedOperation.cs b/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/TrackedOperation.cs index 2a1c0d2d..eba18737 100644 --- a/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/TrackedOperation.cs +++ b/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/TrackedOperation.cs @@ -1,4 +1,4 @@ -using NMoneys; +using NMoneys; using NVs.Budget.Domain.Entities.Operations; using NVs.Budget.Domain.ValueObjects; @@ -9,10 +9,11 @@ public class TrackedOperation( DateTime timestamp, Money amount, string description, + string notes, Domain.Entities.Budgets.Budget budget, IEnumerable tags, IReadOnlyDictionary? attributes) - : Operation(id, timestamp, amount, description, budget, tags, attributes), ITrackableEntity + : Operation(id, timestamp, amount, description, notes, budget, tags, attributes), ITrackableEntity { public string? Version { get; set; } public bool IsRegistered => !string.IsNullOrEmpty(Version); diff --git a/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/UnregisteredOperation.cs b/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/UnregisteredOperation.cs index e794298b..f6e20277 100644 --- a/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/UnregisteredOperation.cs +++ b/src/Application/NVs.Budget.Application.Contracts/Entities/Accounting/UnregisteredOperation.cs @@ -1,4 +1,4 @@ -using NMoneys; +using NMoneys; namespace NVs.Budget.Application.Contracts.Entities.Accounting; diff --git a/src/Application/NVs.Budget.Application.Tests/DuplicatesDetectorShould.cs b/src/Application/NVs.Budget.Application.Tests/DuplicatesDetectorShould.cs index 1fd4137c..8d1575f0 100644 --- a/src/Application/NVs.Budget.Application.Tests/DuplicatesDetectorShould.cs +++ b/src/Application/NVs.Budget.Application.Tests/DuplicatesDetectorShould.cs @@ -1,4 +1,4 @@ -using AutoFixture; +using AutoFixture; using FluentAssertions; using NVs.Budget.Application.Contracts.Entities.Accounting; using NVs.Budget.Application.Contracts.Options; @@ -34,6 +34,7 @@ private List GenerateDuplicates(TrackedOperation operation, pa operation.Timestamp + offset, operation.Amount, operation.Description, + operation.Notes, operation.Budget, operation.Tags, operation.Attributes.AsReadOnly() diff --git a/src/Application/NVs.Budget.Application.Tests/Fakes/FakeOperationsRepository.cs b/src/Application/NVs.Budget.Application.Tests/Fakes/FakeOperationsRepository.cs index 42460a02..54deea0b 100644 --- a/src/Application/NVs.Budget.Application.Tests/Fakes/FakeOperationsRepository.cs +++ b/src/Application/NVs.Budget.Application.Tests/Fakes/FakeOperationsRepository.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using FluentResults; using NVs.Budget.Application.Contracts.Entities.Accounting; @@ -18,6 +18,7 @@ public Task> Register(UnregisteredOperation operation, operation.Timestamp, operation.Amount, operation.Description, + string.Empty, budget, Enumerable.Empty(), operation.Attributes) diff --git a/src/Application/NVs.Budget.Application.UseCases/Budgets/MergeBudgetsRequestHandler.cs b/src/Application/NVs.Budget.Application.UseCases/Budgets/MergeBudgetsRequestHandler.cs index 4231cc98..2dc4af9f 100644 --- a/src/Application/NVs.Budget.Application.UseCases/Budgets/MergeBudgetsRequestHandler.cs +++ b/src/Application/NVs.Budget.Application.UseCases/Budgets/MergeBudgetsRequestHandler.cs @@ -29,7 +29,7 @@ public async Task Handle(MergeBudgetsRequest request, CancellationToken var source = orderedBudgets[i]; var operations = reckoner.GetOperations(new(o => o.Budget.Id == source.Id), cancellationToken) - .Select(o => new TrackedOperation(o.Id, o.Timestamp, o.Amount, o.Description, sink, o.Tags, o.Attributes.AsReadOnly()){ Version = o.Version }); + .Select(o => new TrackedOperation(o.Id, o.Timestamp, o.Amount, o.Description, o.Notes, sink, o.Tags, o.Attributes.AsReadOnly()){ Version = o.Version }); var updateRes = await accountant.Update(operations, sink, new(null, TaggingMode.Skip), cancellationToken); result.Reasons.AddRange(updateRes.Reasons); diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Exchange/MoneyConverter.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Exchange/MoneyConverter.cs index 2e00b77a..c7dc088d 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Exchange/MoneyConverter.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Exchange/MoneyConverter.cs @@ -1,4 +1,4 @@ -using NMoneys; +using NMoneys; using NVs.Budget.Application.Contracts.Entities; using NVs.Budget.Domain.Entities.Operations; using NVs.Budget.Infrastructure.ExchangeRates.Contracts; @@ -28,6 +28,7 @@ public async Task Convert(Operation operation, Currency targetCurrenc operation.Timestamp, new Money(operation.Amount.Amount * rate.Rate, targetCurrency), operation.Description, + operation.Notes, operation.Budget, operation.Tags.ToList(), new Dictionary(operation.Attributes) diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Reckon/Reckoner.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Reckon/Reckoner.cs index 2549a5b6..572c059a 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Reckon/Reckoner.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Reckon/Reckoner.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Accounting; @@ -113,7 +113,7 @@ public async Task>> Ge private TrackedOperation AsTrackedOperation(Operation operation) { var result = new TrackedOperation( - operation.Id, operation.Timestamp, operation.Amount, operation.Description, + operation.Id, operation.Timestamp, operation.Amount, operation.Description, operation.Notes, AsTrackedBudget(operation.Budget), operation.Tags, operation.Attributes.AsReadOnly() ); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs index c20db113..06ae2e14 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -8,6 +8,7 @@ public record OperationResponse( DateTime Timestamp, MoneyResponse Amount, string Description, + string Notes, Guid BudgetId, IReadOnlyCollection Tags, Dictionary? Attributes @@ -22,6 +23,7 @@ public record UnregisteredOperationRequest( DateTime Timestamp, MoneyResponse Amount, string Description, + string? Notes, Dictionary? Attributes ); @@ -31,6 +33,7 @@ public record UpdateOperationRequest( DateTime Timestamp, MoneyResponse Amount, string Description, + string? Notes, IReadOnlyCollection Tags, Dictionary? Attributes ); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs b/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs index 27a6951b..b4b17e7a 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs @@ -17,6 +17,7 @@ public OperationResponse ToResponse(TrackedOperation operation) operation.Timestamp, new MoneyResponse(operation.Amount.Amount, operation.Amount.CurrencyCode.ToString()), operation.Description, + operation.Notes, operation.Budget.Id, operation.Tags.Select(t => t.Value).ToList(), operation.Attributes.Count > 0 ? new Dictionary(operation.Attributes) : null @@ -38,6 +39,7 @@ public OperationResponse ToResponse(Operation operation) operation.Timestamp, new MoneyResponse(operation.Amount.Amount, operation.Amount.CurrencyCode.ToString()), operation.Description, + operation.Notes, operation.Budget.Id, operation.Tags.Select(t => t.Value).ToList(), operation.Attributes.Count > 0 ? new Dictionary(operation.Attributes) : null @@ -82,6 +84,7 @@ public Result FromRequest(UpdateOperationRequest request, Trac request.Timestamp, moneyResult.Value, request.Description, + request.Notes ?? string.Empty, budget, tags, attributes diff --git a/src/Domain/NVs.Budget.Domain/Entities/Operations/Operation.cs b/src/Domain/NVs.Budget.Domain/Entities/Operations/Operation.cs index e76817ce..a595e35f 100644 --- a/src/Domain/NVs.Budget.Domain/Entities/Operations/Operation.cs +++ b/src/Domain/NVs.Budget.Domain/Entities/Operations/Operation.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using NMoneys; using NVs.Budget.Domain.ValueObjects; @@ -12,17 +12,19 @@ public class Operation : EntityBase public DateTime Timestamp { get; } public Money Amount { get; } public string Description { get; } + public string Notes { get; } public Budgets.Budget Budget { get; } public IReadOnlyCollection Tags => _tags.AsReadOnly(); public IDictionary Attributes { get; } = new AttributesDictionary(new Dictionary()); - public Operation(Guid id, DateTime timestamp, Money amount, string description, Budgets.Budget budget, IEnumerable tags, IReadOnlyDictionary? attributes) : base(id) + public Operation(Guid id, DateTime timestamp, Money amount, string description, string notes, Budgets.Budget budget, IEnumerable tags, IReadOnlyDictionary? attributes) : base(id) { Timestamp = timestamp; Amount = amount; Description = description; + Notes = notes; Budget = budget; if (attributes != null) diff --git a/src/Domain/NVs.Budget.Domain/Entities/Transactions/Transfer.cs b/src/Domain/NVs.Budget.Domain/Entities/Transactions/Transfer.cs index bc71923b..6154d7de 100644 --- a/src/Domain/NVs.Budget.Domain/Entities/Transactions/Transfer.cs +++ b/src/Domain/NVs.Budget.Domain/Entities/Transactions/Transfer.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Diagnostics; using NMoneys; using NVs.Budget.Domain.Entities.Operations; @@ -110,7 +110,7 @@ public Operation AsTransaction() { nameof(Sink), Sink.Id } }; - return new Operation(Guid.Empty, timestamp, amount, description, budget, tags, attributes); + return new Operation(Guid.Empty, timestamp, amount, description, string.Empty, budget, tags, attributes); } public IEnumerator GetEnumerator() => new Enumerator(this); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index 9a87d40d..519f01a1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -91,6 +91,7 @@ export interface OperationResponse { timestamp: string; amount: MoneyResponse; description: string; + notes: string; budgetId: string; tags: string[]; attributes?: Record; @@ -102,6 +103,7 @@ export interface UpdateOperationRequest { timestamp: string; amount: MoneyResponse; description: string; + notes: string; tags: string[]; attributes?: Record; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html index 4224e97d..d3011658 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html @@ -46,7 +46,8 @@

Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)

[operations]="group" [showActions]="true" (operationDeleted)="onDeleteOperation($event)" - (operationUpdated)="onUpdateOperation($event)"> + (operationUpdated)="onUpdateOperation($event)" + (operationNoteUpdated)="onUpdateOperationNote($event)"> } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts index 93bbc6ff..33a312de 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts @@ -147,5 +147,43 @@ export class DuplicatesListComponent implements OnInit { } }); } + + onUpdateOperationNote(operation: OperationResponse): void { + const current = this.findOperation(operation.id); + const previousNotes = current?.notes ?? ''; + + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update notes: ${errorMessage}`).subscribe(); + if (current) { + current.notes = previousNotes; + } + return; + } + + const updatedOperation = result.updatedOperations?.[0] ?? operation; + this.replaceOperation(updatedOperation); + }, + error: (error) => { + const errorMessage = this.notificationService.handleError(error, 'Failed to update notes'); + this.notificationService.showError(errorMessage).subscribe(); + if (current) { + current.notes = previousNotes; + } + } + }); + } + + private replaceOperation(updated: OperationResponse): void { + this.duplicateGroups = this.duplicateGroups.map(group => + group.map(item => item.id === updated.id ? updated : item) + ); + } + + private findOperation(operationId: string): OperationResponse | undefined { + return this.duplicateGroups.flat().find(operation => operation.id === operationId); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.html index 643c8aed..1fdf5e78 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.html @@ -23,7 +23,8 @@

{{ groupTitle }}

[operations]="operations" [showActions]="true" (operationDeleted)="onDeleteOperation($event)" - (operationUpdated)="onUpdateOperation($event)"> + (operationUpdated)="onUpdateOperation($event)" + (operationNoteUpdated)="onUpdateOperationNote($event)"> } @else { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts index 407ff4d4..42fad2e3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts @@ -182,5 +182,35 @@ export class LogbookGroupComponent implements OnInit { } }); } + + onUpdateOperationNote(operation: OperationResponse): void { + const current = this.operations.find(o => o.id === operation.id); + const previousNotes = current?.notes ?? ''; + + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update notes: ${errorMessage}`).subscribe(); + if (current) { + current.notes = previousNotes; + } + return; + } + + const updatedOperation = result.updatedOperations?.[0] ?? operation; + this.operations = this.operations.map(item => + item.id === updatedOperation.id ? updatedOperation : item + ); + }, + error: (error) => { + const errorMessage = this.notificationService.handleError(error, 'Failed to update notes'); + this.notificationService.showError(errorMessage).subscribe(); + if (current) { + current.notes = previousNotes; + } + } + }); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 3a63ba14..a48f4be4 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -105,7 +105,8 @@

Operations

[operations]="operations" [showActions]="true" (operationDeleted)="onDeleteOperation($event)" - (operationUpdated)="onUpdateOperation($event)"> + (operationUpdated)="onUpdateOperation($event)" + (operationNoteUpdated)="onUpdateOperationNote($event)">
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index a7300dc1..78d38849 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -187,4 +187,34 @@ export class OperationsListComponent implements OnInit { } }); } + + onUpdateOperationNote(operation: OperationResponse): void { + const current = this.operations.find(o => o.id === operation.id); + const previousNotes = current?.notes ?? ''; + + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update notes: ${errorMessage}`).subscribe(); + if (current) { + current.notes = previousNotes; + } + return; + } + + const updatedOperation = result.updatedOperations?.[0] ?? operation; + this.operations = this.operations.map(item => + item.id === updatedOperation.id ? updatedOperation : item + ); + }, + error: (error) => { + const errorMessage = this.notificationService.handleError(error, 'Failed to update notes'); + this.notificationService.showError(errorMessage).subscribe(); + if (current) { + current.notes = previousNotes; + } + } + }); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 6e4dc007..17f8a171 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -42,13 +42,29 @@
{{ operation.timestamp | dateFormat }} @if (isEditing(operation.id) && editingOperation) { - +
+ + +
} @else { - {{ operation.description }} +
+
{{ operation.description }}
+ +
}
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index f80c7132..7f75a177 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -126,6 +126,35 @@ &.col-description { color: var(--tui-text-primary); + + .description-content, + .description-edit { + display: flex; + flex-direction: column; + gap: 0.35rem; + } + + .description-text { + white-space: pre-wrap; + } + + .notes-input { + width: 100%; + padding: 0.25rem 0.5rem; + border: 1px solid transparent; + border-radius: 0.25rem; + background: transparent; + color: var(--tui-text-secondary); + font-size: 0.8rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--tui-border-normal); + background: var(--tui-background-base); + color: var(--tui-text-primary); + } + } } &.col-amount { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 5b83289e..685b1a12 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -11,6 +11,7 @@ import { ObjectKeysPipe } from '../shared/pipes/object-keys.pipe'; interface EditableOperation { id: string; description: string; + notes: string; amount: number; currencyCode: string; tags: string[]; @@ -35,16 +36,26 @@ interface EditableOperation { styleUrls: ['./operations-table.component.less'] }) export class OperationsTableComponent { - @Input() operations: OperationResponse[] = []; + private _operations: OperationResponse[] = []; + @Input() set operations(value: OperationResponse[]) { + this._operations = value; + this.syncNoteDrafts(value); + } + + get operations(): OperationResponse[] { + return this._operations; + } @Input() showActions = true; @Output() operationDeleted = new EventEmitter(); @Output() operationUpdated = new EventEmitter(); + @Output() operationNoteUpdated = new EventEmitter(); expandedOperationId: string | null = null; editingOperationId: string | null = null; editingOperation: EditableOperation | null = null; sortField: 'amount' | 'timestamp' | 'description' | null = null; sortDirection: 'asc' | 'desc' | null = null; + noteDrafts: Record = {}; get displayedOperations(): OperationResponse[] { if (!this.sortField || !this.sortDirection) return this.operations; @@ -101,6 +112,7 @@ export class OperationsTableComponent { this.editingOperation = { id: operation.id, description: operation.description, + notes: operation.notes ?? '', amount: operation.amount.value, currencyCode: operation.amount.currencyCode, tags: [...operation.tags], @@ -119,6 +131,7 @@ export class OperationsTableComponent { const updatedOperation: OperationResponse = { ...operation, description: this.editingOperation.description, + notes: this.editingOperation.notes, amount: { value: this.editingOperation.amount, currencyCode: this.editingOperation.currencyCode @@ -128,6 +141,7 @@ export class OperationsTableComponent { }; this.operationUpdated.emit(updatedOperation); + this.noteDrafts[operation.id] = updatedOperation.notes; this.editingOperationId = null; this.editingOperation = null; } @@ -172,5 +186,38 @@ export class OperationsTableComponent { this.editingOperation.attributes[newKey] = value; } } + + beginNotesEdit(operation: OperationResponse): void { + if (!(operation.id in this.noteDrafts)) { + this.noteDrafts[operation.id] = operation.notes ?? ''; + } + } + + saveNotes(operation: OperationResponse): void { + const currentNotes = operation.notes ?? ''; + const nextNotes = (this.noteDrafts[operation.id] ?? '').trimEnd(); + if (nextNotes === currentNotes) { + return; + } + + this.noteDrafts[operation.id] = nextNotes; + this.operationNoteUpdated.emit({ + ...operation, + notes: nextNotes + }); + } + + private syncNoteDrafts(operations: OperationResponse[]): void { + const seenIds = new Set(operations.map(operation => operation.id)); + for (const operation of operations) { + this.noteDrafts[operation.id] = operation.notes ?? ''; + } + + for (const id of Object.keys(this.noteDrafts)) { + if (!seenIds.has(id)) { + delete this.noteDrafts[id]; + } + } + } } diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Entities/StoredOperation.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Entities/StoredOperation.cs index 300f60fd..b9a96dd2 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Entities/StoredOperation.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Entities/StoredOperation.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using NVs.Budget.Application.Contracts.Entities; @@ -6,13 +6,14 @@ namespace NVs.Budget.Infrastructure.Persistence.EF.Entities; [SuppressMessage("ReSharper", "ClassWithVirtualMembersNeverInherited.Global", Justification = "Virtual nav props are required to perform lazy load")] -internal class StoredOperation(Guid id, DateTime timestamp, string description) : DbRecord, ITrackableEntity +internal class StoredOperation(Guid id, DateTime timestamp, string description, string notes) : DbRecord, ITrackableEntity { [Key] public Guid Id { get; [UsedImplicitly] private set; } = id; public DateTime Timestamp { get; set; } = timestamp; public StoredMoney Amount { get; set; } = StoredMoney.Zero; public string Description { get; set; } = description; + public string Notes { get; set; } = notes; public string? Version { get; set; } = string.Empty; public IList Tags { get; init; } = new List(); @@ -22,5 +23,5 @@ internal class StoredOperation(Guid id, DateTime timestamp, string description) public virtual StoredTransfer? SourceTransfer { get; set; } public virtual StoredTransfer? SinkTransfer { get; set; } - public static readonly StoredOperation Invalid = new(Guid.Empty, DateTime.MinValue, string.Empty); + public static readonly StoredOperation Invalid = new(Guid.Empty, DateTime.MinValue, string.Empty, string.Empty); } diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.Designer.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.Designer.cs new file mode 100644 index 00000000..655c954b --- /dev/null +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.Designer.cs @@ -0,0 +1,1520 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Persistence.EF.Context; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Persistence.EF.Migrations +{ + [DbContext(typeof(BudgetContext))] + [Migration("20260123061901_AddOperationNotes")] + partial class AddOperationNotes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("budget") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "currency_iso_code", new[] { "aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bhd", "bif", "bmd", "bnd", "bob", "bov", "brl", "bsd", "btn", "bwp", "byn", "byr", "bzd", "cad", "cdf", "che", "chf", "chw", "clf", "clp", "cny", "cop", "cou", "crc", "cuc", "cup", "cve", "czk", "djf", "dkk", "dop", "dzd", "eek", "egp", "ern", "etb", "eur", "fjd", "fkp", "gbp", "gel", "ghs", "gip", "gmd", "gnf", "gtq", "gyd", "hkd", "hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "iqd", "irr", "isk", "jmd", "jod", "jpy", "kes", "kgs", "khr", "kmf", "kpw", "krw", "kwd", "kyd", "kzt", "lak", "lbp", "lkr", "lrd", "lsl", "ltl", "lvl", "lyd", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mru", "mur", "mvr", "mwk", "mxn", "mxv", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "omr", "pab", "pen", "pgk", "php", "pkr", "pln", "pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sdg", "sek", "sgd", "shp", "sle", "sll", "sos", "srd", "ssp", "std", "stn", "svc", "syp", "szl", "thb", "tjs", "tmt", "tnd", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "usn", "uss", "uyi", "uyu", "uyw", "uzs", "ved", "vef", "ves", "vnd", "vuv", "wst", "xaf", "xag", "xau", "xba", "xbb", "xbc", "xbd", "xcd", "xdr", "xof", "xpd", "xpf", "xpt", "xsu", "xts", "xua", "xxx", "yer", "zar", "zmk", "zmw", "zwg", "zwl" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Budgets", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredCsvFileReadingOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CultureInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateTimeKind") + .HasColumnType("integer"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("FileNamePattern") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.ToTable("CsvFileReadingOptions", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.ToTable("Operations", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOwner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Owners", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredRate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AsOf") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("From") + .HasColumnType("integer"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("To") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Rates", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTransfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("SinkId") + .HasColumnType("uuid"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SinkId") + .IsUnique(); + + b.HasIndex("SourceId") + .IsUnique(); + + b.ToTable("Transfers", "budget"); + }); + + modelBuilder.Entity("StoredBudgetStoredOwner", b => + { + b.Property("BudgetsId") + .HasColumnType("uuid"); + + b.Property("OwnersId") + .HasColumnType("uuid"); + + b.HasKey("BudgetsId", "OwnersId"); + + b.HasIndex("OwnersId"); + + b.ToTable("StoredBudgetStoredOwner", "budget"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", b => + { + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTaggingCriterion", "TaggingCriteria", b1 => + { + b1.Property("BudgetId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Condition") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("BudgetId", "Id"); + + b1.ToTable("StoredTaggingCriterion", "budget"); + + b1.WithOwner("Budget") + .HasForeignKey("BudgetId"); + + b1.Navigation("Budget"); + }); + + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTransferCriterion", "TransferCriteria", b1 => + { + b1.Property("BudgetId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Accuracy") + .HasColumnType("integer"); + + b1.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Criterion") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("BudgetId", "Id"); + + b1.ToTable("StoredTransferCriterion", "budget"); + + b1.WithOwner("Budget") + .HasForeignKey("BudgetId"); + + b1.Navigation("Budget"); + }); + + b.OwnsOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "LogbookCriteria", b1 => + { + b1.Property("StoredBudgetId") + .HasColumnType("uuid"); + + b1.Property("Criteria") + .HasColumnType("text"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b1.Property("IsUniversal") + .HasColumnType("boolean"); + + b1.Property("Substitution") + .HasColumnType("text"); + + b1.Property("Type") + .HasColumnType("integer"); + + b1.HasKey("StoredBudgetId"); + + b1.ToTable("Budgets", "budget"); + + b1.ToJson("LogbookCriteria"); + + b1.WithOwner() + .HasForeignKey("StoredBudgetId"); + + b1.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b2 => + { + b2.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("StoredLogbookCriteriaStoredBudgetId", "Id"); + + b2.ToTable("Budgets", "budget"); + + b2.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId"); + }); + + b1.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria.Subcriteria#StoredLogbookCriteria", "Subcriteria", b2 => + { + b2.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property("Criteria") + .HasColumnType("text"); + + b2.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b2.Property("IsUniversal") + .HasColumnType("boolean"); + + b2.Property("Substitution") + .HasColumnType("text"); + + b2.Property("Type") + .HasColumnType("integer"); + + b2.HasKey("StoredLogbookCriteriaStoredBudgetId", "Id"); + + b2.ToTable("Budgets", "budget"); + + b2.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId"); + + b2.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b3 => + { + b3.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b3.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b3.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b3.Property("Criteria") + .HasColumnType("text"); + + b3.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b3.Property("IsUniversal") + .HasColumnType("boolean"); + + b3.Property("Substitution") + .HasColumnType("text"); + + b3.Property("Type") + .HasColumnType("integer"); + + b3.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "Id"); + + b3.ToTable("Budgets", "budget"); + + b3.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId"); + + b3.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b4 => + { + b4.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b4.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b4.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b4.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b4.Property("Criteria") + .HasColumnType("text"); + + b4.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b4.Property("IsUniversal") + .HasColumnType("boolean"); + + b4.Property("Substitution") + .HasColumnType("text"); + + b4.Property("Type") + .HasColumnType("integer"); + + b4.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "Id"); + + b4.ToTable("Budgets", "budget"); + + b4.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1"); + + b4.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b5 => + { + b5.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b5.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b5.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b5.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b5.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b5.Property("Criteria") + .HasColumnType("text"); + + b5.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b5.Property("IsUniversal") + .HasColumnType("boolean"); + + b5.Property("Substitution") + .HasColumnType("text"); + + b5.Property("Type") + .HasColumnType("integer"); + + b5.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "Id"); + + b5.ToTable("Budgets", "budget"); + + b5.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2"); + + b5.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b6 => + { + b6.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b6.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b6.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b6.Property("Criteria") + .HasColumnType("text"); + + b6.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b6.Property("IsUniversal") + .HasColumnType("boolean"); + + b6.Property("Substitution") + .HasColumnType("text"); + + b6.Property("Type") + .HasColumnType("integer"); + + b6.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "Id"); + + b6.ToTable("Budgets", "budget"); + + b6.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3"); + + b6.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b7 => + { + b7.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b7.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b7.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b7.Property("Criteria") + .HasColumnType("text"); + + b7.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b7.Property("IsUniversal") + .HasColumnType("boolean"); + + b7.Property("Substitution") + .HasColumnType("text"); + + b7.Property("Type") + .HasColumnType("integer"); + + b7.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "Id"); + + b7.ToTable("Budgets", "budget"); + + b7.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4"); + + b7.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b8 => + { + b8.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b8.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b8.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b8.Property("Criteria") + .HasColumnType("text"); + + b8.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b8.Property("IsUniversal") + .HasColumnType("boolean"); + + b8.Property("Substitution") + .HasColumnType("text"); + + b8.Property("Type") + .HasColumnType("integer"); + + b8.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "Id"); + + b8.ToTable("Budgets", "budget"); + + b8.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5"); + + b8.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b9 => + { + b9.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b9.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b9.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b9.Property("Criteria") + .HasColumnType("text"); + + b9.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b9.Property("IsUniversal") + .HasColumnType("boolean"); + + b9.Property("Substitution") + .HasColumnType("text"); + + b9.Property("Type") + .HasColumnType("integer"); + + b9.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "Id"); + + b9.ToTable("Budgets", "budget"); + + b9.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6"); + + b9.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b10 => + { + b10.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b10.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId7") + .HasColumnType("integer"); + + b10.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b10.Property("Criteria") + .HasColumnType("text"); + + b10.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b10.Property("IsUniversal") + .HasColumnType("boolean"); + + b10.Property("Substitution") + .HasColumnType("text"); + + b10.Property("Type") + .HasColumnType("integer"); + + b10.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "Id"); + + b10.ToTable("Budgets", "budget"); + + b10.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7"); + + b10.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredLogbookCriteria", "Subcriteria", b11 => + { + b11.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b11.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId7") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId8") + .HasColumnType("integer"); + + b11.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b11.Property("Criteria") + .HasColumnType("text"); + + b11.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b11.Property("IsUniversal") + .HasColumnType("boolean"); + + b11.Property("Substitution") + .HasColumnType("text"); + + b11.Property("Type") + .HasColumnType("integer"); + + b11.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8", "Id"); + + b11.ToTable("Budgets", "budget"); + + b11.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8"); + + b11.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b12 => + { + b12.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b12.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId7") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId8") + .HasColumnType("integer"); + + b12.Property("StoredLogbookCriteriaId9") + .HasColumnType("integer"); + + b12.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b12.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b12.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8", "StoredLogbookCriteriaId9", "Id"); + + b12.ToTable("Budgets", "budget"); + + b12.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8", "StoredLogbookCriteriaId9"); + }); + + b11.Navigation("Tags"); + }); + + b10.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b11 => + { + b11.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b11.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId7") + .HasColumnType("integer"); + + b11.Property("StoredLogbookCriteriaId8") + .HasColumnType("integer"); + + b11.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b11.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b11.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8", "Id"); + + b11.ToTable("Budgets", "budget"); + + b11.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "StoredLogbookCriteriaId8"); + }); + + b10.Navigation("Subcriteria"); + + b10.Navigation("Tags"); + }); + + b9.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b10 => + { + b10.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b10.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b10.Property("StoredLogbookCriteriaId7") + .HasColumnType("integer"); + + b10.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b10.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b10.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7", "Id"); + + b10.ToTable("Budgets", "budget"); + + b10.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "StoredLogbookCriteriaId7"); + }); + + b9.Navigation("Subcriteria"); + + b9.Navigation("Tags"); + }); + + b8.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b9 => + { + b9.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b9.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b9.Property("StoredLogbookCriteriaId6") + .HasColumnType("integer"); + + b9.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b9.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b9.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6", "Id"); + + b9.ToTable("Budgets", "budget"); + + b9.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "StoredLogbookCriteriaId6"); + }); + + b8.Navigation("Subcriteria"); + + b8.Navigation("Tags"); + }); + + b7.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b8 => + { + b8.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b8.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b8.Property("StoredLogbookCriteriaId5") + .HasColumnType("integer"); + + b8.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b8.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b8.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5", "Id"); + + b8.ToTable("Budgets", "budget"); + + b8.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "StoredLogbookCriteriaId5"); + }); + + b7.Navigation("Subcriteria"); + + b7.Navigation("Tags"); + }); + + b6.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b7 => + { + b7.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b7.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b7.Property("StoredLogbookCriteriaId4") + .HasColumnType("integer"); + + b7.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b7.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b7.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4", "Id"); + + b7.ToTable("Budgets", "budget"); + + b7.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "StoredLogbookCriteriaId4"); + }); + + b6.Navigation("Subcriteria"); + + b6.Navigation("Tags"); + }); + + b5.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b6 => + { + b6.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b6.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b6.Property("StoredLogbookCriteriaId3") + .HasColumnType("integer"); + + b6.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b6.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b6.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3", "Id"); + + b6.ToTable("Budgets", "budget"); + + b6.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "StoredLogbookCriteriaId3"); + }); + + b5.Navigation("Subcriteria"); + + b5.Navigation("Tags"); + }); + + b4.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b5 => + { + b5.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b5.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b5.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b5.Property("StoredLogbookCriteriaId2") + .HasColumnType("integer"); + + b5.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b5.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b5.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2", "Id"); + + b5.ToTable("Budgets", "budget"); + + b5.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "StoredLogbookCriteriaId2"); + }); + + b4.Navigation("Subcriteria"); + + b4.Navigation("Tags"); + }); + + b3.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b4 => + { + b4.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b4.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b4.Property("StoredLogbookCriteriaId1") + .HasColumnType("integer"); + + b4.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b4.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b4.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1", "Id"); + + b4.ToTable("Budgets", "budget"); + + b4.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "StoredLogbookCriteriaId1"); + }); + + b3.Navigation("Subcriteria"); + + b3.Navigation("Tags"); + }); + + b2.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b3 => + { + b3.Property("StoredLogbookCriteriaStoredBudgetId") + .HasColumnType("uuid"); + + b3.Property("StoredLogbookCriteriaId") + .HasColumnType("integer"); + + b3.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b3.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b3.HasKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId", "Id"); + + b3.ToTable("Budgets", "budget"); + + b3.WithOwner() + .HasForeignKey("StoredLogbookCriteriaStoredBudgetId", "StoredLogbookCriteriaId"); + }); + + b2.Navigation("Subcriteria"); + + b2.Navigation("Tags"); + }); + + b1.Navigation("Subcriteria"); + + b1.Navigation("Tags"); + }); + + b.Navigation("LogbookCriteria") + .IsRequired(); + + b.Navigation("TaggingCriteria"); + + b.Navigation("TransferCriteria"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredCsvFileReadingOption", b => + { + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", "Budget") + .WithMany("CsvReadingOptions") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredFieldConfiguration", "AttributesConfiguration", b1 => + { + b1.Property("FileReadingOptionId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Field") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Pattern") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("FileReadingOptionId", "Id"); + + b1.ToTable("CsvFileReadingOptions_AttributesConfiguration", "budget"); + + b1.WithOwner("FileReadingOption") + .HasForeignKey("FileReadingOptionId"); + + b1.Navigation("FileReadingOption"); + }); + + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredFieldConfiguration", "FieldConfigurations", b1 => + { + b1.Property("FileReadingOptionId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Field") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Pattern") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("FileReadingOptionId", "Id"); + + b1.ToTable("CsvFileReadingOptions_FieldConfigurations", "budget"); + + b1.WithOwner("FileReadingOption") + .HasForeignKey("FileReadingOptionId"); + + b1.Navigation("FileReadingOption"); + }); + + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredValidationRule", "ValidationRules", b1 => + { + b1.Property("FileReadingOptionId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Condition") + .HasColumnType("integer"); + + b1.Property("FieldConfiguration") + .IsRequired() + .HasColumnType("text"); + + b1.Property("RuleName") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("FileReadingOptionId", "Id"); + + b1.ToTable("StoredValidationRule", "budget"); + + b1.WithOwner("FileReadingOption") + .HasForeignKey("FileReadingOptionId"); + + b1.Navigation("FileReadingOption"); + }); + + b.Navigation("AttributesConfiguration"); + + b.Navigation("Budget"); + + b.Navigation("FieldConfigurations"); + + b.Navigation("ValidationRules"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOperation", b => + { + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", "Budget") + .WithMany("Operations") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredMoney", "Amount", b1 => + { + b1.Property("StoredOperationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric"); + + b1.Property("Currency") + .HasColumnType("integer"); + + b1.HasKey("StoredOperationId"); + + b1.ToTable("Operations", "budget"); + + b1.WithOwner() + .HasForeignKey("StoredOperationId"); + }); + + b.OwnsMany("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTag", "Tags", b1 => + { + b1.Property("StoredOperationId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("StoredOperationId", "Id"); + + b1.ToTable("Operations_Tags", "budget"); + + b1.WithOwner() + .HasForeignKey("StoredOperationId"); + }); + + b.Navigation("Amount") + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredRate", b => + { + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOwner", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTransfer", b => + { + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOperation", "Sink") + .WithOne("SinkTransfer") + .HasForeignKey("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTransfer", "SinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOperation", "Source") + .WithOne("SourceTransfer") + .HasForeignKey("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredTransfer", "SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredMoney", "Fee", b1 => + { + b1.Property("StoredTransferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric"); + + b1.Property("Currency") + .HasColumnType("integer"); + + b1.HasKey("StoredTransferId"); + + b1.ToTable("Transfers", "budget"); + + b1.WithOwner() + .HasForeignKey("StoredTransferId"); + }); + + b.Navigation("Fee") + .IsRequired(); + + b.Navigation("Sink"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("StoredBudgetStoredOwner", b => + { + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", null) + .WithMany() + .HasForeignKey("BudgetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOwner", null) + .WithMany() + .HasForeignKey("OwnersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredBudget", b => + { + b.Navigation("CsvReadingOptions"); + + b.Navigation("Operations"); + }); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Persistence.EF.Entities.StoredOperation", b => + { + b.Navigation("SinkTransfer"); + + b.Navigation("SourceTransfer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.cs new file mode 100644 index 00000000..13f3f394 --- /dev/null +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/20260123061901_AddOperationNotes.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Persistence.EF.Migrations +{ + /// + public partial class AddOperationNotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notes", + schema: "budget", + table: "Operations", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notes", + schema: "budget", + table: "Operations"); + } + } +} diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/BudgetContextModelSnapshot.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/BudgetContextModelSnapshot.cs index f95c6315..40d5ac94 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/BudgetContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Migrations/BudgetContextModelSnapshot.cs @@ -110,6 +110,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + b.Property("Timestamp") .HasColumnType("timestamp with time zone"); diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Repositories/OperationsRepository.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Repositories/OperationsRepository.cs index 6458431e..1dbc065d 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Repositories/OperationsRepository.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/Repositories/OperationsRepository.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using AutoMapper; using FluentResults; @@ -63,7 +63,7 @@ public IAsyncEnumerable> Register(IAsyncEnumerable(u.Amount), @@ -160,6 +160,12 @@ private async Task>> UpdateItems(Queue Date: Fri, 23 Jan 2026 20:02:58 +0300 Subject: [PATCH 3/3] feat: edit on enter, perf improvements --- .../operations-table.component.html | 52 ++++++++++++++++--- .../operations-table.component.less | 38 ++++++++++++++ .../operations-table.component.ts | 33 ++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 17f8a171..3646bd10 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -57,13 +57,51 @@ } @else {
{{ operation.description }}
- + @if (noteEditingId === operation.id) { +
+ + + @if (noteSaveStatus[operation.id] === 'saved') { + ✔ + } @else if (noteSaveStatus[operation.id] === 'error') { + ✖ + } @else if (noteSaveStatus[operation.id] === 'saving') { + … + } + +
+ } @else { +
+ + {{ operation.notes || 'Add note' }} + + + + @if (noteSaveStatus[operation.id] === 'saved') { + ✔ + } @else if (noteSaveStatus[operation.id] === 'error') { + ✖ + } @else if (noteSaveStatus[operation.id] === 'saving') { + … + } + +
+ }
}