From 709d0a4739a9643556086807c933e2431feb0a36 Mon Sep 17 00:00:00 2001 From: Nicolaj Hartmann Date: Fri, 6 Feb 2026 00:18:19 +0100 Subject: [PATCH] Audit v2: fix security, data integrity, compliance, bugs, encoding, UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 --- .beads/issues.jsonl | 4 + .../Invoices/InvoiceCommandHandlers.cs | 24 +- .../JournalEntryDraftCommandHandlers.cs | 12 +- .../Controllers/AttachmentController.cs | 14 + .../Controllers/BankingController.cs | 2 + .../Controllers/WeatherForecastController.cs | 25 -- ...l => 007b_JournalEntryDraftCompliance.sql} | 0 .../Domain/Attachments/AttachmentAggregate.cs | 3 +- .../Attachments/Events/AttachmentEvents.cs | 6 + .../Domain/Companies/CompanyAggregate.cs | 14 + .../JournalEntryDraftAggregate.cs | 17 ++ .../VatCalculationService.cs | 21 +- .../Events/UserCompanyAccessEvents.cs | 8 +- .../UserAccess/UserCompanyAccessAggregate.cs | 8 +- .../Repositories/AttachmentRepository.cs | 13 + .../Repositories/IAttachmentRepository.cs | 1 + .../Subscribers/StandardDanishAccounts.cs | 9 + .../Saft/Services/SaftExportService.cs | 37 ++- .../Books.Api/Saft/Services/SaftXmlBuilder.cs | 7 + backend/Books.Api/WeatherForecast.cs | 12 - .../DocumentUploadModal.tsx | 20 +- .../src/components/layout/CompanySwitcher.tsx | 4 +- .../components/layout/FiscalYearSelector.tsx | 5 +- frontend/src/components/layout/Header.tsx | 30 +- frontend/src/components/layout/Sidebar.tsx | 1 + .../modals/CloseFiscalYearWizard.tsx | 67 ++++- .../settings/BankConnectionsTab.tsx | 2 - frontend/src/components/shared/AmountText.tsx | 5 +- frontend/src/lib/accounting.ts | 6 +- frontend/src/lib/formatters.ts | 4 +- frontend/src/lib/periods.ts | 6 +- frontend/src/lib/vatCodes.ts | 48 ++-- frontend/src/pages/Admin.tsx | 14 +- frontend/src/pages/Bankafstemning.tsx | 50 ++-- frontend/src/pages/CompanySetupWizard.tsx | 3 - frontend/src/pages/Dashboard.tsx | 9 +- frontend/src/pages/Eksport.tsx | 30 +- frontend/src/pages/Fakturaer.tsx | 23 +- frontend/src/pages/Kassekladde.tsx | 264 +++++++++++++++--- frontend/src/pages/Kontooversigt.tsx | 6 +- frontend/src/pages/Kreditnotaer.tsx | 39 +-- frontend/src/pages/Kunder.tsx | 6 +- frontend/src/pages/Loenforstaelse.tsx | 28 +- frontend/src/pages/Momsindberetning.tsx | 37 +-- frontend/src/pages/Ordrer.tsx | 6 +- frontend/src/pages/Settings.tsx | 43 +-- frontend/src/pages/UserSettings.tsx | 23 +- frontend/src/stores/periodStore.ts | 4 +- frontend/src/styles/designTokens.ts | 2 +- frontend/src/types/periods.ts | 26 +- 50 files changed, 676 insertions(+), 372 deletions(-) delete mode 100644 backend/Books.Api/Controllers/WeatherForecastController.cs rename backend/Books.Api/Database/Migrations/{007_JournalEntryDraftCompliance.sql => 007b_JournalEntryDraftCompliance.sql} (100%) delete mode 100644 backend/Books.Api/WeatherForecast.cs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3496f65..b24e700 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,9 +1,11 @@ +{"id":"books-0ea","title":"Phase 1+2: Backend security, data integrity, legal compliance","description":"Fix negative debit/credit validation, mandatory document date, event sourcing timestamps, BankingController auth, attachment access check, CVR validation, API key salt, VAT code system, VAT accounts, SAF-T fixes, invoice CVR validation","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:55.640389+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.394948+01:00"} {"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":"closed","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:35:30.305957+01:00","closed_at":"2026-02-05T21:35:30.305957+01:00","close_reason":"Closed"} {"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"} {"id":"books-8lo","title":"revisit the laytoug and desig nfor kontooversigten.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:06.620288+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.365315+01:00","closed_at":"2026-01-30T14:47:52.365315+01:00","close_reason":"Closed"} +{"id":"books-9ig","title":"Phase 3: Critical frontend bugs","description":"Fix closing wizard entries, create-next-year checkbox, Dashboard a-href, Kassekladde filters, edit draft form population, Vis detaljer action, Flere filtre button, duplicate migration","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:57.507387+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.478081+01:00"} {"id":"books-bj6","title":"Test automatisk pickup","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:04:40.572496+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:05:44.401903+01:00","closed_at":"2026-01-30T14:05:44.401903+01:00","close_reason":"completed"} {"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"} @@ -14,6 +16,8 @@ {"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":"closed","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:35:30.433843+01:00","closed_at":"2026-02-05T21:35:30.433843+01:00","close_reason":"Closed"} {"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":"closed","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:35:30.243779+01:00","closed_at":"2026-02-05T21:35:30.243779+01:00","close_reason":"Closed"} {"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-m5a","title":"Phase 6+7: Consistency, quality, UX, accessibility","description":"Remove console.logs, adopt PageHeader on 6 pages, standardize loading states, date formats, fix deprecated props, colors, sidebar width, mobile responsiveness, aria-labels, a11y","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:17:00.630867+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.639147+01:00"} +{"id":"books-pos","title":"Phase 4+5: Danish encoding + dead buttons","description":"Fix ~45 Danish character encoding issues across 10 files. Fix dead buttons: Download PDF, Eksporter, Administrer fiscal year, bank connections, help link, notification bell","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:59.078667+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.558186+01:00"} {"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"} {"id":"books-wzq","title":"tilføj en lille disclaimer på alle områder, hvor der er statisk data. brug gerne planning mode","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:22:53.728536+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.557962+01:00","closed_at":"2026-01-30T14:40:44.557962+01:00","close_reason":"Closed"} diff --git a/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs index 6a4f584..ce7c5ab 100644 --- a/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs +++ b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs @@ -1,4 +1,6 @@ +using Books.Api.Domain; using Books.Api.Domain.Invoices; +using Books.Api.EventFlow.Repositories; using Books.Api.Invoicing.Services; using EventFlow.Commands; @@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices; /// /// Command handler for creating invoices. /// Auto-assigns a sequential invoice number if one is not provided. +/// Validates the company has a CVR number (required for invoicing). /// public class CreateInvoiceCommandHandler( - IInvoiceNumberService invoiceNumberService) + IInvoiceNumberService invoiceNumberService, + ICompanyRepository companyRepository) : CommandHandler { public override async Task ExecuteAsync( @@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler( CreateInvoiceCommand command, CancellationToken cancellationToken) { + // Validate company has a CVR number (required for invoicing per Danish law) + var company = await companyRepository.GetByIdAsync(command.CompanyId, cancellationToken); + if (company == null) + { + throw new DomainException( + "COMPANY_NOT_FOUND", + $"Company '{command.CompanyId}' not found", + $"Virksomheden '{command.CompanyId}' blev ikke fundet"); + } + + if (string.IsNullOrWhiteSpace(company.Cvr)) + { + throw new DomainException( + "CVR_REQUIRED_FOR_INVOICE", + "Company must have a CVR number to create invoices. Please update company settings.", + "Virksomheden skal have et CVR-nummer for at oprette fakturaer. Opdater venligst virksomhedsindstillinger."); + } + // Auto-assign invoice number if not provided var invoiceNumber = command.InvoiceNumber; if (string.IsNullOrWhiteSpace(invoiceNumber)) diff --git a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs index d976127..19a37cd 100644 --- a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs +++ b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs @@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler( $"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) + // Validate document date is set (required for posting per Bogføringsloven) + if (draft?.DocumentDate == null) + { + throw new DomainException( + "DOCUMENT_DATE_REQUIRED", + "Document date (bilagsdato) is required for posting a journal entry", + "Bilagsdato er påkrævet for bogføring af en postering"); + } + + // Validate document date falls within fiscal year range { var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value); var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate); diff --git a/backend/Books.Api/Controllers/AttachmentController.cs b/backend/Books.Api/Controllers/AttachmentController.cs index ec9b8d6..5f51d03 100644 --- a/backend/Books.Api/Controllers/AttachmentController.cs +++ b/backend/Books.Api/Controllers/AttachmentController.cs @@ -197,6 +197,20 @@ public class AttachmentController( return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); } + // Look up the attachment to verify company access + var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken); + if (attachment == null) + { + return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" }); + } + + // Verify the user has access to the company that owns this attachment + var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken); + if (access == null) + { + return Forbid(); + } + var file = await fileStorage.GetAsync(storagePath, cancellationToken); if (file == null) diff --git a/backend/Books.Api/Controllers/BankingController.cs b/backend/Books.Api/Controllers/BankingController.cs index 7948597..bd95f10 100644 --- a/backend/Books.Api/Controllers/BankingController.cs +++ b/backend/Books.Api/Controllers/BankingController.cs @@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections; using Books.Api.Domain.BankConnections; using EventFlow; using EventFlow.Aggregates.ExecutionResults; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Books.Api.Controllers; [ApiController] [Route("api/banking")] +[Authorize] public class BankingController : ControllerBase { private readonly ICommandBus _commandBus; diff --git a/backend/Books.Api/Controllers/WeatherForecastController.cs b/backend/Books.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index 23126ca..0000000 --- a/backend/Books.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Books.Api.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = - [ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - ]; - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql b/backend/Books.Api/Database/Migrations/007b_JournalEntryDraftCompliance.sql similarity index 100% rename from backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql rename to backend/Books.Api/Database/Migrations/007b_JournalEntryDraftCompliance.sql diff --git a/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs b/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs index 572878c..c07362a 100644 --- a/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs +++ b/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs @@ -29,7 +29,7 @@ public class AttachmentAggregate(AttachmentId id) _isCreated = true; _companyId = e.CompanyId; _transactionId = e.TransactionId; - _uploadedAt = DateTimeOffset.UtcNow; + _uploadedAt = e.UploadedAt; } public void Apply(AttachmentLinkedToTransactionEvent e) @@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id) fileSize, storagePath.Trim(), uploadedBy, + DateTimeOffset.UtcNow, draftId?.Trim(), transactionId?.Trim())); } diff --git a/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs b/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs index b7c558e..9254486 100644 --- a/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs +++ b/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs @@ -14,6 +14,7 @@ public class AttachmentUploadedEvent( long fileSize, string storagePath, string uploadedBy, + DateTimeOffset uploadedAt, string? draftId = null, string? transactionId = null) : AggregateEvent { @@ -46,6 +47,11 @@ public class AttachmentUploadedEvent( public string UploadedBy { get; } = uploadedBy; + /// + /// Timestamp when the attachment was uploaded. + /// + public DateTimeOffset UploadedAt { get; } = uploadedAt; + /// /// Optional reference to journal entry draft. /// diff --git a/backend/Books.Api/Domain/Companies/CompanyAggregate.cs b/backend/Books.Api/Domain/Companies/CompanyAggregate.cs index 1c15816..b31edbb 100644 --- a/backend/Books.Api/Domain/Companies/CompanyAggregate.cs +++ b/backend/Books.Api/Domain/Companies/CompanyAggregate.cs @@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot 12) throw new DomainException("Fiscal year start month must be between 1 and 12"); + // Validate CVR number if provided + if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim())) + throw new DomainException( + "INVALID_CVR", + $"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.", + $"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum."); + Emit(new CompanyCreatedEvent( name.Trim(), cvr?.Trim(), @@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot 12) throw new DomainException("Fiscal year start month must be between 1 and 12"); + // Validate CVR number if provided + if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim())) + throw new DomainException( + "INVALID_CVR", + $"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.", + $"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum."); + Emit(new CompanyUpdatedEvent( name.Trim(), cvr?.Trim(), diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs index 204ad6c..15016dc 100644 --- a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs +++ b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs @@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id) /// /// Validates a single draft line. + /// Amounts must be non-negative. /// 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) + { + throw new DomainException( + "NEGATIVE_DEBIT_AMOUNT", + $"Line {line.LineNumber} has a negative debit amount. Amounts must be non-negative.", + $"Linje {line.LineNumber} har et negativt debetbeløb. Beløb skal være ikke-negative."); + } + + if (line.CreditAmount < 0) + { + throw new DomainException( + "NEGATIVE_CREDIT_AMOUNT", + $"Line {line.LineNumber} has a negative credit amount. Amounts must be non-negative.", + $"Linje {line.LineNumber} har et negativt kreditbeløb. Beløb skal være ikke-negative."); + } + if (line.DebitAmount > 0 && line.CreditAmount > 0) { throw new DomainException( diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs index f4b1275..a8b26f3 100644 --- a/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs +++ b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs @@ -168,24 +168,11 @@ public class VatCalculationService : IVatCalculationService // - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT) // - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT) // The key insight: VAT follows the same direction as the base transaction - if (isInputVat) + vatLine = vatLine with { - // Purchases: VAT follows the expense direction (typically debit) - vatLine = vatLine with - { - DebitAmount = isDebit ? vatAmount : 0, - CreditAmount = !isDebit ? vatAmount : 0 - }; - } - else - { - // Sales: VAT follows the revenue direction (typically credit) - vatLine = vatLine with - { - DebitAmount = isDebit ? vatAmount : 0, - CreditAmount = !isDebit ? vatAmount : 0 - }; - } + DebitAmount = isDebit ? vatAmount : 0, + CreditAmount = !isDebit ? vatAmount : 0 + }; vatLines.Add(vatLine); diff --git a/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs b/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs index 144122c..80dd3d4 100644 --- a/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs +++ b/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs @@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent( string userId, string companyId, CompanyRole role, - string grantedBy) : AggregateEvent + string grantedBy, + DateTimeOffset grantedAt) : AggregateEvent { public string UserId { get; } = userId; public string CompanyId { get; } = companyId; public CompanyRole Role { get; } = role; public string GrantedBy { get; } = grantedBy; + public DateTimeOffset GrantedAt { get; } = grantedAt; } /// @@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent( /// Emitted when a user's access to a company is revoked. /// public class UserCompanyAccessRevokedEvent( - string revokedBy) : AggregateEvent + string revokedBy, + DateTimeOffset revokedAt) : AggregateEvent { public string RevokedBy { get; } = revokedBy; + public DateTimeOffset RevokedAt { get; } = revokedAt; } diff --git a/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs index c879d2e..1994d40 100644 --- a/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs +++ b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs @@ -33,7 +33,7 @@ public class UserCompanyAccessAggregate : AggregateRoot @@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot(sql, new { Id = id }); } + public async Task GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE storage_path = @StoragePath AND is_deleted = FALSE + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { StoragePath = storagePath }); + } + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); diff --git a/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs b/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs index 4e54ef2..6cd9ad4 100644 --- a/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs +++ b/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs @@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories; public interface IAttachmentRepository { Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default); Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); Task> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default); Task> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default); diff --git a/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs b/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs index 67fb78a..b810d27 100644 --- a/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs +++ b/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs @@ -190,6 +190,15 @@ public static class StandardDanishAccounts yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110"); yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110"); + // ========================================= + // MOMSKONTI (VAT Accounts) - 56xx + // Standard: 5610 = Købsmoms, 5611 = Salgsmoms, 5620 = EU-erhvervelsesmoms + // Required for VAT calculation and reporting + // ========================================= + yield return new("5610", "Købsmoms", AccountType.Liability, "Indgående moms (Input VAT)", null, "5610", true); + yield return new("5611", "Salgsmoms", AccountType.Liability, "Udgående moms (Output VAT)", null, "5611", true); + yield return new("5620", "EU-erhvervelsesmoms", AccountType.Liability, "EU acquisition VAT", null, "5620", true); + // ========================================= // PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx // Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat diff --git a/backend/Books.Api/Saft/Services/SaftExportService.cs b/backend/Books.Api/Saft/Services/SaftExportService.cs index 36ffc3e..df65a15 100644 --- a/backend/Books.Api/Saft/Services/SaftExportService.cs +++ b/backend/Books.Api/Saft/Services/SaftExportService.cs @@ -1,3 +1,4 @@ +using Books.Api.Domain; using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.Repositories; using Books.Api.Saft.Models; @@ -294,6 +295,9 @@ public class SaftExportService( if (isDebit) totalDebit += entry.Amount; else totalCredit += entry.Amount; + // Try to extract VAT information from the entry description + var taxInfo = ExtractTaxInformation(entry.Description, entry.Amount); + return new SaftTransactionLine( (idx + 1).ToString(), accountNumber ?? entry.AccountId.ToString(), @@ -302,7 +306,7 @@ public class SaftExportService( creditAmount, null, // CustomerID - could parse from reference null, // SupplierID - null); // TaxInfo - would need VAT code tracking + taxInfo); }).ToList(); transactions.Add(new SaftTransaction( @@ -385,6 +389,37 @@ public class SaftExportService( return null; } + /// + /// Extracts VAT/tax information from a ledger entry description. + /// VAT lines generated by the system contain patterns like "Moms U25 (25%)" or "Moms I25 (25%)". + /// + private static SaftTaxInformation? ExtractTaxInformation(string? description, decimal amount) + { + if (string.IsNullOrEmpty(description)) + return null; + + // Check for known VAT codes in the description + foreach (var vatCodeInfo in VatCodes.All) + { + if (vatCodeInfo.Code == VatCodes.INGEN) + continue; + + if (description.Contains(vatCodeInfo.Code, StringComparison.OrdinalIgnoreCase)) + { + var rate = VatCodes.GetRate(vatCodeInfo.Code); + var taxBase = rate > 0 ? Math.Round(amount / rate, 2, MidpointRounding.AwayFromZero) : 0m; + + return new SaftTaxInformation( + vatCodeInfo.Code, + rate * 100, // TaxPercentage as whole number (25 not 0.25) + taxBase, + amount); + } + } + + return null; + } + /// /// Validates a Danish CVR number. /// A valid CVR is exactly 8 digits. diff --git a/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs index 6c7b57f..5dab855 100644 --- a/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs +++ b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs @@ -47,6 +47,7 @@ public class SaftXmlBuilder writer.WriteStartElement("Header"); writer.WriteElementString("AuditFileVersion", header.AuditFileVersion); + writer.WriteElementString("AuditFileCountry", "DK"); writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated); writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName); writer.WriteElementString("SoftwareID", header.SoftwareID); @@ -72,6 +73,12 @@ public class SaftXmlBuilder WriteContact(writer, company.Contact); } + // SAF-T DK requires TaxRegistrationNumber with "DK" prefix + CVR + if (!string.IsNullOrEmpty(company.RegistrationNumber)) + { + writer.WriteElementString("TaxRegistrationNumber", "DK" + company.RegistrationNumber); + } + writer.WriteEndElement(); // Company } diff --git a/backend/Books.Api/WeatherForecast.cs b/backend/Books.Api/WeatherForecast.cs deleted file mode 100644 index badd639..0000000 --- a/backend/Books.Api/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Books.Api; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} diff --git a/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx index c262263..45add52 100644 --- a/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx +++ b/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx @@ -105,7 +105,7 @@ export function DocumentUploadModal({ setIsPosting(true); try { await postDraftMutation.mutateAsync(result.draftId); - message.success('Bogfoert!'); + message.success('Bogført!'); onConfirm(); } catch (err) { message.error('Kunne ikke bogføre. Prøv igen.'); @@ -154,7 +154,7 @@ export function DocumentUploadModal({
- AI-tjenesten udtraekker information fra dokumentet + AI-tjenesten udtrækker information fra dokumentet
@@ -206,7 +206,7 @@ export function DocumentUploadModal({ Luk , , ]} onCancel={onClose} @@ -279,7 +279,7 @@ export function DocumentUploadModal({ loading={isPosting} disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)} > - Godkend og bogfoer + Godkend og bogfør , ]} onCancel={handleCancel} @@ -297,7 +297,7 @@ export function DocumentUploadModal({
- Foreslaaet bogfoering + Foreslået bogføring {journalLines.length > 0 && ( isBalanced ? ( @@ -341,8 +341,8 @@ export function DocumentUploadModal({ /> ) : ( } @@ -394,7 +394,7 @@ export function DocumentUploadModal({ } @@ -406,7 +406,7 @@ export function DocumentUploadModal({ {result.accountSuggestion && (
- Kontoforslag baseret paa AI-analyse ( + Kontoforslag baseret på AI-analyse ( {Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
@@ -474,7 +474,7 @@ function ExtractedInfoSection({ style={{ marginBottom: hasLineItems ? 12 : 0 }} > {extraction.vendor && ( - + {extraction.vendor} )} diff --git a/frontend/src/components/layout/CompanySwitcher.tsx b/frontend/src/components/layout/CompanySwitcher.tsx index 02f8a78..24e6ce2 100644 --- a/frontend/src/components/layout/CompanySwitcher.tsx +++ b/frontend/src/components/layout/CompanySwitcher.tsx @@ -1,4 +1,4 @@ -import { Select, Space, Typography, Tag, Skeleton } from 'antd'; +import { Select, Space, Typography, Tag } from 'antd'; import { ShopOutlined } from '@ant-design/icons'; import { useCompanyStore } from '@/stores/companyStore'; import { formatCVR } from '@/lib/formatters'; @@ -22,7 +22,7 @@ export default function CompanySwitcher({ compact = false }: CompanySwitcherProp }; if (companies.length === 0) { - return ; + return Ingen virksomheder; } return ( diff --git a/frontend/src/components/layout/FiscalYearSelector.tsx b/frontend/src/components/layout/FiscalYearSelector.tsx index 4536ee1..e185d7f 100644 --- a/frontend/src/components/layout/FiscalYearSelector.tsx +++ b/frontend/src/components/layout/FiscalYearSelector.tsx @@ -10,6 +10,7 @@ import { MinusCircleOutlined, LockOutlined, } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; import { usePeriodStore } from '@/stores/periodStore'; import { useCompanyStore } from '@/stores/companyStore'; import { useFiscalYears } from '@/api/queries/fiscalYearQueries'; @@ -50,6 +51,7 @@ interface FiscalYearSelectorProps { } export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) { + const navigate = useNavigate(); const { activeCompany } = useCompanyStore(); const { fiscalYears, @@ -126,8 +128,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear if (onManage) { onManage(); } else { - // Navigate to settings page - console.log('Navigate to fiscal year settings'); + navigate('/indstillinger'); } }; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index e645cc9..b857757 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Layout, Space, Button, Dropdown, Avatar, Divider, Tooltip, Badge } from 'antd'; +import { Layout, Space, Button, Dropdown, Avatar, Divider, Tooltip, Badge, Popover, message } from 'antd'; import { UserOutlined, LogoutOutlined, @@ -129,22 +129,28 @@ export default function Header({ isMobile = false }: HeaderProps) { - + + + } /> @@ -389,7 +381,7 @@ export default function Bankafstemning() { } size="small" - bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }} + styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }} > {bankTransactions.length === 0 ? ( } size="small" - bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }} + styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }} > {ledgerEntries.length === 0 ? ( { setIsCreateModalOpen(false); @@ -674,12 +666,12 @@ export default function Bankafstemning() { > ({ + value: acc.id, + label: `${acc.accountNumber} - ${acc.name}`, + }))} /> diff --git a/frontend/src/pages/Kassekladde.tsx b/frontend/src/pages/Kassekladde.tsx index cbc7bbe..73d963c 100644 --- a/frontend/src/pages/Kassekladde.tsx +++ b/frontend/src/pages/Kassekladde.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Typography, Button, @@ -15,6 +15,9 @@ import { Dropdown, Skeleton, Empty, + Descriptions, + Table, + Drawer, } from 'antd'; import { PlusOutlined, @@ -34,7 +37,7 @@ 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 type { TransactionLine, JournalEntryDraft, JournalEntryDraftStatus } from '@/types/accounting'; import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations'; import { usePeriodStore } from '@/stores/periodStore'; @@ -51,7 +54,10 @@ interface DraftDisplay { totalCredit: number; isReconciled: boolean; isVoided: boolean; + status: JournalEntryDraftStatus; lines: JournalEntryDraft['lines']; + postedAt?: string; + postedBy?: string; } export default function Kassekladde() { @@ -59,6 +65,10 @@ export default function Kassekladde() { const [isModalOpen, setIsModalOpen] = useState(false); const [editingDraft, setEditingDraft] = useState(null); const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null); + const [accountFilter, setAccountFilter] = useState(null); + const [statusFilter, setStatusFilter] = useState(null); + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); + const [detailDraft, setDetailDraft] = useState(null); const [form] = Form.useForm(); const [lines, setLines] = useState[]>([ { debit: 0, credit: 0 }, @@ -79,7 +89,7 @@ export default function Kassekladde() { const isLoading = accountsLoading || draftsLoading; // Convert drafts to display format - const displayData: DraftDisplay[] = drafts.map(draft => ({ + const displayData: DraftDisplay[] = useMemo(() => drafts.map(draft => ({ id: draft.id, transactionNumber: draft.voucherNumber || draft.name, date: draft.documentDate || draft.createdAt, @@ -89,7 +99,62 @@ export default function Kassekladde() { totalCredit: draft.lines?.reduce((sum, l) => sum + (l.creditAmount || 0), 0) ?? 0, isReconciled: draft.status === 'posted', isVoided: draft.status === 'discarded', - })); + status: draft.status, + postedAt: draft.status === 'posted' ? draft.updatedAt : undefined, + postedBy: draft.createdBy, + })), [drafts]); + + // Apply filters to display data + const filteredData: DraftDisplay[] = useMemo(() => { + let data = displayData; + + // Date filter + if (dateFilter && dateFilter[0] && dateFilter[1]) { + const startDate = dateFilter[0].startOf('day'); + const endDate = dateFilter[1].endOf('day'); + data = data.filter(d => { + const dDate = dayjs(d.date); + return (dDate.isAfter(startDate) || dDate.isSame(startDate, 'day')) && + (dDate.isBefore(endDate) || dDate.isSame(endDate, 'day')); + }); + } + + // Account filter - filter drafts where any line references the selected account + if (accountFilter) { + data = data.filter(d => + d.lines.some(l => l.accountId === accountFilter) + ); + } + + // Status filter + if (statusFilter) { + data = data.filter(d => d.status === statusFilter); + } + + return data; + }, [displayData, dateFilter, accountFilter, statusFilter]); + + // Pre-populate form when editing a draft + useEffect(() => { + if (editingDraft && isModalOpen) { + form.setFieldsValue({ + date: editingDraft.date ? dayjs(editingDraft.date) : dayjs(), + description: editingDraft.description, + }); + // Populate lines from the draft + if (editingDraft.lines && editingDraft.lines.length > 0) { + setLines(editingDraft.lines.map(l => ({ + accountId: l.accountId, + debit: l.debitAmount || 0, + credit: l.creditAmount || 0, + description: l.description, + vatCode: l.vatCode, + }))); + } else { + setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); + } + } + }, [editingDraft, isModalOpen, form]); const columns: DataTableColumn[] = [ { @@ -134,7 +199,7 @@ export default function Kassekladde() { return Annulleret; } return value ? ( - Bogført + Bogfort ) : ( Kladde ); @@ -192,7 +257,7 @@ export default function Kassekladde() { const handleAction = (action: string, record: DraftDisplay) => { switch (action) { case 'view': - message.info(`Vis detaljer for bilag ${record.transactionNumber}`); + setDetailDraft(record); break; case 'edit': setEditingDraft(record); @@ -236,7 +301,7 @@ export default function Kassekladde() { case 'void': Modal.confirm({ title: 'Annuller bilag', - content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`, + content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`, okText: 'Annuller bilag', okType: 'danger', cancelText: 'Fortryd', @@ -287,7 +352,7 @@ export default function Kassekladde() { const validation = validateDoubleEntry(lines as TransactionLine[]); if (!validation.valid) { message.error( - `Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})` + `Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})` ); return; } @@ -347,6 +412,7 @@ export default function Kassekladde() { } setIsModalOpen(false); + setEditingDraft(null); form.resetFields(); setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); } catch (error) { @@ -356,6 +422,23 @@ export default function Kassekladde() { } }; + // Helper to look up account name by ID + const getAccountName = (accountId: string): string => { + const acc = accounts.find(a => a.id === accountId); + return acc ? `${acc.accountNumber} - ${acc.name}` : accountId; + }; + + const getStatusLabel = (status: JournalEntryDraftStatus): { label: string; color: string } => { + switch (status) { + case 'posted': return { label: 'Bogfort', color: 'green' }; + case 'discarded': return { label: 'Annulleret', color: 'red' }; + case 'draft': return { label: 'Kladde', color: 'orange' }; + case 'pending_review': return { label: 'Afventer gennemgang', color: 'blue' }; + case 'approved': return { label: 'Godkendt', color: 'cyan' }; + default: return { label: status, color: 'default' }; + } + }; + const balance = validateDoubleEntry(lines as TransactionLine[]); if (isLoading) { @@ -364,7 +447,7 @@ export default function Kassekladde() {
@@ -376,13 +459,15 @@ export default function Kassekladde() { } onClick={() => { setEditingDraft(null); + form.resetFields(); + setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); setIsModalOpen(true); }} > @@ -397,36 +482,58 @@ export default function Kassekladde() { placeholder={['Fra dato', 'Til dato']} value={dateFilter} onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)} - format="DD/MM/YYYY" + format="DD-MM-YYYY" /> - - + {showAdvancedFilters && ( + <> + setStatusFilter(value ?? null)} + options={[ + { value: 'posted', label: 'Bogfort' }, + { value: 'draft', label: 'Kladde' }, + { value: 'discarded', label: 'Annulleret' }, + ]} + /> + + )} + {/* Data Table */} - {displayData.length === 0 ? ( + {filteredData.length === 0 ? ( ) : ( - data={displayData} + data={filteredData} columns={columns} exportFilename="kassekladde" rowSelection="multiple" @@ -437,12 +544,95 @@ export default function Kassekladde() { /> )} + {/* Detail Drawer */} + setDetailDraft(null)} + width={600} + > + {detailDraft && ( + <> + + + #{detailDraft.transactionNumber} + + + {detailDraft.date ? dayjs(detailDraft.date).format('DD-MM-YYYY') : '-'} + + + {detailDraft.description} + + + + {getStatusLabel(detailDraft.status).label} + + + {detailDraft.isReconciled && detailDraft.postedAt && ( + + {dayjs(detailDraft.postedAt).format('DD-MM-YYYY HH:mm')} + + )} + {detailDraft.postedBy && ( + + {detailDraft.postedBy} + + )} + + {formatCurrency(detailDraft.totalDebit)} + + + {formatCurrency(detailDraft.totalCredit)} + + + + + Posteringslinjer + + ({ ...l, key: idx }))} + columns={[ + { + title: 'Konto', + dataIndex: 'accountId', + key: 'account', + render: (accountId: string) => getAccountName(accountId), + }, + { + title: 'Debet', + dataIndex: 'debitAmount', + key: 'debit', + align: 'right' as const, + render: (v: number) => v ? formatCurrency(v) : '-', + }, + { + title: 'Kredit', + dataIndex: 'creditAmount', + key: 'credit', + align: 'right' as const, + render: (v: number) => v ? formatCurrency(v) : '-', + }, + { + title: 'Tekst', + dataIndex: 'description', + key: 'description', + render: (v: string) => v || '-', + }, + ]} + pagination={false} + size="small" + /> + + )} + + {/* Create/Edit Modal */} { setIsModalOpen(false); + setEditingDraft(null); form.resetFields(); setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); }} @@ -457,10 +647,10 @@ export default function Kassekladde() { - +
} diff --git a/frontend/src/pages/Kreditnotaer.tsx b/frontend/src/pages/Kreditnotaer.tsx index fc8bfa3..62f4259 100644 --- a/frontend/src/pages/Kreditnotaer.tsx +++ b/frontend/src/pages/Kreditnotaer.tsx @@ -11,7 +11,7 @@ import { Input, Select, InputNumber, - Spin, + Skeleton, Alert, Drawer, Descriptions, @@ -65,6 +65,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'; const { Title, Text } = Typography; @@ -438,25 +439,16 @@ export default function Kreditnotaer() { return (
- {/* Header */} -
-
- - Kreditnotaer - - {company?.name} -
- -
+ } onClick={handleCreateCreditNote}> + Ny kreditnota + + } + /> {/* Error State */} {error && ( @@ -549,12 +541,7 @@ export default function Kreditnotaer() { {/* Credit Note Table */} {loading ? ( - -
- + ) : filteredCreditNotes.length > 0 ? ( {loading ? ( - -
- + ) : filteredCustomers.length > 0 ? (
- {/* Header */} -
-
- - Lønforståelse - - {company?.name} -
- + }>Eksporter lønsedler - -
+ } + /> diff --git a/frontend/src/pages/Momsindberetning.tsx b/frontend/src/pages/Momsindberetning.tsx index 705cc83..ea60559 100644 --- a/frontend/src/pages/Momsindberetning.tsx +++ b/frontend/src/pages/Momsindberetning.tsx @@ -17,6 +17,7 @@ import { Descriptions, Empty, Skeleton, + Tooltip, } from 'antd'; import { DownloadOutlined, @@ -89,25 +90,25 @@ export default function Momsindberetning() { }, { boxNumber: 2, - nameDanish: 'Moms af varekob i udlandet (EU)', + nameDanish: 'Moms af varekøb i udlandet (EU)', nameEnglish: 'VAT on goods from EU', - description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande', + description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande', amount: vatReport.boxC, basis: vatReport.basis3, }, { boxNumber: 3, - nameDanish: 'Moms af ydelseskob i udlandet', + nameDanish: 'Moms af ydelseskøb i udlandet', nameEnglish: 'VAT on services from abroad', - description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt', + description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt', amount: vatReport.boxD, basis: vatReport.basis4, }, { boxNumber: 4, - nameDanish: 'Kobsmoms', + nameDanish: 'Købsmoms', nameEnglish: 'Input VAT', - description: 'Fradragsberettiget moms af kob', + description: 'Fradragsberettiget moms af køb', amount: vatReport.boxB, basis: undefined, // Backend doesn't provide a specific basis for input VAT }, @@ -125,7 +126,7 @@ export default function Momsindberetning() { return [ { type: 'Salgsmoms', value: vatReport.boxA }, { type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) }, - { type: 'Kobsmoms (fradrag)', value: inputVAT }, + { type: 'Købsmoms (fradrag)', value: inputVAT }, ].filter(d => d.value > 0); }, [vatReport, inputVAT]); @@ -223,7 +224,9 @@ export default function Momsindberetning() { breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]} extra={ - + + + , - , + + + , , ]} > @@ -436,7 +441,7 @@ export default function Momsindberetning() { { dataIndex: 'nameDanish', title: 'Felt' }, { dataIndex: 'amount', - title: 'Belob', + title: 'Beløb', align: 'right', render: (v: number) => formatCurrency(v), }, diff --git a/frontend/src/pages/Ordrer.tsx b/frontend/src/pages/Ordrer.tsx index b1c8b29..3d07dc0 100644 --- a/frontend/src/pages/Ordrer.tsx +++ b/frontend/src/pages/Ordrer.tsx @@ -10,7 +10,7 @@ import { Form, Input, Select, - Spin, + Skeleton, Alert, Drawer, Descriptions, @@ -495,9 +495,7 @@ export default function Ordrer() { {/* Order Table */} {loading ? ( - -
- + ) : filteredOrders.length > 0 ? (
), children: ( - - -
- - Tilknyttede bankkonti - - -
- - - - - - -
-
+ ), }, { @@ -360,13 +335,11 @@ export default function Settings() { return (
- {/* Header */} -
- - Indstillinger - - {company?.name} -
+
diff --git a/frontend/src/pages/UserSettings.tsx b/frontend/src/pages/UserSettings.tsx index 471a694..cbfb598 100644 --- a/frontend/src/pages/UserSettings.tsx +++ b/frontend/src/pages/UserSettings.tsx @@ -27,6 +27,7 @@ import { } from '@ant-design/icons'; import type { UploadProps } from 'antd'; import { spacing } from '@/styles/designTokens'; +import { PageHeader } from '@/components/shared/PageHeader'; const { Title, Text } = Typography; @@ -55,8 +56,7 @@ export default function UserSettings() { const handleSaveProfile = async () => { try { - const values = await profileForm.validateFields(); - console.log('Saving profile:', values); + await profileForm.validateFields(); showSuccess('Profil opdateret'); } catch (error) { console.error('Validation failed:', error); @@ -71,7 +71,6 @@ export default function UserSettings() { showError('Adgangskoderne stemmer ikke overens'); return; } - console.log('Changing password'); showSuccess('Adgangskode opdateret'); passwordForm.resetFields(); setIsChangingPassword(false); @@ -83,8 +82,7 @@ export default function UserSettings() { const handleSaveNotifications = async () => { try { - const values = await notificationForm.validateFields(); - console.log('Saving notifications:', values); + await notificationForm.validateFields(); showSuccess('Notifikationsindstillinger gemt'); } catch (error) { console.error('Validation failed:', error); @@ -124,8 +122,7 @@ export default function UserSettings() { } return false; // Prevent auto upload }, - onChange: (info) => { - console.log('Upload:', info.file); + onChange: () => { showSuccess('Profilbillede opdateret'); }, }; @@ -458,13 +455,11 @@ export default function UserSettings() { return (
- {/* Header */} -
- - Min profil - - Administrer dine personlige indstillinger -
+
diff --git a/frontend/src/stores/periodStore.ts b/frontend/src/stores/periodStore.ts index 8f7ba52..884af22 100644 --- a/frontend/src/stores/periodStore.ts +++ b/frontend/src/stores/periodStore.ts @@ -322,7 +322,7 @@ export const usePeriodStore = create()( return { allowed: false, reason: 'Period is locked', - reasonDanish: 'Perioden er laast', + reasonDanish: 'Perioden er låst', }; } @@ -344,7 +344,7 @@ export const usePeriodStore = create()( return { allowed: false, reason: 'Cannot post to future periods', - reasonDanish: 'Kan ikke bogfoere i fremtidige perioder', + reasonDanish: 'Kan ikke bogføre i fremtidige perioder', }; } diff --git a/frontend/src/styles/designTokens.ts b/frontend/src/styles/designTokens.ts index 40f3da2..9870b99 100644 --- a/frontend/src/styles/designTokens.ts +++ b/frontend/src/styles/designTokens.ts @@ -321,7 +321,7 @@ export const componentTokens = { padding: spacing.xl, }, sidebar: { - width: 200, + width: 220, collapsedWidth: 80, }, modal: { diff --git a/frontend/src/types/periods.ts b/frontend/src/types/periods.ts index a554833..a0e24a9 100644 --- a/frontend/src/types/periods.ts +++ b/frontend/src/types/periods.ts @@ -4,29 +4,29 @@ * Period frequency - how often accounting periods are defined */ export type PeriodFrequency = - | 'monthly' // Maanedlig + | 'monthly' // Månedlig | 'quarterly' // Kvartalsvis - | 'half-yearly' // Halvaarlig - | 'yearly'; // Aarlig + | 'half-yearly' // Halvårlig + | 'yearly'; // Årlig /** * Period status according to Danish accounting requirements */ export type PeriodStatus = | 'future' // Fremtidig - not yet started - | 'open' // Aaben - current working period + | 'open' // Åben - current working period | 'closed' // Lukket - closed but can be reopened - | 'locked'; // Laast - permanently locked (after arsafslutning) + | 'locked'; // Låst - permanently locked (after årsafslutning) /** * VAT Period frequency (can differ from accounting periods) * Based on SKAT requirements */ export type VATPeriodicitet = - | 'monthly' // Maanedlig (omsaetning > 50M DKK) + | 'monthly' // Månedlig (omsætning > 50M DKK) | 'quarterly' // Kvartalsvis (default for most) - | 'half-yearly' // Halvaarlig (omsaetning < 1M DKK, optional) - | 'yearly'; // Aarlig (omsaetning < 300K DKK, optional) + | 'half-yearly' // Halvårlig (omsætning < 1M DKK, optional) + | 'yearly'; // Årlig (omsætning < 300K DKK, optional) /** * Fiscal Year (Regnskabsaar) @@ -230,10 +230,10 @@ export const DANISH_MONTHS_SHORT = [ * Period frequency display names */ export const PERIOD_FREQUENCY_NAMES: Record = { - 'monthly': { danish: 'Maanedlig', english: 'Monthly' }, + 'monthly': { danish: 'Månedlig', english: 'Monthly' }, 'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' }, - 'half-yearly': { danish: 'Halvaarslig', english: 'Half-yearly' }, - 'yearly': { danish: 'Aarlig', english: 'Yearly' }, + 'half-yearly': { danish: 'Halvårslig', english: 'Half-yearly' }, + 'yearly': { danish: 'Årlig', english: 'Yearly' }, }; /** @@ -252,7 +252,7 @@ export const PERIOD_STATUS_CONFIG: Record