diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8cb8c16..5379cfb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:24:40.198621+01:00","closed_at":"2026-01-30T22:24:40.198621+01:00","close_reason":"Closed"} +{"id":"books-0xk","title":"Phase 2: Wire broken features to backend APIs","description":"Connect all console.log-only handlers to real GraphQL mutations: Kassekladde submit, Settings save, Bankafstemning save, Kontooversigt account CRUD, FiscalYear creation, CloseFiscalYearWizard, Void/Copy actions.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.249535+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.295989+01:00"} {"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"} {"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"} {"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"} @@ -7,8 +8,11 @@ {"id":"books-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"} {"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"} {"id":"books-ced","title":"brug smb om regnskab + fropntend designer til at sikrer at alt er godt for både balance og kontooversigt","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:46.484629+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.42433+01:00","closed_at":"2026-01-30T14:47:52.42433+01:00","close_reason":"Closed"} +{"id":"books-cws","title":"Phase 3: Accounting compliance fixes","description":"Balanced entry enforcement, VAT code unification, invoice numbering, fiscal year gap/overlap checks, posting date tracking, SAF-T fixes.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.362182+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.37896+01:00"} {"id":"books-h6e","title":"fjern hurtig bogføring og den visning der høre dertil","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:14:50.436314+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:18:09.911294+01:00","closed_at":"2026-01-30T14:18:09.911294+01:00","close_reason":"Closed"} {"id":"books-hzt","title":"fix bug med tilføj brugere står forkert med encoded tegn","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:21:34.556319+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:28:31.320973+01:00","closed_at":"2026-01-30T14:28:31.320973+01:00","close_reason":"Closed"} +{"id":"books-k95","title":"Phase 4: UX consistency \u0026 bug fixes","description":"Danish character encoding, DemoDataDisclaimer deployment, PageHeader adoption, mobile responsiveness, mock data removal, dead buttons.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.471301+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.460303+01:00"} +{"id":"books-ley","title":"Phase 1: GraphQL Authentication \u0026 Authorization","description":"Add authentication to GraphQL endpoint and authorization checks to all resolvers. Fix: S-01 through S-06, RBAC always returning owner, admin hardcoded email check.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.131213+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.214637+01:00"} {"id":"books-ljg","title":"Fjern mock data og kobl frontend til backend GraphQL","description":"Frontend bruger ~2000 linjer hardcoded mock data i stedet for at bruge de eksisterende GraphQL hooks.\n\n## Problem\n- Backend GraphQL API er klar med queries og mutations\n- Frontend har hooks skrevet (useAccounts, useFiscalYears, etc.)\n- Men pages bruger hardcoded mock data i stedet for at kalde hooks\n\n## Filer der skal opdateres\n1. Dashboard.tsx - mock metrics, charts, transactions\n2. Kassekladde.tsx - mock accounts og posteringer \n3. Kontooversigt.tsx - mock kontoplan og balancer\n4. Bankafstemning.tsx - mock bank accounts og transaktioner\n5. FiscalYearSelector.tsx - mock fiscal years\n6. CompanySwitcher.tsx - mock companies\n7. Stores (companyStore, periodStore) - skal initialiseres fra API\n\n## Acceptkriterier\n- Al mock data fjernet fra frontend\n- Alle pages bruger GraphQL hooks til at hente data\n- Stores initialiseres korrekt ved app start\n- Data vises fra backend i UI","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:27:49.225279+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:42:04.17437+01:00","closed_at":"2026-01-30T22:42:04.17437+01:00","close_reason":"Closed"} {"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"} {"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"} diff --git a/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs index 0870032..d95713d 100644 --- a/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs +++ b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs @@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests public void MarkPosted_WhenActive_EmitsPostedEvent() { // Arrange - var aggregate = CreateActiveDraft(); + var aggregate = CreateActiveDraftWithLines(); // Act aggregate.MarkPosted("transaction-123", "user@example.com"); // Assert var uncommittedEvents = aggregate.UncommittedEvents.ToList(); - uncommittedEvents.Should().HaveCount(2); // Created + Posted + uncommittedEvents.Should().HaveCount(3); // Created + Updated + Posted - var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent; + var postedEvent = uncommittedEvents[2].AggregateEvent as JournalEntryDraftPostedEvent; postedEvent.Should().NotBeNull(); postedEvent!.TransactionId.Should().Be("transaction-123"); postedEvent.PostedBy.Should().Be("user@example.com"); + postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException() { // Arrange - var aggregate = CreateActiveDraft(); + var aggregate = CreateActiveDraftWithLines(); // Act var act = () => aggregate.MarkPosted(" ", "user@example.com"); @@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException() { // Arrange - var aggregate = CreateActiveDraft(); + var aggregate = CreateActiveDraftWithLines(); // Act var act = () => aggregate.MarkPosted("transaction-123", ""); @@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests return aggregate; } - private static JournalEntryDraftAggregate CreatePostedDraft() + private static JournalEntryDraftAggregate CreateActiveDraftWithLines() { var aggregate = CreateActiveDraft(); + var lines = new List + { + new(1, "account-1", 1000m, 0m, "Debet"), + new(2, "account-2", 0m, 1000m, "Kredit") + }; + aggregate.Update("Test Draft", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines); + return aggregate; + } + + private static JournalEntryDraftAggregate CreatePostedDraft() + { + var aggregate = CreateActiveDraftWithLines(); aggregate.MarkPosted("transaction-123", "user@example.com"); return aggregate; } diff --git a/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs b/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs index 648edb6..85f0419 100644 --- a/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs +++ b/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs @@ -12,7 +12,7 @@ namespace Books.Api.Authentication; public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName; - public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24); + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5); } public class ApiKeyAuthenticationHandler( diff --git a/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs b/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs index 98a7212..34a5fec 100644 --- a/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs +++ b/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs @@ -1,15 +1,35 @@ +using Books.Api.Domain; using Books.Api.Domain.Accounts; +using Books.Api.EventFlow.Repositories; using EventFlow.Commands; namespace Books.Api.Commands.Accounts; -public class CreateAccountCommandHandler : CommandHandler +/// +/// Command handler for creating a new account. +/// Validates that the account number is unique within the company. +/// +public class CreateAccountCommandHandler( + IAccountRepository accountRepository) + : CommandHandler { - public override Task ExecuteAsync( + public override async Task ExecuteAsync( AccountAggregate aggregate, CreateAccountCommand command, CancellationToken cancellationToken) { + // Check if an account with the same number already exists for this company + var existingAccount = await accountRepository.GetByCompanyAndNumberAsync( + command.CompanyId, command.AccountNumber, cancellationToken); + + if (existingAccount != null) + { + throw new DomainException( + "ACCOUNT_NUMBER_EXISTS", + $"Account number {command.AccountNumber} already exists for this company", + $"Kontonummer {command.AccountNumber} eksisterer allerede"); + } + aggregate.Create( command.CompanyId, command.AccountNumber, @@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler +/// +/// Command handler for creating a new fiscal year. +/// Validates overlap with existing fiscal years and checks for gaps. +/// +public class CreateFiscalYearCommandHandler( + IFiscalYearRepository fiscalYearRepository) + : CommandHandler { - public override Task ExecuteAsync( + public override async Task ExecuteAsync( FiscalYearAggregate aggregate, CreateFiscalYearCommand command, CancellationToken cancellationToken) { + // Check for overlapping fiscal years + var hasOverlap = await fiscalYearRepository.HasOverlappingYearAsync( + command.CompanyId, + command.StartDate, + command.EndDate, + excludeId: null, + cancellationToken); + + if (hasOverlap) + { + throw new DomainException( + "FISCAL_YEAR_OVERLAP", + "The fiscal year overlaps with an existing fiscal year", + "Regnskabsåret overlapper med et eksisterende regnskabsår"); + } + + // Check for gaps: verify the new start date follows the latest existing fiscal year + if (!command.IsFirstFiscalYear) + { + var existingYears = await fiscalYearRepository.GetByCompanyIdAsync( + command.CompanyId, cancellationToken); + + if (existingYears.Count > 0) + { + // Find the latest end date among existing fiscal years + var latestEndDate = existingYears + .Select(fy => DateOnly.FromDateTime(fy.EndDate)) + .Max(); + + var expectedStartDate = latestEndDate.AddDays(1); + + if (command.StartDate != expectedStartDate) + { + throw new DomainException( + "FISCAL_YEAR_GAP", + $"Fiscal year must start on {expectedStartDate:yyyy-MM-dd} (day after previous year ends on {latestEndDate:yyyy-MM-dd}). No gaps are allowed between fiscal years.", + $"Regnskabsåret skal starte den {expectedStartDate:yyyy-MM-dd} (dagen efter forrige år slutter den {latestEndDate:yyyy-MM-dd}). Der må ikke være huller mellem regnskabsår."); + } + } + } + aggregate.Create( command.CompanyId, command.Name, @@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler +/// Command handler for creating invoices. +/// Auto-assigns a sequential invoice number if one is not provided. +/// +public class CreateInvoiceCommandHandler( + IInvoiceNumberService invoiceNumberService) : CommandHandler { - public override Task ExecuteAsync( + public override async Task ExecuteAsync( InvoiceAggregate aggregate, CreateInvoiceCommand command, CancellationToken cancellationToken) { + // Auto-assign invoice number if not provided + var invoiceNumber = command.InvoiceNumber; + if (string.IsNullOrWhiteSpace(invoiceNumber)) + { + invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync( + command.CompanyId, + command.InvoiceDate.Year, + cancellationToken); + } + aggregate.Create( command.CompanyId, command.FiscalYearId, command.CustomerId, command.CustomerName, command.CustomerNumber, - command.InvoiceNumber, + invoiceNumber, command.InvoiceDate, command.DueDate, command.PaymentTermsDays, @@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler command.Notes, command.Reference, command.CreatedBy); - - return Task.CompletedTask; } } diff --git a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs index cb67fb3..d976127 100644 --- a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs +++ b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs @@ -1,4 +1,6 @@ +using Books.Api.Domain; using Books.Api.Domain.JournalEntryDrafts; +using Books.Api.EventFlow.Repositories; using EventFlow.Commands; namespace Books.Api.Commands.JournalEntryDrafts; @@ -42,19 +44,75 @@ public class UpdateJournalEntryDraftCommandHandler } } -public class MarkJournalEntryDraftPostedCommandHandler +/// +/// Command handler for posting a journal entry draft. +/// Validates fiscal year status and date range before allowing the post. +/// +public class MarkJournalEntryDraftPostedCommandHandler( + IJournalEntryDraftRepository draftRepository, + IFiscalYearRepository fiscalYearRepository) : CommandHandler { - public override Task ExecuteAsync( + public override async Task ExecuteAsync( JournalEntryDraftAggregate aggregate, MarkJournalEntryDraftPostedCommand command, CancellationToken cancellationToken) { + // Load the draft read model to get fiscal year and document date + var draft = await draftRepository.GetByIdAsync( + aggregate.Id.Value, cancellationToken); + + var fiscalYearId = draft?.FiscalYearId ?? aggregate.FiscalYearId; + + // Validate fiscal year is set + if (string.IsNullOrWhiteSpace(fiscalYearId)) + { + throw new DomainException( + "FISCAL_YEAR_REQUIRED", + "Fiscal year is required for posting a journal entry", + "Regnskabsår er påkrævet for bogføring af en postering"); + } + + // Fetch and validate fiscal year + var fiscalYear = await fiscalYearRepository.GetByIdAsync( + fiscalYearId, cancellationToken); + + if (fiscalYear == null) + { + throw new DomainException( + "FISCAL_YEAR_NOT_FOUND", + $"Fiscal year '{fiscalYearId}' not found", + $"Regnskabsår '{fiscalYearId}' blev ikke fundet"); + } + + // Validate fiscal year is open (not Closed or Locked) + if (fiscalYear.Status != "Open") + { + throw new DomainException( + "FISCAL_YEAR_NOT_OPEN", + $"Fiscal year is {fiscalYear.Status}. Only open fiscal years allow posting.", + $"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring."); + } + + // Validate document date falls within fiscal year range (if document date is set) + if (draft?.DocumentDate != null) + { + var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value); + var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate); + var fyEnd = DateOnly.FromDateTime(fiscalYear.EndDate); + + if (documentDate < fyStart || documentDate > fyEnd) + { + throw new DomainException( + "DOCUMENT_DATE_OUTSIDE_FISCAL_YEAR", + $"Document date {documentDate:yyyy-MM-dd} falls outside the fiscal year ({fyStart:yyyy-MM-dd} to {fyEnd:yyyy-MM-dd})", + $"Bilagsdato {documentDate:yyyy-MM-dd} ligger uden for regnskabsåret ({fyStart:yyyy-MM-dd} til {fyEnd:yyyy-MM-dd})"); + } + } + aggregate.MarkPosted( command.TransactionId, command.PostedBy); - - return Task.CompletedTask; } } diff --git a/backend/Books.Api/Controllers/BankingController.cs b/backend/Books.Api/Controllers/BankingController.cs index b9f180b..7948597 100644 --- a/backend/Books.Api/Controllers/BankingController.cs +++ b/backend/Books.Api/Controllers/BankingController.cs @@ -58,6 +58,10 @@ public class BankingController : ControllerBase try { + // TODO: Add proper CSRF/state validation. Currently the state parameter + // is used as the connection ID, but it should also include a CSRF token + // that is validated against the user session to prevent cross-site request + // forgery attacks on the OAuth callback. // State contains the connection ID (set during StartBankConnection) var connectionId = state; diff --git a/backend/Books.Api/Database/Migrations/030_AddPostedAtColumn.sql b/backend/Books.Api/Database/Migrations/030_AddPostedAtColumn.sql new file mode 100644 index 0000000..0cbf111 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/030_AddPostedAtColumn.sql @@ -0,0 +1,9 @@ +-- Migration: 030_AddPostedAtColumn +-- Description: Add posted_at column to journal_entry_draft_read_models +-- for audit trail compliance (exact timestamp when draft was posted to ledger) + +ALTER TABLE journal_entry_draft_read_models +ADD COLUMN IF NOT EXISTS posted_at TIMESTAMPTZ; + +COMMENT ON COLUMN journal_entry_draft_read_models.posted_at IS +'Exact timestamp when the journal entry draft was posted to the ledger'; diff --git a/backend/Books.Api/Domain/Invoices/InvoiceLine.cs b/backend/Books.Api/Domain/Invoices/InvoiceLine.cs index 8d1b909..500058b 100644 --- a/backend/Books.Api/Domain/Invoices/InvoiceLine.cs +++ b/backend/Books.Api/Domain/Invoices/InvoiceLine.cs @@ -1,3 +1,5 @@ +using Books.Api.Domain; + namespace Books.Api.Domain.Invoices; /// @@ -65,15 +67,19 @@ public sealed record InvoiceLine /// /// Gets the VAT rate for this line based on VatCode. + /// Delegates to the canonical VatCodes.GetRate() to ensure consistency. /// - private decimal GetVatRate() => VatCode switch + private decimal GetVatRate() { - "U25" or "I25" => 0.25m, // Danish standard 25% - "UEU" or "IEU" => 0m, // EU sales (reverse charge) - "UEXP" or "IEXP" => 0m, // Export (no VAT) - "INGEN" => 0m, // No VAT - _ => 0.25m // Default to Danish standard - }; + if (!VatCodes.IsValid(VatCode)) + { + throw new InvalidOperationException( + $"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " + + $"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN"); + } + + return VatCodes.GetRate(VatCode); + } /// /// Creates an InvoiceLine with validation. diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs index b80277e..174e393 100644 --- a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs +++ b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs @@ -2,10 +2,20 @@ using EventFlow.Aggregates; namespace Books.Api.Domain.JournalEntryDrafts.Events; +/// +/// Event emitted when a journal entry draft is posted to the ledger. +/// Includes PostedAt timestamp for audit trail compliance. +/// public class JournalEntryDraftPostedEvent( string transactionId, - string postedBy) : AggregateEvent + string postedBy, + DateTimeOffset postedAt) : AggregateEvent { public string TransactionId { get; } = transactionId; public string PostedBy { get; } = postedBy; + + /// + /// The exact timestamp when the draft was posted to the ledger. + /// + public DateTimeOffset PostedAt { get; } = postedAt; } diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs index 2cda0f7..204ad6c 100644 --- a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs +++ b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs @@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) IEmit, IEmit { + /// + /// Tolerance for floating-point rounding when comparing debit/credit totals. + /// + private const decimal BalanceTolerance = 0.01m; + private bool _isCreated; private DraftStatus _status = DraftStatus.Active; private string _companyId = string.Empty; private string _voucherNumber = string.Empty; + private string? _fiscalYearId; + private List _lines = []; public string CompanyId => _companyId; public string VoucherNumber => _voucherNumber; + /// + /// The fiscal year ID assigned during the last update. + /// + public string? FiscalYearId => _fiscalYearId; + + /// + /// The current draft lines (populated from the last update event). + /// + public IReadOnlyList Lines => _lines.AsReadOnly(); + #region Apply Methods public void Apply(JournalEntryDraftCreatedEvent e) @@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) public void Apply(JournalEntryDraftUpdatedEvent e) { - // State is stored in read model, not in aggregate + _fiscalYearId = e.FiscalYearId; + _lines = e.Lines.ToList(); } public void Apply(JournalEntryDraftPostedEvent e) @@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) /// /// Updates a journal entry draft (auto-save). + /// Validates that each line has either DebitAmount or CreditAmount (not both), + /// and that VAT codes are valid. /// /// Draft name /// Bilagsdato - the date of the transaction/document (e.g., invoice date) @@ -126,6 +146,12 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) } } + // Validate individual lines: cannot have both debit and credit amounts + foreach (var line in lines) + { + ValidateDraftLine(line); + } + Emit(new JournalEntryDraftUpdatedEvent( name?.Trim(), documentDate, @@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) attachmentIds)); } + /// + /// Marks the draft as posted after validation. + /// Enforces double-entry bookkeeping: total debits must equal total credits. + /// Requires at least 2 lines with valid account IDs. + /// + /// The ledger transaction ID + /// User who posted the draft public void MarkPosted(string transactionId, string postedBy) { EnsureCanModify(); @@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) "Posted by is required", "Bogført af er påkrævet"); - Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy)); + // Validate minimum number of lines for double-entry bookkeeping + if (_lines.Count < 2) + throw new DomainException( + "INSUFFICIENT_LINES", + "A journal entry must have at least 2 lines for double-entry bookkeeping", + "En postering skal have mindst 2 linjer for dobbelt bogholderi"); + + // Validate all lines have account IDs assigned + var linesWithoutAccounts = _lines.Where(l => string.IsNullOrWhiteSpace(l.AccountId)).ToList(); + if (linesWithoutAccounts.Count > 0) + { + var lineNumbers = string.Join(", ", linesWithoutAccounts.Select(l => l.LineNumber)); + throw new DomainException( + "ACCOUNT_REQUIRED", + $"All lines must have an account. Lines without account: {lineNumbers}", + $"Alle linjer skal have en konto. Linjer uden konto: {lineNumbers}"); + } + + // Validate debit/credit balance (fundamental double-entry accounting principle) + var totalDebit = _lines.Sum(l => l.DebitAmount); + var totalCredit = _lines.Sum(l => l.CreditAmount); + + if (Math.Abs(totalDebit - totalCredit) > BalanceTolerance) + throw new DomainException( + "UNBALANCED_ENTRY", + $"Total debits must equal credits. Debit: {totalDebit:N2}, Credit: {totalCredit:N2}", + $"Debet og kredit skal balancere. Debet: {totalDebit:N2}, Kredit: {totalCredit:N2}"); + + Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy, DateTimeOffset.UtcNow)); } public void Discard(string discardedBy) @@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) "Kassekladden er blevet kasseret"); } + /// + /// Validates a single draft line. + /// A line cannot have both DebitAmount > 0 AND CreditAmount > 0. + /// At least one of DebitAmount or CreditAmount must be > 0. + /// + private static void ValidateDraftLine(DraftLine line) + { + if (line.DebitAmount > 0 && line.CreditAmount > 0) + { + throw new DomainException( + "INVALID_LINE_AMOUNTS", + $"Line {line.LineNumber} cannot have both debit and credit amounts. Use separate lines.", + $"Linje {line.LineNumber} kan ikke have både debet- og kreditbeløb. Brug separate linjer."); + } + + if (line.DebitAmount <= 0 && line.CreditAmount <= 0) + { + throw new DomainException( + "MISSING_LINE_AMOUNT", + $"Line {line.LineNumber} must have either a debit or credit amount greater than zero", + $"Linje {line.LineNumber} skal have enten et debet- eller kreditbeløb større end nul"); + } + } + #endregion } diff --git a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs index 0579841..8a1e56b 100644 --- a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs +++ b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs @@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel, public string AttachmentIds { get; set; } = "[]"; public string Status { get; set; } = "active"; public string? TransactionId { get; set; } + /// + /// The exact timestamp when the draft was posted to the ledger. + /// + public DateTimeOffset? PostedAt { get; set; } public string CreatedBy { get; set; } = string.Empty; /// /// Full AI extraction data stored as JSON string. @@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel, Status = "posted"; TransactionId = domainEvent.AggregateEvent.TransactionId; + PostedAt = domainEvent.AggregateEvent.PostedAt; return Task.CompletedTask; } diff --git a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs index 227ac4c..5269d47 100644 --- a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs +++ b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs @@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto public string AttachmentIds { get; set; } = "[]"; public string Status { get; set; } = "active"; public string? TransactionId { get; set; } + /// + /// The exact timestamp when the draft was posted to the ledger. + /// + public DateTimeOffset? PostedAt { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } diff --git a/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs b/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs index a7480d2..877b88f 100644 --- a/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs +++ b/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs @@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal attachment_ids AS AttachmentIds, status AS Status, transaction_id AS TransactionId, + posted_at AS PostedAt, created_by AS CreatedBy, create_time AS CreatedAt, - updated_time AS UpdatedAt + updated_time AS UpdatedAt, + extraction_data AS ExtractionData """; public async Task GetByIdAsync( diff --git a/backend/Books.Api/GraphQL/Mutations/BooksMutation.cs b/backend/Books.Api/GraphQL/Mutations/BooksMutation.cs index b1ec859..3ba8acd 100644 --- a/backend/Books.Api/GraphQL/Mutations/BooksMutation.cs +++ b/backend/Books.Api/GraphQL/Mutations/BooksMutation.cs @@ -1,5 +1,9 @@ +using System.Security.Claims; +using Books.Api.Authorization; using Books.Api.Commands.Companies; +using Books.Api.Commands.UserAccess; using Books.Api.Domain.Companies; +using Books.Api.Domain.UserAccess; using Books.Api.EventFlow.Repositories; using Books.Api.GraphQL.InputTypes; using Books.Api.GraphQL.Types; @@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType await commandBus.PublishAsync(command, ctx.CancellationToken); + // Grant the creating user owner access to the new company + var httpContext = ctx.RequestServices!.GetRequiredService().HttpContext; + var userId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId != null) + { + var accessId = UserCompanyAccessId.FromUserAndCompany(userId, companyId.Value); + var grantCmd = new GrantUserCompanyAccessCommand( + accessId, userId, companyId.Value, CompanyRole.Owner, userId); + await commandBus.PublishAsync(grantCmd, ctx.CancellationToken); + } + // Return the created company (eventually consistent) return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken); }); @@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType .ResolveAsync(async ctx => { var id = ctx.GetArgument("id"); + + // Require Owner or Accountant role to update a company + var accessService = ctx.RequestServices!.GetRequiredService(); + await accessService.RequireAccessAsync(id, CompanyRole.Accountant, ctx.CancellationToken); + var input = ctx.GetArgument("input"); var commandBus = ctx.RequestServices!.GetRequiredService(); var repository = ctx.RequestServices!.GetRequiredService(); diff --git a/backend/Books.Api/GraphQL/Queries/BooksQuery.cs b/backend/Books.Api/GraphQL/Queries/BooksQuery.cs index 2736fe1..e1f4cef 100644 --- a/backend/Books.Api/GraphQL/Queries/BooksQuery.cs +++ b/backend/Books.Api/GraphQL/Queries/BooksQuery.cs @@ -1,4 +1,6 @@ +using Books.Api.Authorization; using Books.Api.Domain.Companies; +using Books.Api.Domain.UserAccess; using Books.Api.EventFlow.Repositories; using Books.Api.GraphQL.Types; using GraphQL; @@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType // companies: [CompanyType] Field>("companies") - .Description("Get all companies") + .Description("Get all companies accessible to the current user") .ResolveAsync(async ctx => { + var accessService = ctx.RequestServices!.GetRequiredService(); var repository = ctx.RequestServices!.GetRequiredService(); - return await repository.GetAllAsync(ctx.CancellationToken); + var userAccesses = await accessService.GetUserCompaniesAsync(ctx.CancellationToken); + var companyIds = userAccesses.Select(a => CompanyId.With(a.CompanyId)).ToList(); + if (companyIds.Count == 0) return Enumerable.Empty(); + return await repository.GetByIds(companyIds, ctx.CancellationToken); }); // company(id: ID!): CompanyType @@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType .ResolveAsync(async ctx => { var id = ctx.GetArgument("id"); + var accessService = ctx.RequestServices!.GetRequiredService(); + await accessService.RequireAccessAsync(id, CompanyRole.Viewer, ctx.CancellationToken); var repository = ctx.RequestServices!.GetRequiredService(); var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken); return companies.FirstOrDefault(); diff --git a/backend/Books.Api/Program.cs b/backend/Books.Api/Program.cs index fe7311c..0797f68 100644 --- a/backend/Books.Api/Program.cs +++ b/backend/Books.Api/Program.cs @@ -1,4 +1,5 @@ using Books.Api; +using Books.Api.Authorization; using Books.Api.GraphQL; using GraphQL; using GraphQL.Server.Ui.Altair; @@ -30,6 +31,25 @@ app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); +// Company context middleware - extracts X-Company-Id header and validates user access +app.UseCompanyContext(); + +// Require authentication for the GraphQL endpoint +app.UseWhen( + context => context.Request.Path.StartsWithSegments("/graphql"), + appBuilder => appBuilder.Use(async (context, next) => + { + if (context.User.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"errors\":[{\"message\":\"Authentication required\"}]}"); + return; + } + await next(); + }) +); + // Map controllers (for AuthController) app.MapControllers(); diff --git a/backend/Books.Api/Reporting/VatReportService.cs b/backend/Books.Api/Reporting/VatReportService.cs index 50d2d97..62e0390 100644 --- a/backend/Books.Api/Reporting/VatReportService.cs +++ b/backend/Books.Api/Reporting/VatReportService.cs @@ -14,6 +14,8 @@ public class VatReportService( ILogger logger) : IVatReportService { // Standard Danish VAT account numbers + // TODO: These should ideally come from company-level configuration, + // as different chart-of-accounts templates may use different numbers. private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms) private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms) @@ -133,13 +135,28 @@ public class VatReportService( report.TotalInputVat = report.BoxB; report.NetVat = report.TotalOutputVat - report.TotalInputVat; - // Basis amounts require tracking of original transaction amounts - // For now, calculate from VAT amounts assuming 25% rate + // Basis1 (Felt 1): Net domestic turnover with VAT + // TODO: Query actual net turnover from transactions with output VAT codes (U25) + // instead of back-calculating from VAT amount, which is inaccurate when + // mixed VAT rates or partial deductions are involved. + // Ideally: query revenue account balances filtered by VAT code U25. + // For now, back-calculate from output VAT assuming standard 25% rate if (report.BoxA > 0) { report.Basis1 = Math.Round(report.BoxA / 0.25m, 2); } + // TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions. + // Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods. + // report.BoxC = sum of VAT calculated on IEUV transactions. + // report.Basis3 = net purchase amount for IEUV transactions. + + // TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions. + // Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT + // on services purchased from abroad. + // report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions. + // report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions. + logger.LogInformation( "VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}", companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat); diff --git a/backend/Books.Api/Saft/Services/SaftExportService.cs b/backend/Books.Api/Saft/Services/SaftExportService.cs index cb10273..36ffc3e 100644 --- a/backend/Books.Api/Saft/Services/SaftExportService.cs +++ b/backend/Books.Api/Saft/Services/SaftExportService.cs @@ -332,6 +332,15 @@ public class SaftExportService( journals); } + /// + /// Maps internal account types to SAF-T standard account classifications. + /// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts + /// can represent either income (e.g., interest income, account 8000-8499) or + /// expense (e.g., interest expense, account 8500-8999). Without the account + /// number or balance direction, we cannot determine the correct mapping. + /// A future improvement should inspect the account number range or actual + /// balance direction to choose between "Income" and "Expense". + /// private static string MapAccountType(string accountType) { return accountType.ToLowerInvariant() switch @@ -343,7 +352,10 @@ public class SaftExportService( "cogs" => "Expense", "expense" => "Expense", "personnel" => "Expense", - "financial" => "Income", // Could be either, defaulting to Income + // Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999). + // Defaulting to "Expense" is safer since most financial items are costs (interest, fees). + // TODO: Determine mapping based on account number range or balance direction. + "financial" => "Expense", "extraordinary" => "Expense", _ => "Asset" }; diff --git a/backend/Books.Api/Startup.cs b/backend/Books.Api/Startup.cs index df44db2..7713284 100644 --- a/backend/Books.Api/Startup.cs +++ b/backend/Books.Api/Startup.cs @@ -1,5 +1,7 @@ using Books.Api.Authentication; +using Books.Api.Authorization; using Books.Api.EventFlow.Extensions; +using Books.Api.EventFlow.Repositories; using Books.Api.EventFlow.Infrastructure; using Books.Api.GraphQL; using Books.Api.Infrastructure; @@ -67,6 +69,13 @@ public static class Startup // Read model repositories services.AddRepositories(); + // HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers) + services.AddHttpContextAccessor(); + + // User company access repository and access service + services.AddScoped(); + services.AddScoped(); + // Logging decorators services.DecorateAsyncEventHandlersWithLogging(); @@ -76,7 +85,7 @@ public static class Startup .AddSystemTextJson() .AddDataLoader() .AddGraphTypes(typeof(BooksSchema).Assembly) - .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true)); + .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false)); // Memory cache for API key caching services.AddMemoryCache(); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 29c373a..5930d99 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import { GraphQLClient } from 'graphql-request'; import { QueryClient } from '@tanstack/react-query'; +import { useCompanyStore } from '@/stores/companyStore'; // GraphQL endpoint - configure based on environment const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql'; @@ -26,8 +27,8 @@ export const queryClient = new QueryClient({ refetchOnWindowFocus: true, }, mutations: { - // Retry mutations once - retry: 1, + // Never retry mutations - non-idempotent operations could create duplicates + retry: 0, }, }, }); @@ -38,7 +39,16 @@ export async function fetchGraphQL { try { - const data = await graphqlClient.request(query, variables); + // Get active company from store (outside React) + const activeCompany = useCompanyStore.getState().activeCompany; + + // Build headers with company ID if available + const headers: Record = {}; + if (activeCompany?.id) { + headers['X-Company-Id'] = activeCompany.id; + } + + const data = await graphqlClient.request(query, variables, headers); return data; } catch (error) { // Log error for debugging diff --git a/frontend/src/api/documentProcessing.ts b/frontend/src/api/documentProcessing.ts index d65c602..1bdaf83 100644 --- a/frontend/src/api/documentProcessing.ts +++ b/frontend/src/api/documentProcessing.ts @@ -126,7 +126,7 @@ export async function processDocument( throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)'); } if (response.status === 503) { - throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgaengelig'); + throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgængelig'); } throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`); } diff --git a/frontend/src/api/queries/accountQueries.ts b/frontend/src/api/queries/accountQueries.ts index c6c28b4..ce215a1 100644 --- a/frontend/src/api/queries/accountQueries.ts +++ b/frontend/src/api/queries/accountQueries.ts @@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account { description: acc.description, vatCode: acc.vatCodeId, isActive: acc.isActive, + isSystemAccount: acc.isSystemAccount, balance: 0, // Not returned from backend yet createdAt: acc.createdAt, updatedAt: acc.updatedAt, diff --git a/frontend/src/components/auth/CompanyGuard.tsx b/frontend/src/components/auth/CompanyGuard.tsx index 78372e9..0e1e002 100644 --- a/frontend/src/components/auth/CompanyGuard.tsx +++ b/frontend/src/components/auth/CompanyGuard.tsx @@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) { } // Note: Users with existing companies CAN access the wizard to create more } - }, [companies, isLoading, navigate, location.pathname]); + }, [companies, isLoading, navigate]); // Note: location.pathname intentionally omitted to prevent infinite loop // Reset navigation ref when companies change (user created a company) useEffect(() => { diff --git a/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx index f31fc64..c262263 100644 --- a/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx +++ b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx @@ -108,7 +108,7 @@ export function DocumentUploadModal({ message.success('Bogfoert!'); onConfirm(); } catch (err) { - message.error('Kunne ikke bogfoere. Proev igen.'); + message.error('Kunne ikke bogføre. Prøv igen.'); } finally { setIsPosting(false); } @@ -270,7 +270,7 @@ export function DocumentUploadModal({ Annuller , , - - - + + + + + } + /> + + {/* Filters */} @@ -314,7 +313,7 @@ export default function Bankafstemning() { formatCurrency(value as number)} @@ -374,7 +373,7 @@ export default function Bankafstemning() { disabled={!canMatch} > Match valgte ({selectedBankTransactions.length} bank,{' '} - {selectedLedgerTransactions.length} bogforing) + {selectedLedgerTransactions.length} bogføring) @@ -496,7 +495,7 @@ export default function Bankafstemning() { - Bogforingsposter + Bogføringsposter {ledgerEntries.length} uafstemte } @@ -505,7 +504,7 @@ export default function Bankafstemning() { > {ledgerEntries.length === 0 ? ( ) : ( @@ -674,22 +673,22 @@ export default function Bankafstemning() { rules={[{ required: true }]} > diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e5c0c25..87bbeef 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,6 @@ import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd'; import { BankOutlined, - RiseOutlined, - FallOutlined, FileTextOutlined, CheckCircleOutlined, ClockCircleOutlined, @@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries'; import { useVatReport } from '@/api/queries/vatQueries'; import { formatCurrency, formatDate } from '@/lib/formatters'; import { accountingColors } from '@/styles/theme'; +import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer'; +import { PageHeader } from '@/components/shared/PageHeader'; -const { Title, Text } = Typography; +const { Text } = Typography; // Types for chart data interface CashFlowDataPoint { @@ -47,9 +47,13 @@ export default function Dashboard() { const { activeCompany } = useCompanyStore(); const { currentFiscalYear } = usePeriodStore(); - // Define date interval - const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD'); - const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD'); + // Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type + const periodStart = currentFiscalYear?.startDate + ? dayjs(currentFiscalYear.startDate).format('YYYY-MM-DD') + : dayjs().startOf('year').format('YYYY-MM-DD'); + const periodEnd = currentFiscalYear?.endDate + ? dayjs(currentFiscalYear.endDate).format('YYYY-MM-DD') + : dayjs().endOf('year').format('YYYY-MM-DD'); const { data: balances = [], isLoading: balancesLoading } = useAccountBalances( activeCompany?.id, @@ -207,7 +211,7 @@ export default function Dashboard() { const revenueExpenseConfig = { data: cashFlowData.flatMap((d) => [ - { month: d.month, type: 'Indtaegter', value: d.inflow }, + { month: d.month, type: 'Indtægter', value: d.inflow }, { month: d.month, type: 'Udgifter', value: d.outflow }, ]), isGroup: true, @@ -225,15 +229,13 @@ export default function Dashboard() { return (
- {/* Header */} -
- - Dashboard - - - {company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')} - -
+ + + {/* KPI Cards */} @@ -249,13 +251,9 @@ export default function Dashboard() { formatter={(value) => formatCurrency(value as number)} />
- = 0 ? 'green' : 'red'} - icon={metrics.cashChange >= 0 ? : } - > - {metrics.cashChange >= 0 ? '+' : ''} - {(metrics.cashChange * 100).toFixed(1)}% denne maaned - + + Baseret på kontosaldi i regnskabsåret +
@@ -296,10 +294,9 @@ export default function Dashboard() { formatter={(value) => formatCurrency(value as number)} />
- = 0 ? 'orange' : 'green'}> - {metrics.apChange >= 0 ? '+' : ''} - {(metrics.apChange * 100).toFixed(1)}% denne maaned - + + Baseret på kontosaldi i regnskabsåret +
@@ -315,32 +312,33 @@ export default function Dashboard() { formatter={(value) => formatCurrency(value as number)} />
- Naeste frist: 1. marts + Se momsindberetning
+ {/* Charts Row */} {/* Cash Flow Chart */} - + {cashFlowData.length > 0 ? ( ) : ( - + )} {/* Revenue vs Expenses */} - + {cashFlowData.length > 0 ? ( ) : ( - + )} @@ -354,7 +352,7 @@ export default function Dashboard() { {expenseBreakdown.length > 0 ? ( ) : ( - + )} @@ -440,7 +438,9 @@ export default function Dashboard() { - Momsindberetning forfalder om 14 dage + + Se momsindberetning + {metrics.overdueInvoices > 0 && ( diff --git a/frontend/src/pages/Fakturaer.tsx b/frontend/src/pages/Fakturaer.tsx index fa136e5..76ebef1 100644 --- a/frontend/src/pages/Fakturaer.tsx +++ b/frontend/src/pages/Fakturaer.tsx @@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters'; import { spacing } from '@/styles/designTokens'; import { accountingColors } from '@/styles/theme'; import { AmountText } from '@/components/shared/AmountText'; +import { PageHeader } from '@/components/shared/PageHeader'; import { EmptyState } from '@/components/shared/EmptyState'; import type { ColumnsType } from 'antd/es/table'; @@ -457,25 +458,16 @@ export default function Fakturaer() { return (
- {/* Header */} -
-
- - Fakturaer - - {company?.name} -
- -
+ } onClick={handleCreateInvoice}> + Ny fakturakladde + + } + /> {/* Error State */} {error && ( diff --git a/frontend/src/pages/Kassekladde.tsx b/frontend/src/pages/Kassekladde.tsx index f1ba651..cbc7bbe 100644 --- a/frontend/src/pages/Kassekladde.tsx +++ b/frontend/src/pages/Kassekladde.tsx @@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore'; import { useActiveAccounts } from '@/api/queries/accountQueries'; import { useJournalEntryDrafts } from '@/api/queries/draftQueries'; import { formatCurrency } from '@/lib/formatters'; +import { PageHeader } from '@/components/shared/PageHeader'; import { validateDoubleEntry } from '@/lib/accounting'; import type { TransactionLine, JournalEntryDraft } from '@/types/accounting'; +import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations'; +import { usePeriodStore } from '@/stores/periodStore'; -const { Title, Text } = Typography; +const { Text } = Typography; const { RangePicker } = DatePicker; // Display type for journal entry drafts @@ -62,6 +65,13 @@ export default function Kassekladde() { { debit: 0, credit: 0 }, ]); + const { currentFiscalYear } = usePeriodStore(); + + // Mutation hooks + const createDraftMutation = useCreateJournalEntryDraft(); + const updateDraftMutation = useUpdateJournalEntryDraft(); + const discardDraftMutation = useDiscardJournalEntryDraft(); + // Fetch accounts and drafts from API const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id); const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id); @@ -124,7 +134,7 @@ export default function Kassekladde() { return Annulleret; } return value ? ( - Bogfort + Bogført ) : ( Kladde ); @@ -189,17 +199,56 @@ export default function Kassekladde() { setIsModalOpen(true); break; case 'copy': - message.success(`Bilag ${record.transactionNumber} kopieret`); + if (!activeCompany) { + message.error('Ingen virksomhed valgt'); + break; + } + (async () => { + try { + const draft = await createDraftMutation.mutateAsync({ + companyId: activeCompany.id, + name: `Kopi af ${record.description}`, + description: record.description, + fiscalYearId: currentFiscalYear?.id, + }); + // Copy lines to the new draft + if (record.lines && record.lines.length > 0) { + await updateDraftMutation.mutateAsync({ + id: draft.id, + lines: record.lines.map((l, idx) => ({ + lineNumber: idx + 1, + accountId: l.accountId, + debitAmount: l.debitAmount || 0, + creditAmount: l.creditAmount || 0, + description: l.description, + vatCode: l.vatCode, + })), + }); + } + message.success(`Bilag ${record.transactionNumber} kopieret`); + } catch (error) { + if (error instanceof Error) { + message.error(`Fejl ved kopiering: ${error.message}`); + } + } + })(); break; case 'void': Modal.confirm({ title: 'Annuller bilag', - content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`, + content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`, okText: 'Annuller bilag', okType: 'danger', cancelText: 'Fortryd', - onOk: () => { - message.success(`Bilag ${record.transactionNumber} annulleret`); + onOk: async () => { + try { + await discardDraftMutation.mutateAsync(record.id); + message.success(`Bilag ${record.transactionNumber} annulleret`); + } catch (error) { + if (error instanceof Error) { + message.error(`Fejl ved annullering: ${error.message}`); + } + } }, }); break; @@ -238,18 +287,72 @@ export default function Kassekladde() { const validation = validateDoubleEntry(lines as TransactionLine[]); if (!validation.valid) { message.error( - `Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})` + `Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})` ); return; } - console.log('Submitting:', { ...values, lines }); - message.success('Bilag oprettet'); + if (!activeCompany) { + message.error('Ingen virksomhed valgt'); + return; + } + + if (editingDraft) { + // Update existing draft + await updateDraftMutation.mutateAsync({ + id: editingDraft.id, + name: values.description, + documentDate: values.date?.format('YYYY-MM-DD'), + description: values.description, + fiscalYearId: currentFiscalYear?.id, + lines: lines + .filter(l => l.accountId) + .map((l, idx) => ({ + lineNumber: idx + 1, + accountId: l.accountId!, + debitAmount: l.debit || 0, + creditAmount: l.credit || 0, + description: l.description, + vatCode: l.vatCode, + })), + }); + message.success('Bilag opdateret'); + } else { + // Create new draft + const draft = await createDraftMutation.mutateAsync({ + companyId: activeCompany.id, + name: values.description, + documentDate: values.date?.format('YYYY-MM-DD'), + description: values.description, + fiscalYearId: currentFiscalYear?.id, + }); + + // Update the draft with lines + if (lines.some(l => l.accountId)) { + await updateDraftMutation.mutateAsync({ + id: draft.id, + lines: lines + .filter(l => l.accountId) + .map((l, idx) => ({ + lineNumber: idx + 1, + accountId: l.accountId!, + debitAmount: l.debit || 0, + creditAmount: l.credit || 0, + description: l.description, + vatCode: l.vatCode, + })), + }); + } + message.success('Bilag oprettet'); + } + setIsModalOpen(false); form.resetFields(); setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); } catch (error) { - console.error('Validation failed:', error); + if (error instanceof Error) { + message.error(`Fejl: ${error.message}`); + } } }; @@ -258,21 +361,11 @@ export default function Kassekladde() { if (isLoading) { return (
-
-
- - Kassekladde - - {activeCompany?.name} -
-
+
); @@ -280,32 +373,23 @@ export default function Kassekladde() { return (
- {/* Header */} -
-
- - Kassekladde - - {activeCompany?.name} -
- -
+ } + onClick={() => { + setEditingDraft(null); + setIsModalOpen(true); + }} + > + Nyt bilag + + } + /> {/* Filters */} @@ -329,7 +413,7 @@ export default function Kassekladde() { style={{ width: 120 }} allowClear options={[ - { value: 'posted', label: 'Bogfort' }, + { value: 'posted', label: 'Bogført' }, { value: 'draft', label: 'Kladde' }, { value: 'discarded', label: 'Annulleret' }, ]} @@ -373,7 +457,7 @@ export default function Kassekladde() { @@ -409,7 +493,7 @@ export default function Kassekladde() { } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - style={{ marginBottom: spacing.md }} - allowClear - aria-label="Sog i kontoplan" - /> - - - - - {/* Account Details */} - - {selectedAccount ? ( - - {selectedAccount.accountNumber} - {selectedAccount.name} - {!selectedAccount.isActive && ( - Inaktiv - )} - - } - size="small" - extra={ - - } - role="region" - aria-label={`Detaljer for konto ${selectedAccount.accountNumber}`} - > - -
- formatCurrency(value as number)} - valueStyle={{ - color: - selectedAccount.balance >= 0 - ? accountingColors.credit - : accountingColors.debit, - }} - /> -
- -
- ), - }, - { - key: 'info', - label: 'Kontooplysninger', - children: ( -
- - - Kontonummer -
- {selectedAccount.accountNumber} -
- - - Kontotype -
- {getAccountTypeName(selectedAccount.type)} -
- - - Status -
- {selectedAccount.isActive ? ( - Aktiv - ) : ( - Inaktiv - )} -
- - - Momskode -
- {selectedAccount.vatCode || 'Ingen'} -
- -
-
- ), - }, - ]} - /> - - ) : ( - - } - title="Ingen konto valgt" - description="Vaelg en konto i kontoplanen til venstre for at se detaljer og bevaegelser." - compact - /> - - )} - - - - {/* Create/Edit Account Modal */} - setIsModalOpen(false)} - onOk={handleSubmit} - okText="Gem" - cancelText="Annuller" + } + placeholder="Søg på navn eller nummer..." + style={{ width: 300 }} + allowClear + value={searchText} + onChange={e => setSearchText(e.target.value)} + /> + } + extra={ + + + Vis inaktive + + + + } > -
- - - + ({ + onClick: () => handleRowClick(record), + style: { cursor: 'pointer' }, + })} + /> + - - - + {/* Details/Edit Drawer */} + + {selectedAccount?.name} + {selectedAccount?.accountNumber} + + ) + } + width={500} + open={isDrawerOpen} + onClose={handleCloseDrawer} + extra={ + !isEditMode && selectedAccount && ( + + ) + } + footer={ + isEditMode && ( +
+ + + + +
+ ) + } + > + {isEditMode ? ( + + +
+ + + + + + + ({ - value: type, - label: getAccountTypeName(type), - }))} - /> - + + + - - + + + + + + + + - - - + + + + + ) : selectedAccount ? ( + + {/* Balance Summary */} + + = 0 ? accountingColors.credit : accountingColors.debit, + fontSize: 24, + }} + /> + + Beregnet for indeværende regnskabsår + + - - setShowInactive(value === 'all')} - style={{ width: 150 }} - options={[ - { value: 'active', label: 'Kun aktive' }, - { value: 'all', label: 'Alle kunder' }, - ]} - /> + + Vis inaktive + + diff --git a/frontend/src/pages/Momsindberetning.tsx b/frontend/src/pages/Momsindberetning.tsx index 6029d11..705cc83 100644 --- a/frontend/src/pages/Momsindberetning.tsx +++ b/frontend/src/pages/Momsindberetning.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Typography, Card, @@ -15,24 +15,26 @@ import { Alert, Modal, Descriptions, - message, + Empty, + Skeleton, } from 'antd'; import { DownloadOutlined, SendOutlined, - CheckCircleOutlined, - ClockCircleOutlined, - ExclamationCircleOutlined, } from '@ant-design/icons'; import { Pie } from '@ant-design/charts'; import dayjs from 'dayjs'; import { useCompany } from '@/hooks/useCompany'; -import { formatCurrency, formatDate, formatPeriod } from '@/lib/formatters'; +import { useCompanyStore } from '@/stores/companyStore'; +import { useVatReport } from '@/api/queries/vatQueries'; +import { formatCurrency, formatPeriod } from '@/lib/formatters'; import { accountingColors } from '@/styles/theme'; +import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer'; +import { PageHeader } from '@/components/shared/PageHeader'; -const { Title, Text } = Typography; +const { Text } = Typography; -// Danish VAT boxes (Rubrikker) +// Danish VAT boxes (Rubrikker) - mapped from backend VatReport interface VATBox { boxNumber: number; nameDanish: string; @@ -42,134 +44,90 @@ interface VATBox { basis?: number; } -const mockVATReport: VATBox[] = [ - { - boxNumber: 1, - nameDanish: 'Salgsmoms', - nameEnglish: 'Output VAT', - description: 'Moms af varer og ydelser solgt i Danmark (25%)', - amount: 62500, - basis: 250000, - }, - { - boxNumber: 2, - nameDanish: 'Moms af varekøb i udlandet (EU)', - nameEnglish: 'VAT on goods from EU', - description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande', - amount: 5000, - basis: 20000, - }, - { - boxNumber: 3, - nameDanish: 'Moms af ydelseskøb i udlandet', - nameEnglish: 'VAT on services from abroad', - description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt', - amount: 2500, - basis: 10000, - }, - { - boxNumber: 4, - nameDanish: 'Købsmoms', - nameEnglish: 'Input VAT', - description: 'Fradragsberettiget moms af køb', - amount: 35000, - basis: 140000, - }, - { - boxNumber: 5, - nameDanish: 'Olie- og flaskegasafgift', - nameEnglish: 'Oil and gas duty', - description: 'Godtgørelse af olie- og flaskegasafgift', - amount: 0, - }, - { - boxNumber: 6, - nameDanish: 'Elafgift', - nameEnglish: 'Electricity duty', - description: 'Godtgørelse af elafgift', - amount: 1200, - }, - { - boxNumber: 7, - nameDanish: 'Naturgas- og bygasafgift', - nameEnglish: 'Natural gas duty', - description: 'Godtgørelse af naturgas- og bygasafgift', - amount: 0, - }, - { - boxNumber: 8, - nameDanish: 'Kulafgift', - nameEnglish: 'Coal duty', - description: 'Godtgørelse af kulafgift', - amount: 0, - }, - { - boxNumber: 9, - nameDanish: 'CO2-afgift', - nameEnglish: 'CO2 duty', - description: 'Godtgørelse af CO2-afgift', - amount: 300, - }, -]; - -// Historical submissions -const mockSubmissions = [ - { - id: '1', - period: '2024-10', - submittedAt: '2024-11-28', - status: 'accepted', - netVAT: 28500, - referenceNumber: 'SKAT-2024-123456', - }, - { - id: '2', - period: '2024-07', - submittedAt: '2024-08-30', - status: 'accepted', - netVAT: 32100, - referenceNumber: 'SKAT-2024-789012', - }, - { - id: '3', - period: '2024-04', - submittedAt: '2024-05-29', - status: 'accepted', - netVAT: -5600, - referenceNumber: 'SKAT-2024-345678', - }, -]; - export default function Momsindberetning() { const { company } = useCompany(); + const { activeCompany } = useCompanyStore(); const [selectedPeriod, setSelectedPeriod] = useState( dayjs().subtract(1, 'month').startOf('month') ); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly'); - // Calculate totals - const outputVAT = mockVATReport - .filter((box) => [1, 2, 3].includes(box.boxNumber)) - .reduce((sum, box) => sum + box.amount, 0); + // Calculate period dates based on selection + const periodStart = useMemo(() => { + if (periodType === 'quarterly') { + return selectedPeriod.startOf('quarter').format('YYYY-MM-DD'); + } + return selectedPeriod.startOf('month').format('YYYY-MM-DD'); + }, [selectedPeriod, periodType]); - const inputVAT = mockVATReport - .filter((box) => box.boxNumber === 4) - .reduce((sum, box) => sum + box.amount, 0); + const periodEnd = useMemo(() => { + if (periodType === 'quarterly') { + return selectedPeriod.endOf('quarter').format('YYYY-MM-DD'); + } + return selectedPeriod.endOf('month').format('YYYY-MM-DD'); + }, [selectedPeriod, periodType]); - const energyDuties = mockVATReport - .filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber)) - .reduce((sum, box) => sum + box.amount, 0); + // Fetch VAT report from backend + const { data: vatReport, isLoading, error } = useVatReport( + activeCompany?.id, + periodStart, + periodEnd + ); - const netVAT = outputVAT - inputVAT - energyDuties; + // Map backend VatReport to UI's rubrik display + const vatBoxes: VATBox[] = useMemo(() => { + if (!vatReport) return []; + return [ + { + boxNumber: 1, + nameDanish: 'Salgsmoms', + nameEnglish: 'Output VAT', + description: 'Moms af varer og ydelser solgt i Danmark (25%)', + amount: vatReport.boxA, + basis: vatReport.basis1, + }, + { + boxNumber: 2, + nameDanish: 'Moms af varekob i udlandet (EU)', + nameEnglish: 'VAT on goods from EU', + description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande', + amount: vatReport.boxC, + basis: vatReport.basis3, + }, + { + boxNumber: 3, + nameDanish: 'Moms af ydelseskob i udlandet', + nameEnglish: 'VAT on services from abroad', + description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt', + amount: vatReport.boxD, + basis: vatReport.basis4, + }, + { + boxNumber: 4, + nameDanish: 'Kobsmoms', + nameEnglish: 'Input VAT', + description: 'Fradragsberettiget moms af kob', + amount: vatReport.boxB, + basis: undefined, // Backend doesn't provide a specific basis for input VAT + }, + ]; + }, [vatReport]); + + // Calculate totals from real data + const outputVAT = vatReport?.totalOutputVat ?? 0; + const inputVAT = vatReport?.totalInputVat ?? 0; + const netVAT = vatReport?.netVat ?? 0; // Pie chart config - const pieData = [ - { type: 'Salgsmoms', value: mockVATReport[0].amount }, - { type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount }, - { type: 'Købsmoms (fradrag)', value: inputVAT }, - { type: 'Energiafgifter (fradrag)', value: energyDuties }, - ]; + const pieData = useMemo(() => { + if (!vatReport) return []; + return [ + { type: 'Salgsmoms', value: vatReport.boxA }, + { type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) }, + { type: 'Kobsmoms (fradrag)', value: inputVAT }, + ].filter(d => d.value > 0); + }, [vatReport, inputVAT]); const pieConfig = { data: pieData, @@ -243,96 +201,49 @@ export default function Momsindberetning() { }, ]; - const handleSubmit = () => { - Modal.confirm({ - title: 'Indsend momsangivelse', - icon: , - content: ( -
-

Du er ved at indsende momsangivelse for:

-

- Periode: {formatPeriod(selectedPeriod.toDate())} -

-

- Moms til betaling:{' '} - = 0 ? accountingColors.debit : accountingColors.credit, - }} - > - {formatCurrency(Math.abs(netVAT))} - {netVAT < 0 ? ' (tilgode)' : ''} - -

- -
- ), - okText: 'Indsend til SKAT', - cancelText: 'Annuller', - onOk: () => { - message.success('Momsangivelse indsendt til SKAT'); - }, - }); - }; - - const getStatusTag = (status: string) => { - switch (status) { - case 'accepted': - return ( - }> - Godkendt - - ); - case 'pending': - return ( - }> - Afventer - - ); - case 'rejected': - return ( - }> - Afvist - - ); - default: - return {status}; - } - }; + // Loading state + if (isLoading) { + return ( +
+ + +
+ ); + } return (
- {/* Header */} -
-
- - Momsindberetning - - {company?.name} -
- - - - -
+ + + + + } + /> + + {/* SKAT submission notice */} + {/* Period Selection */} @@ -343,7 +254,7 @@ export default function Momsindberetning() { onChange={setPeriodType} style={{ width: 120 }} options={[ - { value: 'monthly', label: 'Månedlig' }, + { value: 'monthly', label: 'Maanedlig' }, { value: 'quarterly', label: 'Kvartalsvis' }, ]} /> @@ -356,12 +267,26 @@ export default function Momsindberetning() { Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')} + {vatReport && ( + {vatReport.transactionCount} transaktioner + )} + {/* Error state */} + {error && ( + + )} + {/* Summary Cards */} -
+ - + - - - formatCurrency(value as number)} - valueStyle={{ color: accountingColors.credit }} - /> - - - + = 0 ? 'Moms til betaling' : 'Moms til gode'} @@ -413,87 +327,57 @@ export default function Momsindberetning() { -
( - - - - Moms til betaling / tilgode - - - = 0 ? accountingColors.debit : accountingColors.credit, - }} - > - {netVAT >= 0 ? '' : '-'} - {formatCurrency(Math.abs(netVAT))} - - - - - )} - /> + {vatBoxes.length > 0 ? ( +
( + + + + Moms til betaling / tilgode + + + = 0 ? accountingColors.debit : accountingColors.credit, + }} + > + {netVAT >= 0 ? '' : '-'} + {formatCurrency(Math.abs(netVAT))} + + + + + )} + /> + ) : ( + + )} - + {pieData.length > 0 ? ( + + ) : ( + + )} - {mockSubmissions.map((sub) => ( -
-
-
- - {dayjs(sub.period, 'YYYY-MM').format('MMMM YYYY')} - -
- - Indsendt {formatDate(sub.submittedAt)} - -
-
- {getStatusTag(sub.status)} -
- = 0 - ? accountingColors.debit - : accountingColors.credit, - }} - > - {formatCurrency(sub.netVAT)} - -
-
-
- ))} + + + Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret. +
@@ -512,18 +396,25 @@ export default function Momsindberetning() { Download PDF , , ]} > + + {company?.name} {company?.cvr} @@ -537,22 +428,24 @@ export default function Momsindberetning() { -
formatCurrency(v), - }, - ]} - rowKey="boxNumber" - pagination={false} - size="small" - /> + {vatBoxes.length > 0 && ( +
formatCurrency(v), + }, + ]} + rowKey="boxNumber" + pagination={false} + size="small" + /> + )} diff --git a/frontend/src/pages/Ordrer.tsx b/frontend/src/pages/Ordrer.tsx index feace5e..b1c8b29 100644 --- a/frontend/src/pages/Ordrer.tsx +++ b/frontend/src/pages/Ordrer.tsx @@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens'; import { accountingColors } from '@/styles/theme'; import { AmountText } from '@/components/shared/AmountText'; import { EmptyState } from '@/components/shared/EmptyState'; +import { PageHeader } from '@/components/shared/PageHeader'; import type { ColumnsType } from 'antd/es/table'; import type { Order, OrderLine, OrderStatus } from '@/types/order'; import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order'; @@ -140,7 +141,7 @@ export default function Ordrer() { const handleSubmitCreate = async () => { if (!company || !currentFiscalYear) { - showError('Virksomhed eller regnskabsaar ikke valgt'); + showError('Virksomhed eller regnskabsår ikke valgt'); return; } try { @@ -226,12 +227,12 @@ export default function Ordrer() { const handleConfirmOrder = async () => { if (!selectedOrder) return; if (selectedOrder.lines.length === 0) { - showWarning('Tilfoej mindst en linje foer bekraeftelse'); + showWarning('Tilføj mindst en linje før bekræftelse'); return; } try { await confirmOrderMutation.mutateAsync(selectedOrder.id); - showSuccess('Ordre bekraeftet'); + showSuccess('Ordre bekræftet'); // Refresh would happen via query invalidation } catch (err) { if (err instanceof Error) { @@ -276,7 +277,7 @@ export default function Ordrer() { const handleSubmitConvert = async () => { if (!selectedOrder || selectedLinesToInvoice.length === 0) { - showWarning('Vaelg mindst en linje at fakturere'); + showWarning('Vælg mindst en linje at fakturere'); return; } try { @@ -346,7 +347,7 @@ export default function Ordrer() { render: (value: string | undefined) => (value ? formatDate(value) : '-'), }, { - title: 'Beloeb', + title: 'Beløb', dataIndex: 'amountTotal', key: 'amountTotal', width: 120, @@ -371,7 +372,7 @@ export default function Ordrer() { align: 'center', filters: [ { text: 'Kladde', value: 'draft' }, - { text: 'Bekraeftet', value: 'confirmed' }, + { text: 'Bekræftet', value: 'confirmed' }, { text: 'Delvist faktureret', value: 'partially_invoiced' }, { text: 'Fuldt faktureret', value: 'fully_invoiced' }, { text: 'Annulleret', value: 'cancelled' }, @@ -399,37 +400,28 @@ export default function Ordrer() { return (
- {/* Header */} -
-
- - Ordrer - - {company?.name} -
- -
+ } onClick={handleCreateOrder}> + Ny ordre + + } + /> {/* Error State */} {error && ( refetch()}> - Proev igen + Prøv igen } /> @@ -454,7 +446,7 @@ export default function Ordrer() {
@@ -463,7 +455,7 @@ export default function Ordrer() { } value={searchText} onChange={(e) => setSearchText(e.target.value)} @@ -491,7 +483,7 @@ export default function Ordrer() { options={[ { value: 'all', label: 'Alle status' }, { value: 'draft', label: 'Kladde' }, - { value: 'confirmed', label: 'Bekraeftet' }, + { value: 'confirmed', label: 'Bekræftet' }, { value: 'partially_invoiced', label: 'Delvist faktureret' }, { value: 'fully_invoiced', label: 'Fuldt faktureret' }, { value: 'cancelled', label: 'Annulleret' }, @@ -503,7 +495,7 @@ export default function Ordrer() { {/* Order Table */} {loading ? ( - +
) : filteredOrders.length > 0 ? ( @@ -518,7 +510,7 @@ export default function Ordrer() { - + @@ -612,7 +604,7 @@ export default function Ordrer() { onClick={handleOpenAddLineModal} loading={addOrderLineMutation.isPending} > - Tilfoej linje + Tilføj linje )} {canShowConvertToInvoice(selectedOrder) && (
{selectedOrder.notes && ( <> - Bemaerkninger: + Bemærkninger:

{selectedOrder.notes}

)} {selectedOrder.cancelledReason && ( <> - Annulleringsaarsag: + Annulleringsårsag:

{selectedOrder.cancelledReason}

)} @@ -746,7 +738,7 @@ export default function Ordrer() {
- Beloeb ex. moms: + Beløb ex. moms: {formatCurrency(selectedOrder.amountExVat)}
@@ -794,7 +786,7 @@ export default function Ordrer() { > @@ -812,14 +804,14 @@ export default function Ordrer() { {/* Add Line Modal */} { setIsAddLineModalOpen(false); setSelectedProductId(null); }} onOk={handleSubmitAddLine} - okText="Tilfoej" + okText="Tilføj" cancelText="Annuller" confirmLoading={addOrderLineMutation.isPending} width={550} @@ -840,7 +832,7 @@ export default function Ordrer() { optionType="button" buttonStyle="solid" > - Vaelg produkt + Vælg produkt Fritekst @@ -850,11 +842,11 @@ export default function Ordrer() { label="Produkt" required validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined} - help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined} + help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined} > - +
+ + +
+ + + + ); } if (error) { return ( - +
+ + window.location.reload()}> + Prøv igen + + } + /> +
); } return (
-
- - Produkter - - -
+ } onClick={handleCreate}> + Opret produkt + + } + /> {/* Statistics */} @@ -373,12 +399,10 @@ export default function Produkter() { allowClear style={{ width: 300 }} /> - + + Vis inaktive + + diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8109fd8..5b464a7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -12,7 +12,7 @@ import { Divider, message, Space, - Tag, + Empty, } from 'antd'; import { SaveOutlined, @@ -22,6 +22,8 @@ import { SettingOutlined, } from '@ant-design/icons'; import { useCompany } from '@/hooks/useCompany'; +import { useUpdateCompany } from '@/api/mutations/companyMutations'; +import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer'; const { Title, Text } = Typography; @@ -29,24 +31,45 @@ export default function Settings() { const { company } = useCompany(); const [companyForm] = Form.useForm(); const [preferencesForm] = Form.useForm(); + const updateCompanyMutation = useUpdateCompany(); const handleSaveCompany = async () => { try { const values = await companyForm.validateFields(); - console.log('Saving company:', values); + + if (!company?.id) { + message.error('Ingen virksomhed valgt'); + return; + } + + await updateCompanyMutation.mutateAsync({ + id: company.id, + input: { + name: values.name, + cvr: values.cvr, + address: values.address, + city: values.city, + postalCode: values.postalCode, + }, + }); message.success('Virksomhedsoplysninger gemt'); } catch (error) { - console.error('Validation failed:', error); + if (error instanceof Error) { + message.error(`Fejl ved gemning: ${error.message}`); + } } }; const handleSavePreferences = async () => { try { - const values = await preferencesForm.validateFields(); - console.log('Saving preferences:', values); - message.success('Præferencer gemt'); + await preferencesForm.validateFields(); + // TODO: Backend does not yet have a preferences mutation. + // Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint. + message.info('Præferencer er endnu ikke forbundet til backend'); } catch (error) { - console.error('Validation failed:', error); + if (error instanceof Error) { + message.error(`Fejl ved gemning: ${error.message}`); + } } }; @@ -282,56 +305,19 @@ export default function Settings() { Tilknyttede bankkonti - +
- {/* Mock bank accounts */} - {[ - { - id: '1', - bankName: 'Danske Bank', - accountName: 'Erhvervskonto', - accountNumber: '1234-5678901234', - ledgerAccount: '1000 - Bank', - isActive: true, - }, - { - id: '2', - bankName: 'Nordea', - accountName: 'Opsparingskonto', - accountNumber: '9876-5432109876', - ledgerAccount: '1010 - Bank opsparing', - isActive: true, - }, - ].map((account) => ( - - -
- - - {account.bankName} - {account.accountName} - {account.isActive && Aktiv} - - {account.accountNumber} - - Bogføringskonto: {account.ledgerAccount} - - - - - - - - - - - - ))} + + + ), @@ -345,6 +331,7 @@ export default function Settings() { ), children: ( +
Brugere med adgang - +
- {/* Mock users */} - {[ - { - id: '1', - name: 'Admin Bruger', - email: 'admin@example.com', - role: 'Administrator', - lastLogin: '2025-01-17', - }, - { - id: '2', - name: 'Bogholder', - email: 'bogholder@example.com', - role: 'Bogholder', - lastLogin: '2025-01-16', - }, - ].map((user) => ( - - -
- - {user.name} - {user.email} - - - - - - {user.role} - - - Sidste login: {user.lastLogin} - - - - - - - ))} + + Brugere med adgang til denne virksomhed vil blive vist her, + når funktionen er implementeret. + ), diff --git a/frontend/src/stores/companyStore.ts b/frontend/src/stores/companyStore.ts index 5417cf5..9452ddb 100644 --- a/frontend/src/stores/companyStore.ts +++ b/frontend/src/stores/companyStore.ts @@ -1,18 +1,18 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { Company, CompanyRole } from '@/types/accounting'; +import type { CompanyRole, CompanyWithRole } from '@/types/accounting'; interface CompanyState { - // Current active company - activeCompany: Company | null; - // List of available companies - companies: Company[]; + // Current active company (includes role from myCompanies query) + activeCompany: CompanyWithRole | null; + // List of available companies (includes role from myCompanies query) + companies: CompanyWithRole[]; // Loading state isLoading: boolean; // Actions - setActiveCompany: (company: Company) => void; - setCompanies: (companies: Company[]) => void; + setActiveCompany: (company: CompanyWithRole) => void; + setCompanies: (companies: CompanyWithRole[]) => void; setLoading: (loading: boolean) => void; clearActiveCompany: () => void; } @@ -53,11 +53,11 @@ export const useCompanies = () => useCompanyStore((state) => state.companies); // Get the current user's role for the active company -// Returns 'owner' as default for now - in production this would come from the server +// Returns the role from the myCompanies query data stored on the active company export const useActiveCompanyRole = (): CompanyRole => { - // Placeholder: In a real implementation, this would check the user's role - // for the currently active company from the server/auth context - return 'owner'; + const activeCompany = useCompanyStore((state) => state.activeCompany); + // Return the actual role from the CompanyWithRole data, default to 'viewer' if not set + return activeCompany?.role ?? 'viewer'; }; // Helper functions for user roles @@ -88,9 +88,8 @@ export function getRoleColor(role: CompanyRole): string { } // Hook to check if current user can administer the company -// This is a placeholder - in a real app, this would check the user's role +// Checks if the user has Owner role for the active company export function useCanAdmin(): boolean { - // For now, return true to allow all users to manage access - // In production, this should check the current user's role - return true; + const role = useActiveCompanyRole(); + return role === 'owner'; } diff --git a/frontend/src/types/accounting.ts b/frontend/src/types/accounting.ts index fe42c45..e778f89 100644 --- a/frontend/src/types/accounting.ts +++ b/frontend/src/types/accounting.ts @@ -23,6 +23,7 @@ export interface Account { type: AccountType; parentId?: string; isActive: boolean; + isSystemAccount?: boolean; description?: string; vatCode?: string; balance: number; diff --git a/frontend/src/types/vat.ts b/frontend/src/types/vat.ts index 3308aaf..40fd25c 100644 --- a/frontend/src/types/vat.ts +++ b/frontend/src/types/vat.ts @@ -22,8 +22,8 @@ export type VATCode = * VAT code type classification */ export type VATCodeType = - | 'output' // Udgaaende moms (salg) - | 'input' // Indgaaende moms (koeb) + | 'output' // Udgående moms (salg) + | 'input' // Indgående moms (køb) | 'reverse_charge' // Omvendt betalingspligt | 'exempt' // Momsfritaget | 'none'; // Ingen moms diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 91357a4..a903e1d 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file