Audit v2: fix security, data integrity, compliance, bugs, encoding, UX

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 <a href> 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 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-06 00:18:19 +01:00
parent a1c2af6027
commit 709d0a4739
50 changed files with 676 additions and 372 deletions

View file

@ -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-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-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-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-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-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-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-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-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-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-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-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-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-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-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"} {"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"}

View file

@ -1,4 +1,6 @@
using Books.Api.Domain;
using Books.Api.Domain.Invoices; using Books.Api.Domain.Invoices;
using Books.Api.EventFlow.Repositories;
using Books.Api.Invoicing.Services; using Books.Api.Invoicing.Services;
using EventFlow.Commands; using EventFlow.Commands;
@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices;
/// <summary> /// <summary>
/// Command handler for creating invoices. /// Command handler for creating invoices.
/// Auto-assigns a sequential invoice number if one is not provided. /// Auto-assigns a sequential invoice number if one is not provided.
/// Validates the company has a CVR number (required for invoicing).
/// </summary> /// </summary>
public class CreateInvoiceCommandHandler( public class CreateInvoiceCommandHandler(
IInvoiceNumberService invoiceNumberService) IInvoiceNumberService invoiceNumberService,
ICompanyRepository companyRepository)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand> : CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{ {
public override async Task ExecuteAsync( public override async Task ExecuteAsync(
@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler(
CreateInvoiceCommand command, CreateInvoiceCommand command,
CancellationToken cancellationToken) 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 // Auto-assign invoice number if not provided
var invoiceNumber = command.InvoiceNumber; var invoiceNumber = command.InvoiceNumber;
if (string.IsNullOrWhiteSpace(invoiceNumber)) if (string.IsNullOrWhiteSpace(invoiceNumber))

View file

@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler(
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring."); $"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) // Validate document date is set (required for posting per Bogføringsloven)
if (draft?.DocumentDate != null) 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 documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate); var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);

View file

@ -197,6 +197,20 @@ public class AttachmentController(
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); 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); var file = await fileStorage.GetAsync(storagePath, cancellationToken);
if (file == null) if (file == null)

View file

@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections;
using Books.Api.Domain.BankConnections; using Books.Api.Domain.BankConnections;
using EventFlow; using EventFlow;
using EventFlow.Aggregates.ExecutionResults; using EventFlow.Aggregates.ExecutionResults;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers; namespace Books.Api.Controllers;
[ApiController] [ApiController]
[Route("api/banking")] [Route("api/banking")]
[Authorize]
public class BankingController : ControllerBase public class BankingController : ControllerBase
{ {
private readonly ICommandBus _commandBus; private readonly ICommandBus _commandBus;

View file

@ -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<WeatherForecast> 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();
}
}

View file

@ -29,7 +29,7 @@ public class AttachmentAggregate(AttachmentId id)
_isCreated = true; _isCreated = true;
_companyId = e.CompanyId; _companyId = e.CompanyId;
_transactionId = e.TransactionId; _transactionId = e.TransactionId;
_uploadedAt = DateTimeOffset.UtcNow; _uploadedAt = e.UploadedAt;
} }
public void Apply(AttachmentLinkedToTransactionEvent e) public void Apply(AttachmentLinkedToTransactionEvent e)
@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id)
fileSize, fileSize,
storagePath.Trim(), storagePath.Trim(),
uploadedBy, uploadedBy,
DateTimeOffset.UtcNow,
draftId?.Trim(), draftId?.Trim(),
transactionId?.Trim())); transactionId?.Trim()));
} }

View file

@ -14,6 +14,7 @@ public class AttachmentUploadedEvent(
long fileSize, long fileSize,
string storagePath, string storagePath,
string uploadedBy, string uploadedBy,
DateTimeOffset uploadedAt,
string? draftId = null, string? draftId = null,
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId> string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
{ {
@ -46,6 +47,11 @@ public class AttachmentUploadedEvent(
public string UploadedBy { get; } = uploadedBy; public string UploadedBy { get; } = uploadedBy;
/// <summary>
/// Timestamp when the attachment was uploaded.
/// </summary>
public DateTimeOffset UploadedAt { get; } = uploadedAt;
/// <summary> /// <summary>
/// Optional reference to journal entry draft. /// Optional reference to journal entry draft.
/// </summary> /// </summary>

View file

@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12) if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 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( Emit(new CompanyCreatedEvent(
name.Trim(), name.Trim(),
cvr?.Trim(), cvr?.Trim(),
@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12) if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 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( Emit(new CompanyUpdatedEvent(
name.Trim(), name.Trim(),
cvr?.Trim(), cvr?.Trim(),

View file

@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
/// <summary> /// <summary>
/// Validates a single draft line. /// Validates a single draft line.
/// Amounts must be non-negative.
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0. /// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
/// At least one of DebitAmount or CreditAmount must be > 0. /// At least one of DebitAmount or CreditAmount must be > 0.
/// </summary> /// </summary>
private static void ValidateDraftLine(DraftLine line) 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) if (line.DebitAmount > 0 && line.CreditAmount > 0)
{ {
throw new DomainException( throw new DomainException(

View file

@ -168,24 +168,11 @@ public class VatCalculationService : IVatCalculationService
// - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT) // - 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) // - 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 // 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) DebitAmount = isDebit ? vatAmount : 0,
vatLine = vatLine with CreditAmount = !isDebit ? vatAmount : 0
{ };
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
};
}
vatLines.Add(vatLine); vatLines.Add(vatLine);

View file

@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent(
string userId, string userId,
string companyId, string companyId,
CompanyRole role, CompanyRole role,
string grantedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId> string grantedBy,
DateTimeOffset grantedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
{ {
public string UserId { get; } = userId; public string UserId { get; } = userId;
public string CompanyId { get; } = companyId; public string CompanyId { get; } = companyId;
public CompanyRole Role { get; } = role; public CompanyRole Role { get; } = role;
public string GrantedBy { get; } = grantedBy; public string GrantedBy { get; } = grantedBy;
public DateTimeOffset GrantedAt { get; } = grantedAt;
} }
/// <summary> /// <summary>
@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent(
/// Emitted when a user's access to a company is revoked. /// Emitted when a user's access to a company is revoked.
/// </summary> /// </summary>
public class UserCompanyAccessRevokedEvent( public class UserCompanyAccessRevokedEvent(
string revokedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId> string revokedBy,
DateTimeOffset revokedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
{ {
public string RevokedBy { get; } = revokedBy; public string RevokedBy { get; } = revokedBy;
public DateTimeOffset RevokedAt { get; } = revokedAt;
} }

View file

@ -33,7 +33,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
} }
// If previously revoked, we're re-granting // If previously revoked, we're re-granting
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy)); Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy, DateTimeOffset.UtcNow));
} }
/// <summary> /// <summary>
@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
"Adgang er allerede tilbagekaldt"); "Adgang er allerede tilbagekaldt");
} }
Emit(new UserCompanyAccessRevokedEvent(revokedBy)); Emit(new UserCompanyAccessRevokedEvent(revokedBy, DateTimeOffset.UtcNow));
} }
public void Apply(UserCompanyAccessGrantedEvent e) public void Apply(UserCompanyAccessGrantedEvent e)
@ -82,7 +82,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
CompanyId = e.CompanyId; CompanyId = e.CompanyId;
Role = e.Role; Role = e.Role;
GrantedBy = e.GrantedBy; GrantedBy = e.GrantedBy;
GrantedAt = DateTimeOffset.UtcNow; GrantedAt = e.GrantedAt;
IsActive = true; IsActive = true;
RevokedAt = null; RevokedAt = null;
RevokedBy = null; RevokedBy = null;
@ -96,7 +96,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
public void Apply(UserCompanyAccessRevokedEvent e) public void Apply(UserCompanyAccessRevokedEvent e)
{ {
IsActive = false; IsActive = false;
RevokedAt = DateTimeOffset.UtcNow; RevokedAt = e.RevokedAt;
RevokedBy = e.RevokedBy; RevokedBy = e.RevokedBy;
} }
} }

View file

@ -35,6 +35,19 @@ public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepo
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id }); return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id });
} }
public async Task<AttachmentReadModelDto?> 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<AttachmentReadModelDto>(sql, new { StoragePath = storagePath });
}
public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);

View file

@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories;
public interface IAttachmentRepository public interface IAttachmentRepository
{ {
Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default); Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default); Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default); Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default);

View file

@ -190,6 +190,15 @@ public static class StandardDanishAccounts
yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110"); yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110");
yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "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 // PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx
// Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat // Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat

View file

@ -1,3 +1,4 @@
using Books.Api.Domain;
using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories; using Books.Api.EventFlow.Repositories;
using Books.Api.Saft.Models; using Books.Api.Saft.Models;
@ -294,6 +295,9 @@ public class SaftExportService(
if (isDebit) totalDebit += entry.Amount; if (isDebit) totalDebit += entry.Amount;
else totalCredit += 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( return new SaftTransactionLine(
(idx + 1).ToString(), (idx + 1).ToString(),
accountNumber ?? entry.AccountId.ToString(), accountNumber ?? entry.AccountId.ToString(),
@ -302,7 +306,7 @@ public class SaftExportService(
creditAmount, creditAmount,
null, // CustomerID - could parse from reference null, // CustomerID - could parse from reference
null, // SupplierID null, // SupplierID
null); // TaxInfo - would need VAT code tracking taxInfo);
}).ToList(); }).ToList();
transactions.Add(new SaftTransaction( transactions.Add(new SaftTransaction(
@ -385,6 +389,37 @@ public class SaftExportService(
return null; return null;
} }
/// <summary>
/// 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%)".
/// </summary>
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;
}
/// <summary> /// <summary>
/// Validates a Danish CVR number. /// Validates a Danish CVR number.
/// A valid CVR is exactly 8 digits. /// A valid CVR is exactly 8 digits.

View file

@ -47,6 +47,7 @@ public class SaftXmlBuilder
writer.WriteStartElement("Header"); writer.WriteStartElement("Header");
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion); writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
writer.WriteElementString("AuditFileCountry", "DK");
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated); writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName); writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
writer.WriteElementString("SoftwareID", header.SoftwareID); writer.WriteElementString("SoftwareID", header.SoftwareID);
@ -72,6 +73,12 @@ public class SaftXmlBuilder
WriteContact(writer, company.Contact); 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 writer.WriteEndElement(); // Company
} }

View file

@ -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; }
}

View file

@ -105,7 +105,7 @@ export function DocumentUploadModal({
setIsPosting(true); setIsPosting(true);
try { try {
await postDraftMutation.mutateAsync(result.draftId); await postDraftMutation.mutateAsync(result.draftId);
message.success('Bogfoert!'); message.success('Bogført!');
onConfirm(); onConfirm();
} catch (err) { } catch (err) {
message.error('Kunne ikke bogføre. Prøv igen.'); message.error('Kunne ikke bogføre. Prøv igen.');
@ -154,7 +154,7 @@ export function DocumentUploadModal({
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
AI-tjenesten udtraekker information fra dokumentet AI-tjenesten udtrækker information fra dokumentet
</Text> </Text>
</div> </div>
</div> </div>
@ -206,7 +206,7 @@ export function DocumentUploadModal({
Luk Luk
</Button>, </Button>,
<Button key="view" type="primary" onClick={onConfirm}> <Button key="view" type="primary" onClick={onConfirm}>
Gaa til kladde Gå til kladde
</Button>, </Button>,
]} ]}
onCancel={onClose} onCancel={onClose}
@ -279,7 +279,7 @@ export function DocumentUploadModal({
loading={isPosting} loading={isPosting}
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)} disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
> >
Godkend og bogfoer Godkend og bogfør
</Button>, </Button>,
]} ]}
onCancel={handleCancel} onCancel={handleCancel}
@ -297,7 +297,7 @@ export function DocumentUploadModal({
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}> <Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
Foreslaaet bogfoering Foreslået bogføring
</Title> </Title>
{journalLines.length > 0 && ( {journalLines.length > 0 && (
isBalanced ? ( isBalanced ? (
@ -341,8 +341,8 @@ export function DocumentUploadModal({
/> />
) : ( ) : (
<Alert <Alert
message="Ingen kontobogfoering foreslaaet" message="Ingen kontobogføring foreslået"
description="AI kunne ikke foreslaa konti til dette dokument. Du kan tilfoeje dokumentet til kladden og bogfoere manuelt." description="AI kunne ikke foreslå konti til dette dokument. Du kan tilføje dokumentet til kladden og bogføre manuelt."
type="info" type="info"
showIcon showIcon
icon={<InfoCircleOutlined />} icon={<InfoCircleOutlined />}
@ -394,7 +394,7 @@ export function DocumentUploadModal({
<Divider style={{ margin: `${spacing.md}px 0` }} /> <Divider style={{ margin: `${spacing.md}px 0` }} />
<Alert <Alert
message="Ingen matchende banktransaktion fundet" message="Ingen matchende banktransaktion fundet"
description={`Der blev ikke fundet en pending banktransaktion paa ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`} description={`Der blev ikke fundet en pending banktransaktion på ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`}
type="info" type="info"
showIcon showIcon
icon={<InfoCircleOutlined />} icon={<InfoCircleOutlined />}
@ -406,7 +406,7 @@ export function DocumentUploadModal({
{result.accountSuggestion && ( {result.accountSuggestion && (
<div style={{ marginTop: spacing.sm }}> <div style={{ marginTop: spacing.sm }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
Kontoforslag baseret paa AI-analyse ( Kontoforslag baseret på AI-analyse (
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed) {Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
</Text> </Text>
</div> </div>
@ -474,7 +474,7 @@ function ExtractedInfoSection({
style={{ marginBottom: hasLineItems ? 12 : 0 }} style={{ marginBottom: hasLineItems ? 12 : 0 }}
> >
{extraction.vendor && ( {extraction.vendor && (
<Descriptions.Item label="Leverandoer" span={extraction.vendorCvr ? 1 : 2}> <Descriptions.Item label="Leverandør" span={extraction.vendorCvr ? 1 : 2}>
<Text strong>{extraction.vendor}</Text> <Text strong>{extraction.vendor}</Text>
</Descriptions.Item> </Descriptions.Item>
)} )}

View file

@ -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 { ShopOutlined } from '@ant-design/icons';
import { useCompanyStore } from '@/stores/companyStore'; import { useCompanyStore } from '@/stores/companyStore';
import { formatCVR } from '@/lib/formatters'; import { formatCVR } from '@/lib/formatters';
@ -22,7 +22,7 @@ export default function CompanySwitcher({ compact = false }: CompanySwitcherProp
}; };
if (companies.length === 0) { if (companies.length === 0) {
return <Skeleton.Input style={{ width: 200 }} active />; return <Text type="secondary">Ingen virksomheder</Text>;
} }
return ( return (

View file

@ -10,6 +10,7 @@ import {
MinusCircleOutlined, MinusCircleOutlined,
LockOutlined, LockOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { usePeriodStore } from '@/stores/periodStore'; import { usePeriodStore } from '@/stores/periodStore';
import { useCompanyStore } from '@/stores/companyStore'; import { useCompanyStore } from '@/stores/companyStore';
import { useFiscalYears } from '@/api/queries/fiscalYearQueries'; import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
@ -50,6 +51,7 @@ interface FiscalYearSelectorProps {
} }
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) { export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
const navigate = useNavigate();
const { activeCompany } = useCompanyStore(); const { activeCompany } = useCompanyStore();
const { const {
fiscalYears, fiscalYears,
@ -126,8 +128,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
if (onManage) { if (onManage) {
onManage(); onManage();
} else { } else {
// Navigate to settings page navigate('/indstillinger');
console.log('Navigate to fiscal year settings');
} }
}; };

View file

@ -1,5 +1,5 @@
import { useState } from 'react'; 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 { import {
UserOutlined, UserOutlined,
LogoutOutlined, LogoutOutlined,
@ -129,22 +129,28 @@ export default function Header({ isMobile = false }: HeaderProps) {
<Button <Button
type="text" type="text"
icon={<QuestionCircleOutlined />} icon={<QuestionCircleOutlined />}
onClick={() => window.open('https://help.books.dk', '_blank')} onClick={() => message.info('Hjælp er under udvikling')}
aria-label="Hjælp" aria-label="Hjælp"
/> />
</Tooltip> </Tooltip>
{/* Notifications */} {/* Notifications */}
<Tooltip title="Notifikationer"> <Popover
<Badge count={0} size="small"> content="Ingen nye notifikationer"
<Button title="Notifikationer"
type="text" trigger="click"
icon={<BellOutlined />} placement="bottomRight"
onClick={() => navigate('/indstillinger')} >
aria-label="Notifikationer" <Tooltip title="Notifikationer">
/> <Badge count={0} size="small">
</Badge> <Button
</Tooltip> type="text"
icon={<BellOutlined />}
aria-label="Notifikationer"
/>
</Badge>
</Tooltip>
</Popover>
{/* User Menu */} {/* User Menu */}
<Dropdown <Dropdown

View file

@ -148,6 +148,7 @@ export default function Sidebar() {
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onCollapse={toggleSidebar} onCollapse={toggleSidebar}
width={220} width={220}
aria-label="Hovednavigation"
style={{ style={{
overflow: 'auto', overflow: 'auto',
height: '100vh', height: '100vh',

View file

@ -38,8 +38,10 @@ import {
import { formatCurrency } from '@/lib/formatters'; import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods'; import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting'; import type { Account, Transaction } from '@/types/accounting';
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations'; import { useCloseFiscalYear, useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft } from '@/api/mutations/draftMutations';
import { message } from 'antd'; import { message } from 'antd';
import dayjs from 'dayjs';
const { Text, Title, Paragraph } = Typography; const { Text, Title, Paragraph } = Typography;
@ -85,6 +87,9 @@ export default function CloseFiscalYearWizard({
} = usePeriodStore(); } = usePeriodStore();
const closeFiscalYearMutation = useCloseFiscalYear(); const closeFiscalYearMutation = useCloseFiscalYear();
const createFiscalYearMutation = useCreateFiscalYear();
const createDraftMutation = useCreateJournalEntryDraft();
const updateDraftMutation = useUpdateJournalEntryDraft();
// Reset wizard when opened // Reset wizard when opened
useEffect(() => { useEffect(() => {
@ -164,34 +169,70 @@ export default function CloseFiscalYearWizard({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated // 1. Post closing entries as journal entry drafts
// in generateClosingEntries() but never actually posted to the ledger. for (const entry of closingEntries) {
// Before closing the fiscal year, these closing entries MUST be posted: // Create a draft for each closing entry
// 1. Revenue accounts should be zeroed out to the result account const draft = await createDraftMutation.mutateAsync({
// 2. Expense accounts should be zeroed out to the result account companyId: fiscalYear.companyId,
// 3. The net result should be transferred to the equity account (resultAccountId) name: entry.descriptionDanish,
// Without posting these entries, the opening balances for the next year will be incorrect. documentDate: fiscalYear.endDate,
description: entry.descriptionDanish,
fiscalYearId: fiscalYear.id,
});
// 1. Close open periods if requested (local store) // Add lines to the draft
if (entry.lines.length > 0) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: entry.lines.map((line, idx) => ({
lineNumber: idx + 1,
accountId: line.accountId,
debitAmount: line.debit,
creditAmount: line.credit,
description: entry.descriptionDanish,
})),
});
}
}
// 2. Close open periods if requested (local store)
if (closeOpenPeriods) { if (closeOpenPeriods) {
for (const period of openPeriodsInYear) { for (const period of openPeriodsInYear) {
closePeriod(period.id, 'system'); closePeriod(period.id, 'system');
} }
} }
// 2. Lock all periods in the year (local store) // 3. Lock all periods in the year (local store)
for (const period of yearPeriods) { for (const period of yearPeriods) {
lockPeriod(period.id, 'system'); lockPeriod(period.id, 'system');
} }
// 3. Call backend mutation to close the fiscal year // 4. Call backend mutation to close the fiscal year
await closeFiscalYearMutation.mutateAsync(fiscalYear.id); await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
// 4. Also update local store // 5. Also update local store
closeFiscalYear(fiscalYear.id, 'system'); closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system'); lockFiscalYear(fiscalYear.id, 'system');
// 5. Move to complete step // 6. Create next fiscal year if requested
if (createNextYear) {
const nextStartDate = dayjs(fiscalYear.endDate).add(1, 'day');
const nextEndDate = nextStartDate.add(12, 'month').subtract(1, 'day');
const nextStartYear = nextStartDate.year();
const nextEndYear = nextEndDate.year();
const nextName = nextStartYear === nextEndYear
? `${nextStartYear}`
: `${nextStartYear}/${nextEndYear}`;
await createFiscalYearMutation.mutateAsync({
companyId: fiscalYear.companyId,
name: nextName,
startDate: nextStartDate.format('YYYY-MM-DD'),
endDate: nextEndDate.format('YYYY-MM-DD'),
});
}
// 7. Move to complete step
setCurrentStep('complete'); setCurrentStep('complete');
onSuccess?.(); onSuccess?.();

View file

@ -206,8 +206,6 @@ export default function BankConnectionsTab({ companyId }: BankConnectionsTabProp
// Use backend callback URL - backend will handle OAuth and redirect back to frontend // Use backend callback URL - backend will handle OAuth and redirect back to frontend
const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001'; const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001';
const redirectUrl = `${apiBaseUrl}/api/banking/callback`; const redirectUrl = `${apiBaseUrl}/api/banking/callback`;
console.log('Enable Banking redirect URL:', redirectUrl);
const result = await startConnection.mutateAsync({ const result = await startConnection.mutateAsync({
companyId, companyId,
aspspName: selectedBank, aspspName: selectedBank,

View file

@ -78,7 +78,10 @@ export function AmountText({
const formatAmount = (): string => { const formatAmount = (): string => {
const formatted = formatCurrency(Math.abs(amount)); const formatted = formatCurrency(Math.abs(amount));
const sign = showSign && amount !== 0 ? (amount > 0 ? '+' : '-') : amount < 0 ? '-' : ''; // Always show +/- prefix for non-zero amounts (accessibility: not color-only)
// When showSign is explicitly true, same behavior; kept for API compatibility
const alwaysSign = true || showSign;
const sign = alwaysSign && amount !== 0 ? (amount > 0 ? '+' : '-') : amount < 0 ? '-' : '';
const suffix = showCurrency ? ` ${currencySuffix}` : ''; const suffix = showCurrency ? ` ${currencySuffix}` : '';
return `${sign}${formatted}${suffix}`; return `${sign}${formatted}${suffix}`;

View file

@ -302,7 +302,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
lines.push({ lines.push({
accountId: `vat-output-${vatCode}`, accountId: `vat-output-${vatCode}`,
accountNumber: outputVatAccount, accountNumber: outputVatAccount,
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms', accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
description: `Moms: ${description}`, description: `Moms: ${description}`,
debit: 0, debit: 0,
credit: vatAmount, credit: vatAmount,
@ -392,7 +392,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
lines: [], lines: [],
bankTransactionId: bankTransaction.id, bankTransactionId: bankTransaction.id,
isValid: false, isValid: false,
validationMessage: `Split-beloeb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`, validationMessage: `Split-beløb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`,
}; };
} }
@ -444,7 +444,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
generatedLines.push({ generatedLines.push({
accountId: `vat-output-${splitLine.vatCode}`, accountId: `vat-output-${splitLine.vatCode}`,
accountNumber: outputVatAccount, accountNumber: outputVatAccount,
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms', accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
description: `Moms: ${description}`, description: `Moms: ${description}`,
debit: 0, debit: 0,
credit: lineVat, credit: lineVat,

View file

@ -52,7 +52,7 @@ export function formatNumber(
*/ */
export function formatDate( export function formatDate(
date: string | Date, date: string | Date,
format: string = 'DD/MM/YYYY' format: string = 'DD-MM-YYYY'
): string { ): string {
return dayjs(date).format(format); return dayjs(date).format(format);
} }
@ -62,7 +62,7 @@ export function formatDate(
*/ */
export function formatDateTime( export function formatDateTime(
date: string | Date, date: string | Date,
format: string = 'DD/MM/YYYY HH:mm' format: string = 'DD-MM-YYYY HH:mm'
): string { ): string {
return dayjs(date).format(format); return dayjs(date).format(format);
} }

View file

@ -370,7 +370,7 @@ export function canPostToDate(
return { return {
allowed: false, allowed: false,
reason: 'Period is locked', reason: 'Period is locked',
reasonDanish: 'Perioden er laast', reasonDanish: 'Perioden er låst',
}; };
} }
@ -386,7 +386,7 @@ export function canPostToDate(
return { return {
allowed: false, allowed: false,
reason: 'Cannot post to future periods', reason: 'Cannot post to future periods',
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder', reasonDanish: 'Kan ikke bogføre i fremtidige perioder',
}; };
} }
@ -411,7 +411,7 @@ export function validatePeriodClose(
errors.push({ errors.push({
code: 'PERIOD_LOCKED', code: 'PERIOD_LOCKED',
message: 'Period is already locked and cannot be modified', message: 'Period is already locked and cannot be modified',
messageDanish: 'Perioden er allerede laast og kan ikke aendres', messageDanish: 'Perioden er allerede låst og kan ikke ændres',
}); });
} }

View file

@ -26,7 +26,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
}, },
reverseCharge: false, reverseCharge: false,
deductible: false, deductible: false,
description: 'Moms paa salg af varer og ydelser i Danmark', description: 'Moms på salg af varer og ydelser i Danmark',
}, },
K25: { K25: {
code: 'K25', code: 'K25',
@ -39,11 +39,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
}, },
reverseCharge: false, reverseCharge: false,
deductible: true, deductible: true,
description: 'Fradragsberettiget moms paa koeb', description: 'Fradragsberettiget moms på køb',
}, },
EU_VARE: { EU_VARE: {
code: 'EU_VARE', code: 'EU_VARE',
nameDanish: 'EU-varekoeb (erhvervelsesmoms)', nameDanish: 'EU-varekøb (erhvervelsesmoms)',
nameEnglish: 'EU goods purchase (acquisition VAT)', nameEnglish: 'EU goods purchase (acquisition VAT)',
rate: 0.25, rate: 0.25,
type: 'reverse_charge', type: 'reverse_charge',
@ -53,11 +53,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
}, },
reverseCharge: true, reverseCharge: true,
deductible: true, // Both output and input VAT deductible: true, // Both output and input VAT
description: 'Koeb af varer fra andre EU-lande med omvendt betalingspligt', description: 'Køb af varer fra andre EU-lande med omvendt betalingspligt',
}, },
EU_YDELSE: { EU_YDELSE: {
code: 'EU_YDELSE', code: 'EU_YDELSE',
nameDanish: 'EU-ydelseskoeb (omvendt betalingspligt)', nameDanish: 'EU-ydelseskøb (omvendt betalingspligt)',
nameEnglish: 'EU services purchase (reverse charge)', nameEnglish: 'EU services purchase (reverse charge)',
rate: 0.25, rate: 0.25,
type: 'reverse_charge', type: 'reverse_charge',
@ -67,7 +67,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
}, },
reverseCharge: true, reverseCharge: true,
deductible: true, deductible: true,
description: 'Koeb af ydelser fra udlandet med omvendt betalingspligt', description: 'Køb af ydelser fra udlandet med omvendt betalingspligt',
}, },
MOMSFRI: { MOMSFRI: {
code: 'MOMSFRI', code: 'MOMSFRI',
@ -114,7 +114,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = { export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
monthly: { monthly: {
type: 'monthly', type: 'monthly',
nameDanish: 'Maanedlig', nameDanish: 'Månedlig',
nameEnglish: 'Monthly', nameEnglish: 'Monthly',
deadlineDaysAfterPeriod: 25, // 25th of following month deadlineDaysAfterPeriod: 25, // 25th of following month
periodsPerYear: 12, periodsPerYear: 12,
@ -130,7 +130,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
}, },
'half-yearly': { 'half-yearly': {
type: 'half-yearly', type: 'half-yearly',
nameDanish: 'Halvaarslig', nameDanish: 'Halvårslig',
nameEnglish: 'Half-yearly', nameEnglish: 'Half-yearly',
deadlineDaysAfterPeriod: 60, // ~2 months after period deadlineDaysAfterPeriod: 60, // ~2 months after period
periodsPerYear: 2, periodsPerYear: 2,
@ -138,7 +138,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
}, },
yearly: { yearly: {
type: 'yearly', type: 'yearly',
nameDanish: 'Aarslig', nameDanish: 'Årslig',
nameEnglish: 'Yearly', nameEnglish: 'Yearly',
deadlineDaysAfterPeriod: 90, // March 1st for calendar year deadlineDaysAfterPeriod: 90, // March 1st for calendar year
periodsPerYear: 1, periodsPerYear: 1,
@ -150,7 +150,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
* SKAT VAT box definitions * SKAT VAT box definitions
*/ */
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = { export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
// VAT amounts (Momsbeloeb) // VAT amounts (Momsbeløb)
A: { A: {
id: 'A', id: 'A',
type: 'vat', type: 'vat',
@ -163,37 +163,37 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
B: { B: {
id: 'B', id: 'B',
type: 'vat', type: 'vat',
nameDanish: 'Koebsmoms', nameDanish: 'Købsmoms',
nameEnglish: 'Input VAT (purchases)', nameEnglish: 'Input VAT (purchases)',
description: 'Fradragsberettiget moms af koeb', description: 'Fradragsberettiget moms af køb',
skippable: false, skippable: false,
isDeductible: true, isDeductible: true,
}, },
C: { C: {
id: 'C', id: 'C',
type: 'vat', type: 'vat',
nameDanish: 'Moms af EU-varekoeb', nameDanish: 'Moms af EU-varekøb',
nameEnglish: 'VAT on EU goods purchases', nameEnglish: 'VAT on EU goods purchases',
description: 'Erhvervelsesmoms ved koeb af varer fra andre EU-lande', description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
skippable: true, skippable: true,
isDeductible: false, // Listed as output, but can be deducted via B isDeductible: false, // Listed as output, but can be deducted via B
}, },
D: { D: {
id: 'D', id: 'D',
type: 'vat', type: 'vat',
nameDanish: 'Moms af ydelseskoeb fra udland', nameDanish: 'Moms af ydelseskøb fra udland',
nameEnglish: 'VAT on foreign services', nameEnglish: 'VAT on foreign services',
description: 'Moms ved koeb af ydelser fra udlandet med omvendt betalingspligt', description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
skippable: true, skippable: true,
isDeductible: false, isDeductible: false,
}, },
// Basis/turnover amounts (Omsaetning) // Basis/turnover amounts (Omsætning)
'1': { '1': {
id: '1', id: '1',
type: 'basis', type: 'basis',
nameDanish: 'Salg med moms', nameDanish: 'Salg med moms',
nameEnglish: 'Sales with VAT', nameEnglish: 'Sales with VAT',
description: 'Vaerdi af varer og ydelser solgt med dansk moms (momsgrundlag)', description: 'Værdi af varer og ydelser solgt med dansk moms (momsgrundlag)',
skippable: false, skippable: false,
isDeductible: false, isDeductible: false,
}, },
@ -209,18 +209,18 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
'3': { '3': {
id: '3', id: '3',
type: 'basis', type: 'basis',
nameDanish: 'EU-varekoeb', nameDanish: 'EU-varekøb',
nameEnglish: 'EU goods purchases', nameEnglish: 'EU goods purchases',
description: 'Vaerdi af varer koebt fra andre EU-lande', description: 'Værdi af varer købt fra andre EU-lande',
skippable: true, skippable: true,
isDeductible: false, isDeductible: false,
}, },
'4': { '4': {
id: '4', id: '4',
type: 'basis', type: 'basis',
nameDanish: 'Ydelseskoeb fra udland', nameDanish: 'Ydelseskøb fra udland',
nameEnglish: 'Foreign services purchases', nameEnglish: 'Foreign services purchases',
description: 'Vaerdi af ydelser koebt fra udlandet', description: 'Værdi af ydelser købt fra udlandet',
skippable: true, skippable: true,
isDeductible: false, isDeductible: false,
}, },
@ -310,9 +310,9 @@ export function getPeriodicitetOptions(): Array<{ value: VATPeriodicitet; label:
value: config.type, value: config.type,
label: config.nameDanish, label: config.nameDanish,
description: config.threshold?.min description: config.threshold?.min
? `Omsaetning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK` ? `Omsætning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK`
: config.threshold?.max : config.threshold?.max
? `Omsaetning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK` ? `Omsætning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
: 'Standard', : 'Standard',
})); }));
} }

View file

@ -14,7 +14,6 @@ import {
} from 'antd'; } from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling'; import { showSuccess, showError } from '@/lib/errorHandling';
import { import {
ToolOutlined,
ReloadOutlined, ReloadOutlined,
LockOutlined, LockOutlined,
DashboardOutlined, DashboardOutlined,
@ -24,6 +23,7 @@ import { useCanAdmin } from '@/stores/companyStore';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { graphqlClient } from '@/api/client'; import { graphqlClient } from '@/api/client';
import { gql } from 'graphql-request'; import { gql } from 'graphql-request';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@ -129,13 +129,11 @@ export default function Admin() {
return ( return (
<div> <div>
{/* Header */} <PageHeader
<div style={{ marginBottom: 16 }}> title="Administration"
<Title level={4} style={{ margin: 0 }}> subtitle="Systemværktøjer til fejlfinding og vedligeholdelse"
<ToolOutlined /> Administration breadcrumbs={[{ title: 'Administration' }]}
</Title> />
<Text type="secondary">Systemværktøjer til fejlfinding og vedligeholdelse</Text>
</div>
<Alert <Alert
message="Advarsel" message="Advarsel"

View file

@ -35,6 +35,7 @@ import { useReconciliationStore } from '@/stores/reconciliationStore';
import { useCompanyStore } from '@/stores/companyStore'; import { useCompanyStore } from '@/stores/companyStore';
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries'; import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries'; import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
import { useActiveAccounts } from '@/api/queries/accountQueries';
import { formatCurrency, formatDate } from '@/lib/formatters'; import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme'; import { accountingColors } from '@/styles/theme';
import type { BankTransaction } from '@/types/accounting'; import type { BankTransaction } from '@/types/accounting';
@ -71,6 +72,7 @@ export default function Bankafstemning() {
// Fetch data from API // Fetch data from API
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id); const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id); const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id);
const isLoading = connectionsLoading || transactionsLoading; const isLoading = connectionsLoading || transactionsLoading;
@ -193,7 +195,7 @@ export default function Bankafstemning() {
}); });
} }
message.success('Postering tilfojet til afventende matches'); message.success('Postering tilføjet til afventende matches');
setIsCreateModalOpen(false); setIsCreateModalOpen(false);
setSelectedBankTx(null); setSelectedBankTx(null);
} catch (error) { } catch (error) {
@ -203,17 +205,6 @@ export default function Bankafstemning() {
} }
}; };
const handleSaveAll = () => {
if (pendingMatches.length === 0) {
message.warning('Ingen matches at gemme');
return;
}
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
// mark them as reconciled, and create journal entries for new transactions.
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
};
const handleApplySuggestion = (suggestion: MatchSuggestion) => { const handleApplySuggestion = (suggestion: MatchSuggestion) => {
addPendingMatch({ addPendingMatch({
bankTransactionId: suggestion.bankTransactionId, bankTransactionId: suggestion.bankTransactionId,
@ -263,14 +254,15 @@ export default function Bankafstemning() {
> >
Nulstil valg Nulstil valg
</Button> </Button>
<Button <Tooltip title="Gem-funktionen er under udvikling">
type="primary" <Button
icon={<CheckOutlined />} type="primary"
onClick={handleSaveAll} icon={<CheckOutlined />}
disabled={pendingMatches.length === 0} disabled
> >
Gem afstemninger ({pendingMatches.length}) Gem afstemninger ({pendingMatches.length})
</Button> </Button>
</Tooltip>
</Space> </Space>
} }
/> />
@ -389,7 +381,7 @@ export default function Bankafstemning() {
</Space> </Space>
} }
size="small" size="small"
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }} styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
> >
{bankTransactions.length === 0 ? ( {bankTransactions.length === 0 ? (
<Empty <Empty
@ -500,7 +492,7 @@ export default function Bankafstemning() {
</Space> </Space>
} }
size="small" size="small"
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }} styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
> >
{ledgerEntries.length === 0 ? ( {ledgerEntries.length === 0 ? (
<Empty <Empty
@ -626,7 +618,7 @@ export default function Bankafstemning() {
{/* Create Entry Modal */} {/* Create Entry Modal */}
<Modal <Modal
title="Opret bogforingspost" title="Opret bogføringspost"
open={isCreateModalOpen} open={isCreateModalOpen}
onCancel={() => { onCancel={() => {
setIsCreateModalOpen(false); setIsCreateModalOpen(false);
@ -674,12 +666,12 @@ export default function Bankafstemning() {
> >
<Select <Select
placeholder="Vælg konto" placeholder="Vælg konto"
options={[ showSearch
{ value: '6100', label: '6100 - Husleje' }, optionFilterProp="label"
{ value: '6800', label: '6800 - Kontorartikler' }, options={activeAccounts.map((acc) => ({
{ value: '5000', label: '5000 - Varekøb' }, value: acc.id,
{ value: '4000', label: '4000 - Salg' }, label: `${acc.accountNumber} - ${acc.name}`,
]} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="vatCode" label="Momskode"> <Form.Item name="vatCode" label="Momskode">

View file

@ -119,9 +119,6 @@ export default function CompanySetupWizard() {
return; return;
} }
// Debug logging
console.log('Creating company with values:', values);
const company = await createCompany.mutateAsync({ const company = await createCompany.mutateAsync({
name: values.name.trim(), name: values.name.trim(),
cvr: values.cvr?.trim() || undefined, cvr: values.cvr?.trim() || undefined,

View file

@ -7,6 +7,7 @@ import {
WarningOutlined, WarningOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Line, Pie, Column } from '@ant-design/charts'; import { Line, Pie, Column } from '@ant-design/charts';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany'; import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore'; import { useCompanyStore } from '@/stores/companyStore';
@ -312,7 +313,7 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)} formatter={(value) => formatCurrency(value as number)}
/> />
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<a href="/momsindberetning">Se momsindberetning</a> <Link to="/momsindberetning">Se momsindberetning</Link>
</div> </div>
</Card> </Card>
</Col> </Col>
@ -384,7 +385,7 @@ export default function Dashboard() {
<Card <Card
title="Seneste transaktioner" title="Seneste transaktioner"
size="small" size="small"
bodyStyle={{ padding: 0 }} styles={{ body: { padding: 0 } }}
> >
<div style={{ maxHeight: 240, overflow: 'auto' }}> <div style={{ maxHeight: 240, overflow: 'auto' }}>
{recentTransactions.length > 0 ? ( {recentTransactions.length > 0 ? (
@ -438,9 +439,9 @@ export default function Dashboard() {
<Col> <Col>
<Space> <Space>
<ClockCircleOutlined style={{ color: accountingColors.warning }} /> <ClockCircleOutlined style={{ color: accountingColors.warning }} />
<a href="/momsindberetning"> <Link to="/momsindberetning">
<Text>Se momsindberetning</Text> <Text>Se momsindberetning</Text>
</a> </Link>
</Space> </Space>
</Col> </Col>
{metrics.overdueInvoices > 0 && ( {metrics.overdueInvoices > 0 && (

View file

@ -9,7 +9,7 @@ import {
Button, Button,
Tag, Tag,
Alert, Alert,
Spin, Skeleton,
} from 'antd'; } from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling'; import { showSuccess, showError } from '@/lib/errorHandling';
import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons'; import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
@ -18,8 +18,9 @@ import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
import { useExportSaft, downloadSaftFile } from '@/api/mutations/saftMutations'; import { useExportSaft, downloadSaftFile } from '@/api/mutations/saftMutations';
import { formatDate } from '@/lib/formatters'; import { formatDate } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens'; import { spacing } from '@/styles/designTokens';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
export default function Eksport() { export default function Eksport() {
const { company } = useCompany(); const { company } = useCompany();
@ -53,8 +54,20 @@ export default function Eksport() {
if (fiscalYearsLoading) { if (fiscalYearsLoading) {
return ( return (
<div style={{ textAlign: 'center', padding: spacing.xl }}> <div>
<Spin size="large" /> <PageHeader
title="Eksporter data"
subtitle="Eksporter regnskabsdata til forskellige formater for compliance og rapportering."
breadcrumbs={[{ title: 'Rapporter' }, { title: 'Eksport' }]}
/>
<Row gutter={[spacing.lg, spacing.lg]}>
<Col xs={24} md={12}>
<Card><Skeleton active paragraph={{ rows: 6 }} /></Card>
</Col>
<Col xs={24} md={12}>
<Card><Skeleton active paragraph={{ rows: 6 }} /></Card>
</Col>
</Row>
</div> </div>
); );
} }
@ -63,10 +76,11 @@ export default function Eksport() {
return ( return (
<div> <div>
<Title level={4}>Eksporter data</Title> <PageHeader
<Paragraph type="secondary"> title="Eksporter data"
Eksporter regnskabsdata til forskellige formater for compliance og rapportering. subtitle="Eksporter regnskabsdata til forskellige formater for compliance og rapportering."
</Paragraph> breadcrumbs={[{ title: 'Rapporter' }, { title: 'Eksport' }]}
/>
<Row gutter={[spacing.lg, spacing.lg]}> <Row gutter={[spacing.lg, spacing.lg]}>
{/* SAF-T Export Card */} {/* SAF-T Export Card */}

View file

@ -11,7 +11,7 @@ import {
Input, Input,
Select, Select,
InputNumber, InputNumber,
Spin, Skeleton,
Alert, Alert,
Drawer, Drawer,
Descriptions, Descriptions,
@ -42,6 +42,7 @@ import { useCurrentFiscalYear } from '@/stores/periodStore';
import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries'; import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries';
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries'; import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
import { useActiveProducts, type Product } from '@/api/queries/productQueries'; import { useActiveProducts, type Product } from '@/api/queries/productQueries';
import { useActiveAccounts } from '@/api/queries/accountQueries';
import { import {
useCreateInvoice, useCreateInvoice,
useAddInvoiceLine, useAddInvoiceLine,
@ -124,6 +125,13 @@ export default function Fakturaer() {
// Fetch products for line form // Fetch products for line form
const { data: products = [] } = useActiveProducts(company?.id); const { data: products = [] } = useActiveProducts(company?.id);
// Fetch accounts for payment modal (filter for bank-type accounts: asset accounts starting with 1)
const { data: allAccounts = [] } = useActiveAccounts(company?.id);
const bankAccounts = useMemo(
() => allAccounts.filter((acc) => acc.type === 'asset' && parseInt(acc.accountNumber, 10) >= 1000 && parseInt(acc.accountNumber, 10) < 2000),
[allAccounts]
);
// Mutations // Mutations
const createInvoiceMutation = useCreateInvoice(); const createInvoiceMutation = useCreateInvoice();
const addInvoiceLineMutation = useAddInvoiceLine(); const addInvoiceLineMutation = useAddInvoiceLine();
@ -560,9 +568,7 @@ export default function Fakturaer() {
{/* Invoice Table */} {/* Invoice Table */}
<Card size="small"> <Card size="small">
{loading ? ( {loading ? (
<Spin tip="Indlæser fakturaer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}> <Skeleton active paragraph={{ rows: 8 }} />
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredInvoices.length > 0 ? ( ) : filteredInvoices.length > 0 ? (
<Table <Table
dataSource={filteredInvoices} dataSource={filteredInvoices}
@ -968,9 +974,12 @@ export default function Fakturaer() {
> >
<Select <Select
placeholder="Vælg bankkonto" placeholder="Vælg bankkonto"
options={[ showSearch
{ value: 'bank-hovedkonto', label: '5600 - Bankkonto' }, optionFilterProp="label"
]} options={bankAccounts.map((acc) => ({
value: acc.id,
label: `${acc.accountNumber} - ${acc.name}`,
}))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="paymentDate" label="Betalingsdato"> <Form.Item name="paymentDate" label="Betalingsdato">

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { import {
Typography, Typography,
Button, Button,
@ -15,6 +15,9 @@ import {
Dropdown, Dropdown,
Skeleton, Skeleton,
Empty, Empty,
Descriptions,
Table,
Drawer,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@ -34,7 +37,7 @@ import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
import { formatCurrency } from '@/lib/formatters'; import { formatCurrency } from '@/lib/formatters';
import { PageHeader } from '@/components/shared/PageHeader'; import { PageHeader } from '@/components/shared/PageHeader';
import { validateDoubleEntry } from '@/lib/accounting'; 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 { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
import { usePeriodStore } from '@/stores/periodStore'; import { usePeriodStore } from '@/stores/periodStore';
@ -51,7 +54,10 @@ interface DraftDisplay {
totalCredit: number; totalCredit: number;
isReconciled: boolean; isReconciled: boolean;
isVoided: boolean; isVoided: boolean;
status: JournalEntryDraftStatus;
lines: JournalEntryDraft['lines']; lines: JournalEntryDraft['lines'];
postedAt?: string;
postedBy?: string;
} }
export default function Kassekladde() { export default function Kassekladde() {
@ -59,6 +65,10 @@ export default function Kassekladde() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDraft, setEditingDraft] = useState<DraftDisplay | null>(null); const [editingDraft, setEditingDraft] = useState<DraftDisplay | null>(null);
const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null); const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [accountFilter, setAccountFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [detailDraft, setDetailDraft] = useState<DraftDisplay | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [lines, setLines] = useState<Partial<TransactionLine>[]>([ const [lines, setLines] = useState<Partial<TransactionLine>[]>([
{ debit: 0, credit: 0 }, { debit: 0, credit: 0 },
@ -79,7 +89,7 @@ export default function Kassekladde() {
const isLoading = accountsLoading || draftsLoading; const isLoading = accountsLoading || draftsLoading;
// Convert drafts to display format // Convert drafts to display format
const displayData: DraftDisplay[] = drafts.map(draft => ({ const displayData: DraftDisplay[] = useMemo(() => drafts.map(draft => ({
id: draft.id, id: draft.id,
transactionNumber: draft.voucherNumber || draft.name, transactionNumber: draft.voucherNumber || draft.name,
date: draft.documentDate || draft.createdAt, 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, totalCredit: draft.lines?.reduce((sum, l) => sum + (l.creditAmount || 0), 0) ?? 0,
isReconciled: draft.status === 'posted', isReconciled: draft.status === 'posted',
isVoided: draft.status === 'discarded', 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<DraftDisplay>[] = [ const columns: DataTableColumn<DraftDisplay>[] = [
{ {
@ -134,7 +199,7 @@ export default function Kassekladde() {
return <Tag color="red">Annulleret</Tag>; return <Tag color="red">Annulleret</Tag>;
} }
return value ? ( return value ? (
<Tag color="green">Bogført</Tag> <Tag color="green">Bogfort</Tag>
) : ( ) : (
<Tag color="orange">Kladde</Tag> <Tag color="orange">Kladde</Tag>
); );
@ -192,7 +257,7 @@ export default function Kassekladde() {
const handleAction = (action: string, record: DraftDisplay) => { const handleAction = (action: string, record: DraftDisplay) => {
switch (action) { switch (action) {
case 'view': case 'view':
message.info(`Vis detaljer for bilag ${record.transactionNumber}`); setDetailDraft(record);
break; break;
case 'edit': case 'edit':
setEditingDraft(record); setEditingDraft(record);
@ -236,7 +301,7 @@ export default function Kassekladde() {
case 'void': case 'void':
Modal.confirm({ Modal.confirm({
title: 'Annuller bilag', 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', okText: 'Annuller bilag',
okType: 'danger', okType: 'danger',
cancelText: 'Fortryd', cancelText: 'Fortryd',
@ -287,7 +352,7 @@ export default function Kassekladde() {
const validation = validateDoubleEntry(lines as TransactionLine[]); const validation = validateDoubleEntry(lines as TransactionLine[]);
if (!validation.valid) { if (!validation.valid) {
message.error( 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; return;
} }
@ -347,6 +412,7 @@ export default function Kassekladde() {
} }
setIsModalOpen(false); setIsModalOpen(false);
setEditingDraft(null);
form.resetFields(); form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
} catch (error) { } 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[]); const balance = validateDoubleEntry(lines as TransactionLine[]);
if (isLoading) { if (isLoading) {
@ -364,7 +447,7 @@ export default function Kassekladde() {
<PageHeader <PageHeader
title="Kassekladde" title="Kassekladde"
subtitle={activeCompany?.name} subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]} breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
/> />
<Skeleton active paragraph={{ rows: 10 }} /> <Skeleton active paragraph={{ rows: 10 }} />
</div> </div>
@ -376,13 +459,15 @@ export default function Kassekladde() {
<PageHeader <PageHeader
title="Kassekladde" title="Kassekladde"
subtitle={activeCompany?.name} subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]} breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
extra={ extra={
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={() => {
setEditingDraft(null); setEditingDraft(null);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
> >
@ -397,36 +482,58 @@ export default function Kassekladde() {
placeholder={['Fra dato', 'Til dato']} placeholder={['Fra dato', 'Til dato']}
value={dateFilter} value={dateFilter}
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)} onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
format="DD/MM/YYYY" format="DD-MM-YYYY"
/> />
<Select {showAdvancedFilters && (
placeholder="Konto" <>
style={{ width: 200 }} <Select
allowClear placeholder="Konto"
options={accounts.map((acc) => ({ style={{ width: 200 }}
value: acc.id, allowClear
label: `${acc.accountNumber} - ${acc.name}`, value={accountFilter}
}))} onChange={(value) => setAccountFilter(value ?? null)}
/> options={accounts.map((acc) => ({
<Select value: acc.id,
placeholder="Status" label: `${acc.accountNumber} - ${acc.name}`,
style={{ width: 120 }} }))}
allowClear />
options={[ <Select
{ value: 'posted', label: 'Bogført' }, placeholder="Status"
{ value: 'draft', label: 'Kladde' }, style={{ width: 120 }}
{ value: 'discarded', label: 'Annulleret' }, allowClear
]} value={statusFilter}
/> onChange={(value) => setStatusFilter(value ?? null)}
<Button icon={<FilterOutlined />}>Flere filtre</Button> options={[
{ value: 'posted', label: 'Bogfort' },
{ value: 'draft', label: 'Kladde' },
{ value: 'discarded', label: 'Annulleret' },
]}
/>
</>
)}
<Button
icon={<FilterOutlined />}
type={showAdvancedFilters ? 'primary' : 'default'}
ghost={showAdvancedFilters}
onClick={() => {
setShowAdvancedFilters(!showAdvancedFilters);
if (showAdvancedFilters) {
// Clear advanced filters when hiding
setAccountFilter(null);
setStatusFilter(null);
}
}}
>
{showAdvancedFilters ? 'Skjul filtre' : 'Flere filtre'}
</Button>
</Space> </Space>
{/* Data Table */} {/* Data Table */}
{displayData.length === 0 ? ( {filteredData.length === 0 ? (
<Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." /> <Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." />
) : ( ) : (
<DataTable<DraftDisplay> <DataTable<DraftDisplay>
data={displayData} data={filteredData}
columns={columns} columns={columns}
exportFilename="kassekladde" exportFilename="kassekladde"
rowSelection="multiple" rowSelection="multiple"
@ -437,12 +544,95 @@ export default function Kassekladde() {
/> />
)} )}
{/* Detail Drawer */}
<Drawer
title={`Bilag #${detailDraft?.transactionNumber ?? ''}`}
open={!!detailDraft}
onClose={() => setDetailDraft(null)}
width={600}
>
{detailDraft && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Bilagsnr.">
#{detailDraft.transactionNumber}
</Descriptions.Item>
<Descriptions.Item label="Dato">
{detailDraft.date ? dayjs(detailDraft.date).format('DD-MM-YYYY') : '-'}
</Descriptions.Item>
<Descriptions.Item label="Beskrivelse">
{detailDraft.description}
</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={getStatusLabel(detailDraft.status).color}>
{getStatusLabel(detailDraft.status).label}
</Tag>
</Descriptions.Item>
{detailDraft.isReconciled && detailDraft.postedAt && (
<Descriptions.Item label="Bogfort">
{dayjs(detailDraft.postedAt).format('DD-MM-YYYY HH:mm')}
</Descriptions.Item>
)}
{detailDraft.postedBy && (
<Descriptions.Item label="Bogfort af">
{detailDraft.postedBy}
</Descriptions.Item>
)}
<Descriptions.Item label="Total debet">
{formatCurrency(detailDraft.totalDebit)}
</Descriptions.Item>
<Descriptions.Item label="Total kredit">
{formatCurrency(detailDraft.totalCredit)}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
Posteringslinjer
</Typography.Title>
<Table
dataSource={detailDraft.lines.map((l, idx) => ({ ...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"
/>
</>
)}
</Drawer>
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
<Modal <Modal
title={editingDraft ? 'Rediger bilag' : 'Nyt bilag'} title={editingDraft ? 'Rediger bilag' : 'Nyt bilag'}
open={isModalOpen} open={isModalOpen}
onCancel={() => { onCancel={() => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingDraft(null);
form.resetFields(); form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]); setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
}} }}
@ -457,10 +647,10 @@ export default function Kassekladde() {
<Form.Item <Form.Item
name="date" name="date"
label="Dato" label="Dato"
rules={[{ required: true, message: 'Vælg dato' }]} rules={[{ required: true, message: 'Vaelg dato' }]}
initialValue={dayjs()} initialValue={dayjs()}
> >
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} /> <DatePicker format="DD-MM-YYYY" style={{ width: 150 }} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="description" name="description"
@ -493,7 +683,7 @@ export default function Kassekladde() {
<td style={{ padding: 4 }}> <td style={{ padding: 4 }}>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="Vælg konto" placeholder="Vaelg konto"
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
value={line.accountId} value={line.accountId}
@ -562,7 +752,7 @@ export default function Kassekladde() {
<tr style={{ borderTop: '2px solid #f0f0f0' }}> <tr style={{ borderTop: '2px solid #f0f0f0' }}>
<td style={{ padding: 8 }}> <td style={{ padding: 8 }}>
<Button type="dashed" size="small" onClick={handleAddLine}> <Button type="dashed" size="small" onClick={handleAddLine}>
+ Tilføj linje + Tilf&oslash;j linje
</Button> </Button>
</td> </td>
<td <td

View file

@ -153,7 +153,7 @@ export default function Kontooversigt() {
if (selectedAccount) { if (selectedAccount) {
// TODO: Backend does not yet have an updateAccount mutation. // TODO: Backend does not yet have an updateAccount mutation.
// For now, show a message indicating this is not yet supported. // For now, show a message indicating this is not yet supported.
message.warning('Redigering af konti er endnu ikke understottet i backend'); message.warning('Redigering af konti er endnu ikke understøttet i backend');
} else { } else {
// Create new account // Create new account
await createAccountMutation.mutateAsync({ await createAccountMutation.mutateAsync({
@ -252,7 +252,7 @@ export default function Kontooversigt() {
title="Kontooversigt" title="Kontooversigt"
subtitle={activeCompany?.name} subtitle={activeCompany?.name}
breadcrumbs={[ breadcrumbs={[
{ title: 'Bogføring', path: '/bogforing' }, { title: 'Bogføring' },
{ title: 'Kontooversigt' }, { title: 'Kontooversigt' },
]} ]}
extra={ extra={
@ -316,7 +316,7 @@ export default function Kontooversigt() {
<Card <Card
bordered={false} bordered={false}
bodyStyle={{ padding: 0 }} styles={{ body: { padding: 0 } }}
title={ title={
<Input <Input
prefix={<SearchOutlined className="text-gray-400" />} prefix={<SearchOutlined className="text-gray-400" />}

View file

@ -11,7 +11,7 @@ import {
Input, Input,
Select, Select,
InputNumber, InputNumber,
Spin, Skeleton,
Alert, Alert,
Drawer, Drawer,
Descriptions, Descriptions,
@ -65,6 +65,7 @@ import { spacing } from '@/styles/designTokens';
import { accountingColors } from '@/styles/theme'; import { accountingColors } from '@/styles/theme';
import { AmountText } from '@/components/shared/AmountText'; import { AmountText } from '@/components/shared/AmountText';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -438,25 +439,16 @@ export default function Kreditnotaer() {
return ( return (
<div> <div>
{/* Header */} <PageHeader
<div title="Kreditnotaer"
style={{ subtitle={company?.name}
display: 'flex', breadcrumbs={[{ title: 'Salg' }, { title: 'Kreditnotaer' }]}
justifyContent: 'space-between', extra={
alignItems: 'center', <Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
marginBottom: spacing.lg, Ny kreditnota
}} </Button>
> }
<div> />
<Title level={4} style={{ margin: 0 }}>
Kreditnotaer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
Ny kreditnota
</Button>
</div>
{/* Error State */} {/* Error State */}
{error && ( {error && (
@ -549,12 +541,7 @@ export default function Kreditnotaer() {
{/* Credit Note Table */} {/* Credit Note Table */}
<Card size="small"> <Card size="small">
{loading ? ( {loading ? (
<Spin <Skeleton active paragraph={{ rows: 8 }} />
tip="Indlæser kreditnotaer..."
style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}
>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredCreditNotes.length > 0 ? ( ) : filteredCreditNotes.length > 0 ? (
<Table <Table
dataSource={filteredCreditNotes} dataSource={filteredCreditNotes}

View file

@ -12,7 +12,7 @@ import {
Input, Input,
Select, Select,
Switch, Switch,
Spin, Skeleton,
Alert, Alert,
Drawer, Drawer,
Descriptions, Descriptions,
@ -394,9 +394,7 @@ export default function Kunder() {
{/* Customer Table */} {/* Customer Table */}
<Card size="small"> <Card size="small">
{loading ? ( {loading ? (
<Spin tip="Indlæser kunder..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}> <Skeleton active paragraph={{ rows: 8 }} />
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredCustomers.length > 0 ? ( ) : filteredCustomers.length > 0 ? (
<Table <Table
dataSource={filteredCustomers} dataSource={filteredCustomers}

View file

@ -25,9 +25,10 @@ import { useCompany } from '@/hooks/useCompany';
import { formatCurrency } from '@/lib/formatters'; import { formatCurrency } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme'; import { accountingColors } from '@/styles/theme';
import { DemoDataDisclaimer } from '@/components/shared'; import { DemoDataDisclaimer } from '@/components/shared';
import { PageHeader } from '@/components/shared/PageHeader';
import type { Employee, PayrollEntry } from '@/types/accounting'; import type { Employee, PayrollEntry } from '@/types/accounting';
const { Title, Text } = Typography; const { Text } = Typography;
// Mock data // Mock data
const mockEmployees: Employee[] = [ const mockEmployees: Employee[] = [
@ -273,25 +274,14 @@ export default function Loenforstaelse() {
return ( return (
<div> <div>
{/* Header */} <PageHeader
<div title="Lønforståelse"
style={{ subtitle={company?.name}
display: 'flex', breadcrumbs={[{ title: 'Løn' }, { title: 'Lønforståelse' }]}
justifyContent: 'space-between', extra={
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Lønforståelse
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button> <Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button>
</Space> }
</div> />
<DemoDataDisclaimer /> <DemoDataDisclaimer />

View file

@ -17,6 +17,7 @@ import {
Descriptions, Descriptions,
Empty, Empty,
Skeleton, Skeleton,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
DownloadOutlined, DownloadOutlined,
@ -89,25 +90,25 @@ export default function Momsindberetning() {
}, },
{ {
boxNumber: 2, boxNumber: 2,
nameDanish: 'Moms af varekob i udlandet (EU)', nameDanish: 'Moms af varekøb i udlandet (EU)',
nameEnglish: 'VAT on goods from 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, amount: vatReport.boxC,
basis: vatReport.basis3, basis: vatReport.basis3,
}, },
{ {
boxNumber: 3, boxNumber: 3,
nameDanish: 'Moms af ydelseskob i udlandet', nameDanish: 'Moms af ydelseskøb i udlandet',
nameEnglish: 'VAT on services from abroad', 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, amount: vatReport.boxD,
basis: vatReport.basis4, basis: vatReport.basis4,
}, },
{ {
boxNumber: 4, boxNumber: 4,
nameDanish: 'Kobsmoms', nameDanish: 'Købsmoms',
nameEnglish: 'Input VAT', nameEnglish: 'Input VAT',
description: 'Fradragsberettiget moms af kob', description: 'Fradragsberettiget moms af køb',
amount: vatReport.boxB, amount: vatReport.boxB,
basis: undefined, // Backend doesn't provide a specific basis for input VAT basis: undefined, // Backend doesn't provide a specific basis for input VAT
}, },
@ -125,7 +126,7 @@ export default function Momsindberetning() {
return [ return [
{ type: 'Salgsmoms', value: vatReport.boxA }, { type: 'Salgsmoms', value: vatReport.boxA },
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) }, { 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); ].filter(d => d.value > 0);
}, [vatReport, inputVAT]); }, [vatReport, inputVAT]);
@ -223,7 +224,9 @@ export default function Momsindberetning() {
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]} breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
extra={ extra={
<Space> <Space>
<Button icon={<DownloadOutlined />}>Eksporter</Button> <Tooltip title="Eksport er endnu ikke implementeret">
<Button icon={<DownloadOutlined />} disabled>Eksporter</Button>
</Tooltip>
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined />}
@ -254,7 +257,7 @@ export default function Momsindberetning() {
onChange={setPeriodType} onChange={setPeriodType}
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={[
{ value: 'monthly', label: 'Maanedlig' }, { value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' }, { value: 'quarterly', label: 'Kvartalsvis' },
]} ]}
/> />
@ -277,7 +280,7 @@ export default function Momsindberetning() {
{error && ( {error && (
<Alert <Alert
type="error" type="error"
message="Fejl ved indlaesning af momsdata" message="Fejl ved indlæsning af momsdata"
description={error.message} description={error.message}
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@ -376,7 +379,7 @@ export default function Momsindberetning() {
<Card title="Tidligere indberetninger" size="small"> <Card title="Tidligere indberetninger" size="small">
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" /> <DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
<Text type="secondary"> <Text type="secondary">
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret. Tidligere indberetninger vil blive vist her når SKAT-integration er implementeret.
</Text> </Text>
</Card> </Card>
</Col> </Col>
@ -392,9 +395,11 @@ export default function Momsindberetning() {
<Button key="cancel" onClick={() => setIsPreviewOpen(false)}> <Button key="cancel" onClick={() => setIsPreviewOpen(false)}>
Luk Luk
</Button>, </Button>,
<Button key="export" icon={<DownloadOutlined />}> <Tooltip title="PDF-download er endnu ikke implementeret" key="export-tooltip">
Download PDF <Button key="export" icon={<DownloadOutlined />} disabled>
</Button>, Download PDF
</Button>
</Tooltip>,
<Button <Button
key="skat-link" key="skat-link"
type="primary" type="primary"
@ -404,7 +409,7 @@ export default function Momsindberetning() {
setIsPreviewOpen(false); setIsPreviewOpen(false);
}} }}
> >
Ga til skat.dk Gå til skat.dk
</Button>, </Button>,
]} ]}
> >
@ -436,7 +441,7 @@ export default function Momsindberetning() {
{ dataIndex: 'nameDanish', title: 'Felt' }, { dataIndex: 'nameDanish', title: 'Felt' },
{ {
dataIndex: 'amount', dataIndex: 'amount',
title: 'Belob', title: 'Beløb',
align: 'right', align: 'right',
render: (v: number) => formatCurrency(v), render: (v: number) => formatCurrency(v),
}, },

View file

@ -10,7 +10,7 @@ import {
Form, Form,
Input, Input,
Select, Select,
Spin, Skeleton,
Alert, Alert,
Drawer, Drawer,
Descriptions, Descriptions,
@ -495,9 +495,7 @@ export default function Ordrer() {
{/* Order Table */} {/* Order Table */}
<Card size="small"> <Card size="small">
{loading ? ( {loading ? (
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}> <Skeleton active paragraph={{ rows: 8 }} />
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredOrders.length > 0 ? ( ) : filteredOrders.length > 0 ? (
<Table <Table
dataSource={filteredOrders} dataSource={filteredOrders}

View file

@ -12,7 +12,6 @@ import {
Divider, Divider,
message, message,
Space, Space,
Empty,
} from 'antd'; } from 'antd';
import { import {
SaveOutlined, SaveOutlined,
@ -24,6 +23,8 @@ import {
import { useCompany } from '@/hooks/useCompany'; import { useCompany } from '@/hooks/useCompany';
import { useUpdateCompany } from '@/api/mutations/companyMutations'; import { useUpdateCompany } from '@/api/mutations/companyMutations';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer'; import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
import BankConnectionsTab from '@/components/settings/BankConnectionsTab';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -293,33 +294,7 @@ export default function Settings() {
</span> </span>
), ),
children: ( children: (
<Card> <BankConnectionsTab companyId={company?.id} />
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Title level={5} style={{ margin: 0 }}>
Tilknyttede bankkonti
</Title>
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
</div>
<Divider />
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Ingen bankkonti tilknyttet endnu"
>
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
Tilføj bankkonto
</Button>
</Empty>
</Space>
</Card>
), ),
}, },
{ {
@ -360,13 +335,11 @@ export default function Settings() {
return ( return (
<div> <div>
{/* Header */} <PageHeader
<div style={{ marginBottom: 16 }}> title="Indstillinger"
<Title level={4} style={{ margin: 0 }}> subtitle={company?.name}
Indstillinger breadcrumbs={[{ title: 'Indstillinger' }]}
</Title> />
<Text type="secondary">{company?.name}</Text>
</div>
<Tabs items={tabItems} /> <Tabs items={tabItems} />
</div> </div>

View file

@ -27,6 +27,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { UploadProps } from 'antd'; import type { UploadProps } from 'antd';
import { spacing } from '@/styles/designTokens'; import { spacing } from '@/styles/designTokens';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -55,8 +56,7 @@ export default function UserSettings() {
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
const values = await profileForm.validateFields(); await profileForm.validateFields();
console.log('Saving profile:', values);
showSuccess('Profil opdateret'); showSuccess('Profil opdateret');
} catch (error) { } catch (error) {
console.error('Validation failed:', error); console.error('Validation failed:', error);
@ -71,7 +71,6 @@ export default function UserSettings() {
showError('Adgangskoderne stemmer ikke overens'); showError('Adgangskoderne stemmer ikke overens');
return; return;
} }
console.log('Changing password');
showSuccess('Adgangskode opdateret'); showSuccess('Adgangskode opdateret');
passwordForm.resetFields(); passwordForm.resetFields();
setIsChangingPassword(false); setIsChangingPassword(false);
@ -83,8 +82,7 @@ export default function UserSettings() {
const handleSaveNotifications = async () => { const handleSaveNotifications = async () => {
try { try {
const values = await notificationForm.validateFields(); await notificationForm.validateFields();
console.log('Saving notifications:', values);
showSuccess('Notifikationsindstillinger gemt'); showSuccess('Notifikationsindstillinger gemt');
} catch (error) { } catch (error) {
console.error('Validation failed:', error); console.error('Validation failed:', error);
@ -124,8 +122,7 @@ export default function UserSettings() {
} }
return false; // Prevent auto upload return false; // Prevent auto upload
}, },
onChange: (info) => { onChange: () => {
console.log('Upload:', info.file);
showSuccess('Profilbillede opdateret'); showSuccess('Profilbillede opdateret');
}, },
}; };
@ -458,13 +455,11 @@ export default function UserSettings() {
return ( return (
<div> <div>
{/* Header */} <PageHeader
<div style={{ marginBottom: spacing.lg }}> title="Min profil"
<Title level={4} style={{ margin: 0 }}> subtitle="Administrer dine personlige indstillinger"
Min profil breadcrumbs={[{ title: 'Brugerindstillinger' }]}
</Title> />
<Text type="secondary">Administrer dine personlige indstillinger</Text>
</div>
<Tabs items={tabItems} /> <Tabs items={tabItems} />
</div> </div>

View file

@ -322,7 +322,7 @@ export const usePeriodStore = create<PeriodState>()(
return { return {
allowed: false, allowed: false,
reason: 'Period is locked', reason: 'Period is locked',
reasonDanish: 'Perioden er laast', reasonDanish: 'Perioden er låst',
}; };
} }
@ -344,7 +344,7 @@ export const usePeriodStore = create<PeriodState>()(
return { return {
allowed: false, allowed: false,
reason: 'Cannot post to future periods', reason: 'Cannot post to future periods',
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder', reasonDanish: 'Kan ikke bogføre i fremtidige perioder',
}; };
} }

View file

@ -321,7 +321,7 @@ export const componentTokens = {
padding: spacing.xl, padding: spacing.xl,
}, },
sidebar: { sidebar: {
width: 200, width: 220,
collapsedWidth: 80, collapsedWidth: 80,
}, },
modal: { modal: {

View file

@ -4,29 +4,29 @@
* Period frequency - how often accounting periods are defined * Period frequency - how often accounting periods are defined
*/ */
export type PeriodFrequency = export type PeriodFrequency =
| 'monthly' // Maanedlig | 'monthly' // Månedlig
| 'quarterly' // Kvartalsvis | 'quarterly' // Kvartalsvis
| 'half-yearly' // Halvaarlig | 'half-yearly' // Halvårlig
| 'yearly'; // Aarlig | 'yearly'; // Årlig
/** /**
* Period status according to Danish accounting requirements * Period status according to Danish accounting requirements
*/ */
export type PeriodStatus = export type PeriodStatus =
| 'future' // Fremtidig - not yet started | 'future' // Fremtidig - not yet started
| 'open' // Aaben - current working period | 'open' // Åben - current working period
| 'closed' // Lukket - closed but can be reopened | '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) * VAT Period frequency (can differ from accounting periods)
* Based on SKAT requirements * Based on SKAT requirements
*/ */
export type VATPeriodicitet = export type VATPeriodicitet =
| 'monthly' // Maanedlig (omsaetning > 50M DKK) | 'monthly' // Månedlig (omsætning > 50M DKK)
| 'quarterly' // Kvartalsvis (default for most) | 'quarterly' // Kvartalsvis (default for most)
| 'half-yearly' // Halvaarlig (omsaetning < 1M DKK, optional) | 'half-yearly' // Halvårlig (omsætning < 1M DKK, optional)
| 'yearly'; // Aarlig (omsaetning < 300K DKK, optional) | 'yearly'; // Årlig (omsætning < 300K DKK, optional)
/** /**
* Fiscal Year (Regnskabsaar) * Fiscal Year (Regnskabsaar)
@ -230,10 +230,10 @@ export const DANISH_MONTHS_SHORT = [
* Period frequency display names * Period frequency display names
*/ */
export const PERIOD_FREQUENCY_NAMES: Record<PeriodFrequency, { danish: string; english: string }> = { export const PERIOD_FREQUENCY_NAMES: Record<PeriodFrequency, { danish: string; english: string }> = {
'monthly': { danish: 'Maanedlig', english: 'Monthly' }, 'monthly': { danish: 'Månedlig', english: 'Monthly' },
'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' }, 'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' },
'half-yearly': { danish: 'Halvaarslig', english: 'Half-yearly' }, 'half-yearly': { danish: 'Halvårslig', english: 'Half-yearly' },
'yearly': { danish: 'Aarlig', english: 'Yearly' }, 'yearly': { danish: 'Årlig', english: 'Yearly' },
}; };
/** /**
@ -252,7 +252,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
icon: 'clock-circle' icon: 'clock-circle'
}, },
'open': { 'open': {
danish: 'Aaben', danish: 'Åben',
english: 'Open', english: 'Open',
color: 'green', color: 'green',
icon: 'check-circle' icon: 'check-circle'
@ -264,7 +264,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
icon: 'minus-circle' icon: 'minus-circle'
}, },
'locked': { 'locked': {
danish: 'Laast', danish: 'Låst',
english: 'Locked', english: 'Locked',
color: 'red', color: 'red',
icon: 'lock' icon: 'lock'