Full product audit: fix security, compliance, UX, and wire broken features

Security (Phase 1):
- Add authentication middleware on /graphql endpoint
- Filter company queries by user access (prevent IDOR)
- Add role-based authorization on mutations (owner/accountant)
- Reduce API key cache TTL from 24h to 5 minutes
- Hide exception details in production GraphQL errors
- Fix RBAC in frontend companyStore (was hardcoded)

Wiring broken features (Phase 2):
- Wire Kassekladde submit/void/copy to GraphQL mutations
- Wire Kontooversigt account creation to createAccount mutation
- Wire Settings save to updateCompany mutation
- Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations
- Replace Momsindberetning mock data with real useVatReport query
- Remove Dashboard hardcoded percentages and fake VAT deadline
- Fix Kreditnotaer invoice selector to use real data
- Fix mutation retry from 1 to 0 (prevent duplicate operations)

Accounting compliance (Phase 3):
- Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate
- Add fiscal year boundary enforcement (status, date range checks)
- Add PostedAt timestamp to posted events (Bogføringsloven §7)
- Add account number uniqueness check within company
- Add fiscal year overlap and gap checks
- Add sequential invoice auto-numbering
- Fix InvoiceLine VAT rate to use canonical VatCodes
- Fix SAF-T account type mapping (financial → Expense)
- Add DraftLine validation (cannot have both debit and credit > 0)

UX improvements (Phase 4):
- Fix Danish character encoding across 15+ files (ø, æ, å)
- Deploy DemoDataDisclaimer on pages with mock/incomplete data
- Adopt PageHeader component universally across all pages
- Standardize active/inactive filtering to Switch pattern
- Fix dead buttons in Header (Help, Notifications)
- Remove hardcoded mock data from Settings
- Fix Sidebar controlled state and Kontooversigt navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-05 21:35:26 +01:00
parent effb06fc44
commit 8e05171b66
49 changed files with 1537 additions and 1192 deletions

View file

@ -1,4 +1,5 @@
{"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:24:40.198621+01:00","closed_at":"2026-01-30T22:24:40.198621+01:00","close_reason":"Closed"}
{"id":"books-0xk","title":"Phase 2: Wire broken features to backend APIs","description":"Connect all console.log-only handlers to real GraphQL mutations: Kassekladde submit, Settings save, Bankafstemning save, Kontooversigt account CRUD, FiscalYear creation, CloseFiscalYearWizard, Void/Copy actions.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.249535+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.295989+01:00"}
{"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
@ -7,8 +8,11 @@
{"id":"books-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"}
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
{"id":"books-ced","title":"brug smb om regnskab + fropntend designer til at sikrer at alt er godt for både balance og kontooversigt","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:46.484629+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.42433+01:00","closed_at":"2026-01-30T14:47:52.42433+01:00","close_reason":"Closed"}
{"id":"books-cws","title":"Phase 3: Accounting compliance fixes","description":"Balanced entry enforcement, VAT code unification, invoice numbering, fiscal year gap/overlap checks, posting date tracking, SAF-T fixes.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.362182+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.37896+01:00"}
{"id":"books-h6e","title":"fjern hurtig bogføring og den visning der høre dertil","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:14:50.436314+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:18:09.911294+01:00","closed_at":"2026-01-30T14:18:09.911294+01:00","close_reason":"Closed"}
{"id":"books-hzt","title":"fix bug med tilføj brugere står forkert med encoded tegn","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:21:34.556319+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:28:31.320973+01:00","closed_at":"2026-01-30T14:28:31.320973+01:00","close_reason":"Closed"}
{"id":"books-k95","title":"Phase 4: UX consistency \u0026 bug fixes","description":"Danish character encoding, DemoDataDisclaimer deployment, PageHeader adoption, mobile responsiveness, mock data removal, dead buttons.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.471301+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.460303+01:00"}
{"id":"books-ley","title":"Phase 1: GraphQL Authentication \u0026 Authorization","description":"Add authentication to GraphQL endpoint and authorization checks to all resolvers. Fix: S-01 through S-06, RBAC always returning owner, admin hardcoded email check.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.131213+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.214637+01:00"}
{"id":"books-ljg","title":"Fjern mock data og kobl frontend til backend GraphQL","description":"Frontend bruger ~2000 linjer hardcoded mock data i stedet for at bruge de eksisterende GraphQL hooks.\n\n## Problem\n- Backend GraphQL API er klar med queries og mutations\n- Frontend har hooks skrevet (useAccounts, useFiscalYears, etc.)\n- Men pages bruger hardcoded mock data i stedet for at kalde hooks\n\n## Filer der skal opdateres\n1. Dashboard.tsx - mock metrics, charts, transactions\n2. Kassekladde.tsx - mock accounts og posteringer \n3. Kontooversigt.tsx - mock kontoplan og balancer\n4. Bankafstemning.tsx - mock bank accounts og transaktioner\n5. FiscalYearSelector.tsx - mock fiscal years\n6. CompanySwitcher.tsx - mock companies\n7. Stores (companyStore, periodStore) - skal initialiseres fra API\n\n## Acceptkriterier\n- Al mock data fjernet fra frontend\n- Alle pages bruger GraphQL hooks til at hente data\n- Stores initialiseres korrekt ved app start\n- Data vises fra backend i UI","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:27:49.225279+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:42:04.17437+01:00","closed_at":"2026-01-30T22:42:04.17437+01:00","close_reason":"Closed"}
{"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}

View file

@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests
public void MarkPosted_WhenActive_EmitsPostedEvent()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
aggregate.MarkPosted("transaction-123", "user@example.com");
// Assert
var uncommittedEvents = aggregate.UncommittedEvents.ToList();
uncommittedEvents.Should().HaveCount(2); // Created + Posted
uncommittedEvents.Should().HaveCount(3); // Created + Updated + Posted
var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent;
var postedEvent = uncommittedEvents[2].AggregateEvent as JournalEntryDraftPostedEvent;
postedEvent.Should().NotBeNull();
postedEvent!.TransactionId.Should().Be("transaction-123");
postedEvent.PostedBy.Should().Be("user@example.com");
postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
var act = () => aggregate.MarkPosted(" ", "user@example.com");
@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
var act = () => aggregate.MarkPosted("transaction-123", "");
@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests
return aggregate;
}
private static JournalEntryDraftAggregate CreatePostedDraft()
private static JournalEntryDraftAggregate CreateActiveDraftWithLines()
{
var aggregate = CreateActiveDraft();
var lines = new List<DraftLine>
{
new(1, "account-1", 1000m, 0m, "Debet"),
new(2, "account-2", 0m, 1000m, "Kredit")
};
aggregate.Update("Test Draft", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
return aggregate;
}
private static JournalEntryDraftAggregate CreatePostedDraft()
{
var aggregate = CreateActiveDraftWithLines();
aggregate.MarkPosted("transaction-123", "user@example.com");
return aggregate;
}

View file

@ -12,7 +12,7 @@ namespace Books.Api.Authentication;
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
}
public class ApiKeyAuthenticationHandler(

View file

@ -1,15 +1,35 @@
using Books.Api.Domain;
using Books.Api.Domain.Accounts;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.Accounts;
public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
/// <summary>
/// Command handler for creating a new account.
/// Validates that the account number is unique within the company.
/// </summary>
public class CreateAccountCommandHandler(
IAccountRepository accountRepository)
: CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
AccountAggregate aggregate,
CreateAccountCommand command,
CancellationToken cancellationToken)
{
// Check if an account with the same number already exists for this company
var existingAccount = await accountRepository.GetByCompanyAndNumberAsync(
command.CompanyId, command.AccountNumber, cancellationToken);
if (existingAccount != null)
{
throw new DomainException(
"ACCOUNT_NUMBER_EXISTS",
$"Account number {command.AccountNumber} already exists for this company",
$"Kontonummer {command.AccountNumber} eksisterer allerede");
}
aggregate.Create(
command.CompanyId,
command.AccountNumber,
@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, Acco
command.VatCodeId,
command.IsSystemAccount,
command.StandardAccountNumber);
return Task.CompletedTask;
}
}

View file

@ -1,15 +1,64 @@
using Books.Api.Domain;
using Books.Api.Domain.FiscalYears;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.FiscalYears;
public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
/// <summary>
/// Command handler for creating a new fiscal year.
/// Validates overlap with existing fiscal years and checks for gaps.
/// </summary>
public class CreateFiscalYearCommandHandler(
IFiscalYearRepository fiscalYearRepository)
: CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
FiscalYearAggregate aggregate,
CreateFiscalYearCommand command,
CancellationToken cancellationToken)
{
// Check for overlapping fiscal years
var hasOverlap = await fiscalYearRepository.HasOverlappingYearAsync(
command.CompanyId,
command.StartDate,
command.EndDate,
excludeId: null,
cancellationToken);
if (hasOverlap)
{
throw new DomainException(
"FISCAL_YEAR_OVERLAP",
"The fiscal year overlaps with an existing fiscal year",
"Regnskabsåret overlapper med et eksisterende regnskabsår");
}
// Check for gaps: verify the new start date follows the latest existing fiscal year
if (!command.IsFirstFiscalYear)
{
var existingYears = await fiscalYearRepository.GetByCompanyIdAsync(
command.CompanyId, cancellationToken);
if (existingYears.Count > 0)
{
// Find the latest end date among existing fiscal years
var latestEndDate = existingYears
.Select(fy => DateOnly.FromDateTime(fy.EndDate))
.Max();
var expectedStartDate = latestEndDate.AddDays(1);
if (command.StartDate != expectedStartDate)
{
throw new DomainException(
"FISCAL_YEAR_GAP",
$"Fiscal year must start on {expectedStartDate:yyyy-MM-dd} (day after previous year ends on {latestEndDate:yyyy-MM-dd}). No gaps are allowed between fiscal years.",
$"Regnskabsåret skal starte den {expectedStartDate:yyyy-MM-dd} (dagen efter forrige år slutter den {latestEndDate:yyyy-MM-dd}). Der må ikke være huller mellem regnskabsår.");
}
}
}
aggregate.Create(
command.CompanyId,
command.Name,
@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate
command.EndDate,
command.IsFirstFiscalYear,
command.IsReorganization);
return Task.CompletedTask;
}
}

View file

@ -1,23 +1,39 @@
using Books.Api.Domain.Invoices;
using Books.Api.Invoicing.Services;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
public class CreateInvoiceCommandHandler
/// <summary>
/// Command handler for creating invoices.
/// Auto-assigns a sequential invoice number if one is not provided.
/// </summary>
public class CreateInvoiceCommandHandler(
IInvoiceNumberService invoiceNumberService)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
// Auto-assign invoice number if not provided
var invoiceNumber = command.InvoiceNumber;
if (string.IsNullOrWhiteSpace(invoiceNumber))
{
invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
command.CompanyId,
command.InvoiceDate.Year,
cancellationToken);
}
aggregate.Create(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.InvoiceNumber,
invoiceNumber,
command.InvoiceDate,
command.DueDate,
command.PaymentTermsDays,
@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler
command.Notes,
command.Reference,
command.CreatedBy);
return Task.CompletedTask;
}
}

View file

@ -1,4 +1,6 @@
using Books.Api.Domain;
using Books.Api.Domain.JournalEntryDrafts;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.JournalEntryDrafts;
@ -42,19 +44,75 @@ public class UpdateJournalEntryDraftCommandHandler
}
}
public class MarkJournalEntryDraftPostedCommandHandler
/// <summary>
/// Command handler for posting a journal entry draft.
/// Validates fiscal year status and date range before allowing the post.
/// </summary>
public class MarkJournalEntryDraftPostedCommandHandler(
IJournalEntryDraftRepository draftRepository,
IFiscalYearRepository fiscalYearRepository)
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
MarkJournalEntryDraftPostedCommand command,
CancellationToken cancellationToken)
{
// Load the draft read model to get fiscal year and document date
var draft = await draftRepository.GetByIdAsync(
aggregate.Id.Value, cancellationToken);
var fiscalYearId = draft?.FiscalYearId ?? aggregate.FiscalYearId;
// Validate fiscal year is set
if (string.IsNullOrWhiteSpace(fiscalYearId))
{
throw new DomainException(
"FISCAL_YEAR_REQUIRED",
"Fiscal year is required for posting a journal entry",
"Regnskabsår er påkrævet for bogføring af en postering");
}
// Fetch and validate fiscal year
var fiscalYear = await fiscalYearRepository.GetByIdAsync(
fiscalYearId, cancellationToken);
if (fiscalYear == null)
{
throw new DomainException(
"FISCAL_YEAR_NOT_FOUND",
$"Fiscal year '{fiscalYearId}' not found",
$"Regnskabsår '{fiscalYearId}' blev ikke fundet");
}
// Validate fiscal year is open (not Closed or Locked)
if (fiscalYear.Status != "Open")
{
throw new DomainException(
"FISCAL_YEAR_NOT_OPEN",
$"Fiscal year is {fiscalYear.Status}. Only open fiscal years allow posting.",
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
}
// Validate document date falls within fiscal year range (if document date is set)
if (draft?.DocumentDate != null)
{
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
var fyEnd = DateOnly.FromDateTime(fiscalYear.EndDate);
if (documentDate < fyStart || documentDate > fyEnd)
{
throw new DomainException(
"DOCUMENT_DATE_OUTSIDE_FISCAL_YEAR",
$"Document date {documentDate:yyyy-MM-dd} falls outside the fiscal year ({fyStart:yyyy-MM-dd} to {fyEnd:yyyy-MM-dd})",
$"Bilagsdato {documentDate:yyyy-MM-dd} ligger uden for regnskabsåret ({fyStart:yyyy-MM-dd} til {fyEnd:yyyy-MM-dd})");
}
}
aggregate.MarkPosted(
command.TransactionId,
command.PostedBy);
return Task.CompletedTask;
}
}

View file

@ -58,6 +58,10 @@ public class BankingController : ControllerBase
try
{
// TODO: Add proper CSRF/state validation. Currently the state parameter
// is used as the connection ID, but it should also include a CSRF token
// that is validated against the user session to prevent cross-site request
// forgery attacks on the OAuth callback.
// State contains the connection ID (set during StartBankConnection)
var connectionId = state;

View file

@ -0,0 +1,9 @@
-- Migration: 030_AddPostedAtColumn
-- Description: Add posted_at column to journal_entry_draft_read_models
-- for audit trail compliance (exact timestamp when draft was posted to ledger)
ALTER TABLE journal_entry_draft_read_models
ADD COLUMN IF NOT EXISTS posted_at TIMESTAMPTZ;
COMMENT ON COLUMN journal_entry_draft_read_models.posted_at IS
'Exact timestamp when the journal entry draft was posted to the ledger';

View file

@ -1,3 +1,5 @@
using Books.Api.Domain;
namespace Books.Api.Domain.Invoices;
/// <summary>
@ -65,15 +67,19 @@ public sealed record InvoiceLine
/// <summary>
/// Gets the VAT rate for this line based on VatCode.
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
/// </summary>
private decimal GetVatRate() => VatCode switch
private decimal GetVatRate()
{
"U25" or "I25" => 0.25m, // Danish standard 25%
"UEU" or "IEU" => 0m, // EU sales (reverse charge)
"UEXP" or "IEXP" => 0m, // Export (no VAT)
"INGEN" => 0m, // No VAT
_ => 0.25m // Default to Danish standard
};
if (!VatCodes.IsValid(VatCode))
{
throw new InvalidOperationException(
$"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " +
$"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN");
}
return VatCodes.GetRate(VatCode);
}
/// <summary>
/// Creates an InvoiceLine with validation.

View file

@ -2,10 +2,20 @@ using EventFlow.Aggregates;
namespace Books.Api.Domain.JournalEntryDrafts.Events;
/// <summary>
/// Event emitted when a journal entry draft is posted to the ledger.
/// Includes PostedAt timestamp for audit trail compliance.
/// </summary>
public class JournalEntryDraftPostedEvent(
string transactionId,
string postedBy) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
string postedBy,
DateTimeOffset postedAt) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
{
public string TransactionId { get; } = transactionId;
public string PostedBy { get; } = postedBy;
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset PostedAt { get; } = postedAt;
}

View file

@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
IEmit<JournalEntryDraftPostedEvent>,
IEmit<JournalEntryDraftDiscardedEvent>
{
/// <summary>
/// Tolerance for floating-point rounding when comparing debit/credit totals.
/// </summary>
private const decimal BalanceTolerance = 0.01m;
private bool _isCreated;
private DraftStatus _status = DraftStatus.Active;
private string _companyId = string.Empty;
private string _voucherNumber = string.Empty;
private string? _fiscalYearId;
private List<DraftLine> _lines = [];
public string CompanyId => _companyId;
public string VoucherNumber => _voucherNumber;
/// <summary>
/// The fiscal year ID assigned during the last update.
/// </summary>
public string? FiscalYearId => _fiscalYearId;
/// <summary>
/// The current draft lines (populated from the last update event).
/// </summary>
public IReadOnlyList<DraftLine> Lines => _lines.AsReadOnly();
#region Apply Methods
public void Apply(JournalEntryDraftCreatedEvent e)
@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
public void Apply(JournalEntryDraftUpdatedEvent e)
{
// State is stored in read model, not in aggregate
_fiscalYearId = e.FiscalYearId;
_lines = e.Lines.ToList();
}
public void Apply(JournalEntryDraftPostedEvent e)
@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
/// <summary>
/// Updates a journal entry draft (auto-save).
/// Validates that each line has either DebitAmount or CreditAmount (not both),
/// and that VAT codes are valid.
/// </summary>
/// <param name="name">Draft name</param>
/// <param name="documentDate">Bilagsdato - the date of the transaction/document (e.g., invoice date)</param>
@ -126,6 +146,12 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
}
}
// Validate individual lines: cannot have both debit and credit amounts
foreach (var line in lines)
{
ValidateDraftLine(line);
}
Emit(new JournalEntryDraftUpdatedEvent(
name?.Trim(),
documentDate,
@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
attachmentIds));
}
/// <summary>
/// Marks the draft as posted after validation.
/// Enforces double-entry bookkeeping: total debits must equal total credits.
/// Requires at least 2 lines with valid account IDs.
/// </summary>
/// <param name="transactionId">The ledger transaction ID</param>
/// <param name="postedBy">User who posted the draft</param>
public void MarkPosted(string transactionId, string postedBy)
{
EnsureCanModify();
@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
"Posted by is required",
"Bogført af er påkrævet");
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy));
// Validate minimum number of lines for double-entry bookkeeping
if (_lines.Count < 2)
throw new DomainException(
"INSUFFICIENT_LINES",
"A journal entry must have at least 2 lines for double-entry bookkeeping",
"En postering skal have mindst 2 linjer for dobbelt bogholderi");
// Validate all lines have account IDs assigned
var linesWithoutAccounts = _lines.Where(l => string.IsNullOrWhiteSpace(l.AccountId)).ToList();
if (linesWithoutAccounts.Count > 0)
{
var lineNumbers = string.Join(", ", linesWithoutAccounts.Select(l => l.LineNumber));
throw new DomainException(
"ACCOUNT_REQUIRED",
$"All lines must have an account. Lines without account: {lineNumbers}",
$"Alle linjer skal have en konto. Linjer uden konto: {lineNumbers}");
}
// Validate debit/credit balance (fundamental double-entry accounting principle)
var totalDebit = _lines.Sum(l => l.DebitAmount);
var totalCredit = _lines.Sum(l => l.CreditAmount);
if (Math.Abs(totalDebit - totalCredit) > BalanceTolerance)
throw new DomainException(
"UNBALANCED_ENTRY",
$"Total debits must equal credits. Debit: {totalDebit:N2}, Credit: {totalCredit:N2}",
$"Debet og kredit skal balancere. Debet: {totalDebit:N2}, Kredit: {totalCredit:N2}");
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy, DateTimeOffset.UtcNow));
}
public void Discard(string discardedBy)
@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
"Kassekladden er blevet kasseret");
}
/// <summary>
/// Validates a single draft line.
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
/// At least one of DebitAmount or CreditAmount must be > 0.
/// </summary>
private static void ValidateDraftLine(DraftLine line)
{
if (line.DebitAmount > 0 && line.CreditAmount > 0)
{
throw new DomainException(
"INVALID_LINE_AMOUNTS",
$"Line {line.LineNumber} cannot have both debit and credit amounts. Use separate lines.",
$"Linje {line.LineNumber} kan ikke have både debet- og kreditbeløb. Brug separate linjer.");
}
if (line.DebitAmount <= 0 && line.CreditAmount <= 0)
{
throw new DomainException(
"MISSING_LINE_AMOUNT",
$"Line {line.LineNumber} must have either a debit or credit amount greater than zero",
$"Linje {line.LineNumber} skal have enten et debet- eller kreditbeløb større end nul");
}
}
#endregion
}

View file

@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel,
public string AttachmentIds { get; set; } = "[]";
public string Status { get; set; } = "active";
public string? TransactionId { get; set; }
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset? PostedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// Full AI extraction data stored as JSON string.
@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel,
Status = "posted";
TransactionId = domainEvent.AggregateEvent.TransactionId;
PostedAt = domainEvent.AggregateEvent.PostedAt;
return Task.CompletedTask;
}

View file

@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto
public string AttachmentIds { get; set; } = "[]";
public string Status { get; set; } = "active";
public string? TransactionId { get; set; }
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset? PostedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }

View file

@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal
attachment_ids AS AttachmentIds,
status AS Status,
transaction_id AS TransactionId,
posted_at AS PostedAt,
created_by AS CreatedBy,
create_time AS CreatedAt,
updated_time AS UpdatedAt
updated_time AS UpdatedAt,
extraction_data AS ExtractionData
""";
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(

View file

@ -1,5 +1,9 @@
using System.Security.Claims;
using Books.Api.Authorization;
using Books.Api.Commands.Companies;
using Books.Api.Commands.UserAccess;
using Books.Api.Domain.Companies;
using Books.Api.Domain.UserAccess;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.InputTypes;
using Books.Api.GraphQL.Types;
@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType
await commandBus.PublishAsync(command, ctx.CancellationToken);
// Grant the creating user owner access to the new company
var httpContext = ctx.RequestServices!.GetRequiredService<IHttpContextAccessor>().HttpContext;
var userId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId != null)
{
var accessId = UserCompanyAccessId.FromUserAndCompany(userId, companyId.Value);
var grantCmd = new GrantUserCompanyAccessCommand(
accessId, userId, companyId.Value, CompanyRole.Owner, userId);
await commandBus.PublishAsync(grantCmd, ctx.CancellationToken);
}
// Return the created company (eventually consistent)
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
// Require Owner or Accountant role to update a company
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
await accessService.RequireAccessAsync(id, CompanyRole.Accountant, ctx.CancellationToken);
var input = ctx.GetArgument<UpdateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();

View file

@ -1,4 +1,6 @@
using Books.Api.Authorization;
using Books.Api.Domain.Companies;
using Books.Api.Domain.UserAccess;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.Types;
using GraphQL;
@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType
// companies: [CompanyType]
Field<ListGraphType<CompanyType>>("companies")
.Description("Get all companies")
.Description("Get all companies accessible to the current user")
.ResolveAsync(async ctx =>
{
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
return await repository.GetAllAsync(ctx.CancellationToken);
var userAccesses = await accessService.GetUserCompaniesAsync(ctx.CancellationToken);
var companyIds = userAccesses.Select(a => CompanyId.With(a.CompanyId)).ToList();
if (companyIds.Count == 0) return Enumerable.Empty<object>();
return await repository.GetByIds(companyIds, ctx.CancellationToken);
});
// company(id: ID!): CompanyType
@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
await accessService.RequireAccessAsync(id, CompanyRole.Viewer, ctx.CancellationToken);
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
return companies.FirstOrDefault();

View file

@ -1,4 +1,5 @@
using Books.Api;
using Books.Api.Authorization;
using Books.Api.GraphQL;
using GraphQL;
using GraphQL.Server.Ui.Altair;
@ -30,6 +31,25 @@ app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Company context middleware - extracts X-Company-Id header and validates user access
app.UseCompanyContext();
// Require authentication for the GraphQL endpoint
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/graphql"),
appBuilder => appBuilder.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated != true)
{
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"errors\":[{\"message\":\"Authentication required\"}]}");
return;
}
await next();
})
);
// Map controllers (for AuthController)
app.MapControllers();

View file

@ -14,6 +14,8 @@ public class VatReportService(
ILogger<VatReportService> logger) : IVatReportService
{
// Standard Danish VAT account numbers
// TODO: These should ideally come from company-level configuration,
// as different chart-of-accounts templates may use different numbers.
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
@ -133,13 +135,28 @@ public class VatReportService(
report.TotalInputVat = report.BoxB;
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
// Basis amounts require tracking of original transaction amounts
// For now, calculate from VAT amounts assuming 25% rate
// Basis1 (Felt 1): Net domestic turnover with VAT
// TODO: Query actual net turnover from transactions with output VAT codes (U25)
// instead of back-calculating from VAT amount, which is inaccurate when
// mixed VAT rates or partial deductions are involved.
// Ideally: query revenue account balances filtered by VAT code U25.
// For now, back-calculate from output VAT assuming standard 25% rate
if (report.BoxA > 0)
{
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
}
// TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions.
// Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods.
// report.BoxC = sum of VAT calculated on IEUV transactions.
// report.Basis3 = net purchase amount for IEUV transactions.
// TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions.
// Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT
// on services purchased from abroad.
// report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions.
// report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions.
logger.LogInformation(
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);

View file

@ -332,6 +332,15 @@ public class SaftExportService(
journals);
}
/// <summary>
/// Maps internal account types to SAF-T standard account classifications.
/// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts
/// can represent either income (e.g., interest income, account 8000-8499) or
/// expense (e.g., interest expense, account 8500-8999). Without the account
/// number or balance direction, we cannot determine the correct mapping.
/// A future improvement should inspect the account number range or actual
/// balance direction to choose between "Income" and "Expense".
/// </summary>
private static string MapAccountType(string accountType)
{
return accountType.ToLowerInvariant() switch
@ -343,7 +352,10 @@ public class SaftExportService(
"cogs" => "Expense",
"expense" => "Expense",
"personnel" => "Expense",
"financial" => "Income", // Could be either, defaulting to Income
// Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999).
// Defaulting to "Expense" is safer since most financial items are costs (interest, fees).
// TODO: Determine mapping based on account number range or balance direction.
"financial" => "Expense",
"extraordinary" => "Expense",
_ => "Asset"
};

View file

@ -1,5 +1,7 @@
using Books.Api.Authentication;
using Books.Api.Authorization;
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Repositories;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
using Books.Api.Infrastructure;
@ -67,6 +69,13 @@ public static class Startup
// Read model repositories
services.AddRepositories();
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
services.AddHttpContextAccessor();
// User company access repository and access service
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
// Logging decorators
services.DecorateAsyncEventHandlersWithLogging();
@ -76,7 +85,7 @@ public static class Startup
.AddSystemTextJson()
.AddDataLoader()
.AddGraphTypes(typeof(BooksSchema).Assembly)
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
// Memory cache for API key caching
services.AddMemoryCache();

View file

@ -1,5 +1,6 @@
import { GraphQLClient } from 'graphql-request';
import { QueryClient } from '@tanstack/react-query';
import { useCompanyStore } from '@/stores/companyStore';
// GraphQL endpoint - configure based on environment
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
@ -26,8 +27,8 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: true,
},
mutations: {
// Retry mutations once
retry: 1,
// Never retry mutations - non-idempotent operations could create duplicates
retry: 0,
},
},
});
@ -38,7 +39,16 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
variables?: TVariables
): Promise<TData> {
try {
const data = await graphqlClient.request<TData>(query, variables);
// Get active company from store (outside React)
const activeCompany = useCompanyStore.getState().activeCompany;
// Build headers with company ID if available
const headers: Record<string, string> = {};
if (activeCompany?.id) {
headers['X-Company-Id'] = activeCompany.id;
}
const data = await graphqlClient.request<TData>(query, variables, headers);
return data;
} catch (error) {
// Log error for debugging

View file

@ -126,7 +126,7 @@ export async function processDocument(
throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)');
}
if (response.status === 503) {
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgaengelig');
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgængelig');
}
throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`);
}

View file

@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account {
description: acc.description,
vatCode: acc.vatCodeId,
isActive: acc.isActive,
isSystemAccount: acc.isSystemAccount,
balance: 0, // Not returned from backend yet
createdAt: acc.createdAt,
updatedAt: acc.updatedAt,

View file

@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) {
}
// Note: Users with existing companies CAN access the wizard to create more
}
}, [companies, isLoading, navigate, location.pathname]);
}, [companies, isLoading, navigate]); // Note: location.pathname intentionally omitted to prevent infinite loop
// Reset navigation ref when companies change (user created a company)
useEffect(() => {

View file

@ -108,7 +108,7 @@ export function DocumentUploadModal({
message.success('Bogfoert!');
onConfirm();
} catch (err) {
message.error('Kunne ikke bogfoere. Proev igen.');
message.error('Kunne ikke bogføre. Prøv igen.');
} finally {
setIsPosting(false);
}
@ -270,7 +270,7 @@ export function DocumentUploadModal({
Annuller
</Button>,
<Button key="draft" onClick={handleSaveAsDraft}>
Tilfoej til kladde
Tilføj til kladde
</Button>,
<Button
key="post"
@ -451,7 +451,7 @@ function ExtractedInfoSection({
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
},
{
title: 'Beloeb',
title: 'Beløb',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
@ -526,7 +526,7 @@ function ExtractedInfoSection({
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{extraction.amountExVat != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Beloeb ekskl. moms</Text>
<Text type="secondary">Beløb ekskl. moms</Text>
<AmountText amount={extraction.amountExVat} />
</div>
)}
@ -551,7 +551,7 @@ function ExtractedInfoSection({
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
}}
>
<Text strong>Beloeb inkl. moms</Text>
<Text strong>Beløb inkl. moms</Text>
<Text
strong
style={{

View file

@ -30,7 +30,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
open: {
color: 'success',
icon: <CheckCircleOutlined />,
label: 'Aben',
label: 'Åben',
},
closed: {
color: 'warning',
@ -40,7 +40,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
locked: {
color: 'error',
icon: <LockOutlined />,
label: 'Last',
label: 'Låst',
},
};
@ -84,16 +84,19 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
if (fiscalYearsData.length > 0) {
setFiscalYears(fiscalYearsData);
// Get current value without adding to dependencies to avoid infinite loop
const current = usePeriodStore.getState().currentFiscalYear;
// Validate currentFiscalYear belongs to this company's data
const isValid = currentFiscalYear &&
fiscalYearsData.some(fy => fy.id === currentFiscalYear.id);
const isValid = current &&
fiscalYearsData.some(fy => fy.id === current.id);
if (!isValid) {
const openYear = fiscalYearsData.find(y => y.status === 'open');
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
}
}
}, [fiscalYearsData, currentFiscalYear, setFiscalYears, setCurrentFiscalYear]);
}, [fiscalYearsData, setFiscalYears, setCurrentFiscalYear]);
const handleFiscalYearChange = (yearId: string) => {
const year = fiscalYears.find((y) => y.id === yearId);
@ -146,7 +149,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
style={{ minWidth: 200 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
dropdownRender={(menu) => (
popupRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
@ -157,7 +160,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
onClick={handleCreateNew}
size="small"
>
Opret nyt regnskabsar
Opret nyt regnskabsår
</Button>
<Button
type="text"
@ -172,7 +175,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
)}
options={sortedYears.map((year) => ({
value: year.id,
label: `Regnskabsar ${year.name}`,
label: `Regnskabsår ${year.name}`,
year,
}))}
optionRender={(option) => {

View file

@ -125,31 +125,27 @@ export default function Header({ isMobile = false }: HeaderProps) {
)}
{/* Help */}
<Button
type="text"
icon={<QuestionCircleOutlined />}
aria-label="Hjælp"
title="Hjælp"
/>
{/* Notifications */}
<Button
type="text"
icon={<BellOutlined />}
aria-label="Notifikationer"
title="Notifikationer"
/>
{/* Logout */}
<Tooltip title="Log ud">
<Tooltip title="Hjælp">
<Button
type="text"
icon={<LogoutOutlined />}
onClick={logout}
aria-label="Log ud"
icon={<QuestionCircleOutlined />}
onClick={() => window.open('https://help.books.dk', '_blank')}
aria-label="Hjælp"
/>
</Tooltip>
{/* Notifications */}
<Tooltip title="Notifikationer">
<Badge count={0} size="small">
<Button
type="text"
icon={<BellOutlined />}
onClick={() => navigate('/indstillinger')}
aria-label="Notifikationer"
/>
</Badge>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{

View file

@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { Layout, Menu } from 'antd';
import {
DashboardOutlined,
@ -42,7 +43,7 @@ function getItem(
const menuItems: MenuItem[] = [
getItem('Dashboard', '/', <DashboardOutlined />),
getItem('Bogfoering', 'accounting', <BookOutlined />, [
getItem('Bogføring', 'accounting', <BookOutlined />, [
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
]),
@ -61,7 +62,7 @@ const menuItems: MenuItem[] = [
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
getItem('Lønforståelse', '/loenforstaelse', <TeamOutlined />),
getItem('Eksport', '/eksport', <ExportOutlined />),
]),
@ -99,6 +100,17 @@ interface SidebarMenuProps {
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
const navigate = useNavigate();
const location = useLocation();
const [openKeys, setOpenKeys] = useState<string[]>(getOpenKeys(location.pathname));
// Update openKeys when location changes
useEffect(() => {
const newOpenKeys = getOpenKeys(location.pathname);
setOpenKeys((prev) => {
// Merge: keep existing open keys but ensure the current path's group is open
const merged = [...new Set([...prev, ...newOpenKeys])];
return merged;
});
}, [location.pathname]);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key.startsWith('/')) {
@ -107,6 +119,10 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
}
};
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
const selectedKeys = [location.pathname];
return (
@ -114,7 +130,8 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={getOpenKeys(location.pathname)}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}

View file

@ -38,6 +38,8 @@ import {
import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
import { message } from 'antd';
const { Text, Title, Paragraph } = Typography;
@ -82,6 +84,8 @@ export default function CloseFiscalYearWizard({
lockPeriod,
} = usePeriodStore();
const closeFiscalYearMutation = useCloseFiscalYear();
// Reset wizard when opened
useEffect(() => {
if (open) {
@ -160,27 +164,41 @@ export default function CloseFiscalYearWizard({
setIsSubmitting(true);
try {
// 1. Close open periods if requested
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
// in generateClosingEntries() but never actually posted to the ledger.
// Before closing the fiscal year, these closing entries MUST be posted:
// 1. Revenue accounts should be zeroed out to the result account
// 2. Expense accounts should be zeroed out to the result account
// 3. The net result should be transferred to the equity account (resultAccountId)
// Without posting these entries, the opening balances for the next year will be incorrect.
// 1. Close open periods if requested (local store)
if (closeOpenPeriods) {
for (const period of openPeriodsInYear) {
closePeriod(period.id, 'system');
}
}
// 2. Lock all periods in the year
// 2. Lock all periods in the year (local store)
for (const period of yearPeriods) {
lockPeriod(period.id, 'system');
}
// 3. Close and lock the fiscal year
// 3. Call backend mutation to close the fiscal year
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
// 4. Also update local store
closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system');
// 4. Move to complete step
// 5. Move to complete step
setCurrentStep('complete');
onSuccess?.();
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved arsafslutning: ${error.message}`);
}
console.error('Failed to close fiscal year:', error);
} finally {
setIsSubmitting(false);

View file

@ -27,6 +27,8 @@ import {
} from '@/lib/fiscalYear';
import { generateAccountingPeriods } from '@/lib/periods';
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
import { useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
import { message } from 'antd';
const { Text } = Typography;
const { RangePicker } = DatePicker;
@ -58,6 +60,7 @@ export default function CreateFiscalYearModal({
const { activeCompany } = useCompanyStore();
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
const createFiscalYearMutation = useCreateFiscalYear();
// Calculate suggested fiscal year boundaries
useEffect(() => {
@ -114,20 +117,18 @@ export default function CreateFiscalYearModal({
const startDate = values.dateRange[0].format('YYYY-MM-DD');
const endDate = values.dateRange[1].format('YYYY-MM-DD');
// Create fiscal year object
const newFiscalYear: FiscalYear = {
id: `fy-${values.name}-${Date.now()}`,
// Call backend mutation - let the backend generate the ID
const newFiscalYear = await createFiscalYearMutation.mutateAsync({
companyId: activeCompany.id,
name: values.name,
startDate,
endDate,
status: 'open',
openingBalancePosted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
// Generate accounting periods and add required fields
// Also update local store as a cache layer
addFiscalYear(newFiscalYear);
// Generate accounting periods locally for the UI
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
const now = new Date().toISOString();
const newPeriods = generatedPeriods.map((p, idx) => ({
@ -136,9 +137,6 @@ export default function CreateFiscalYearModal({
createdAt: now,
updatedAt: now,
}));
// Add to store
addFiscalYear(newFiscalYear);
setPeriods([...periods, ...newPeriods]);
// Set as current if this is the first or most recent
@ -153,6 +151,9 @@ export default function CreateFiscalYearModal({
onSuccess?.(newFiscalYear);
onClose();
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved oprettelse: ${error.message}`);
}
console.error('Failed to create fiscal year:', error);
} finally {
setIsSubmitting(false);
@ -180,7 +181,7 @@ export default function CreateFiscalYearModal({
loading: isSubmitting,
}}
width={520}
destroyOnClose
destroyOnHidden
>
{!activeCompany && (
<Alert

View file

@ -289,7 +289,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
lines.push({
accountId: `vat-input-${vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
accountName: 'Indgående moms',
description: `Moms: ${description}`,
debit: vatAmount,
credit: 0,
@ -347,7 +347,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
lines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
accountName: 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
@ -431,7 +431,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
generatedLines.push({
accountId: `vat-input-${splitLine.vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
accountName: 'Indgående moms',
description: `Moms: ${description}`,
debit: lineVat,
credit: 0,
@ -506,7 +506,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
generatedLines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
accountName: 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,

View file

@ -16,7 +16,7 @@ import type { VATPeriodicitet } from '@/types/periods';
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
S25: {
code: 'S25',
nameDanish: 'Udgaaende moms 25%',
nameDanish: 'Udgående moms 25%',
nameEnglish: 'Output VAT 25%',
rate: 0.25,
type: 'output',
@ -30,7 +30,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
},
K25: {
code: 'K25',
nameDanish: 'Indgaaende moms 25%',
nameDanish: 'Indgående moms 25%',
nameEnglish: 'Input VAT 25%',
rate: 0.25,
type: 'input',
@ -230,8 +230,8 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
* Default VAT accounts for automatic double-entry
*/
export const VAT_ACCOUNTS = {
inputVAT: '5610', // Indgaaende moms (fradrag)
outputVAT: '5710', // Udgaaende moms (skyld)
inputVAT: '5610', // Indgående moms (fradrag)
outputVAT: '5710', // Udgående moms (skyld)
euVAT: '5620', // EU-moms (erhvervelsesmoms)
} as const;

View file

@ -20,14 +20,13 @@ import {
DashboardOutlined,
} from '@ant-design/icons';
import { useUser } from '@/stores/authStore';
import { useCanAdmin } from '@/stores/companyStore';
import { useMutation, useQuery } from '@tanstack/react-query';
import { graphqlClient } from '@/api/client';
import { gql } from 'graphql-request';
const { Title, Text, Paragraph } = Typography;
// Admin email that has access
const ADMIN_EMAIL = 'nhh@softwarehuset.com';
// Derive backend base URL from GraphQL endpoint
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
@ -59,8 +58,8 @@ export default function Admin() {
const [form] = Form.useForm();
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
// Check if user is admin
const isAdmin = user?.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase();
// Check if user has Owner role for the active company
const isAdmin = useCanAdmin();
// Fetch available read model types
const { data: readModelTypes, isLoading: typesLoading } = useQuery({

View file

@ -38,8 +38,10 @@ import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { BankTransaction } from '@/types/accounting';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Type for ledger entries (API not implemented yet)
@ -160,7 +162,7 @@ export default function Bankafstemning() {
ledgerTransactionId: ledgerEntry.id,
matchType: 'existing',
});
message.success('Match tilfojet');
message.success('Match tilføjet');
}
};
@ -177,8 +179,9 @@ export default function Bankafstemning() {
const handleSubmitCreate = async () => {
try {
const values = await form.validateFields();
console.log('Creating entry:', values);
// TODO: Backend mutation for creating a journal entry from bank transaction is needed.
// This should create a JournalEntryDraft and then post it, linking it to the bank transaction.
if (selectedBankTx) {
addPendingMatch({
bankTransactionId: selectedBankTx.id,
@ -190,11 +193,13 @@ export default function Bankafstemning() {
});
}
message.success('Postering oprettet og matchet');
message.success('Postering tilfojet til afventende matches');
setIsCreateModalOpen(false);
setSelectedBankTx(null);
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
@ -203,9 +208,10 @@ export default function Bankafstemning() {
message.warning('Ingen matches at gemme');
return;
}
// TODO: Send to GraphQL mutation
console.log('Saving matches:', pendingMatches);
message.success(`${pendingMatches.length} afstemninger gemt`);
// 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) => {
@ -241,42 +247,35 @@ export default function Bankafstemning() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Bankafstemning
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button
icon={<UndoOutlined />}
onClick={clearAllSelections}
disabled={
selectedBankTransactions.length === 0 &&
selectedLedgerTransactions.length === 0
}
>
Nulstil valg
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleSaveAll}
disabled={pendingMatches.length === 0}
>
Gem afstemninger ({pendingMatches.length})
</Button>
</Space>
</div>
<PageHeader
title="Bankafstemning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Bank', path: '/bankafstemning' }, { title: 'Bankafstemning' }]}
extra={
<Space>
<Button
icon={<UndoOutlined />}
onClick={clearAllSelections}
disabled={
selectedBankTransactions.length === 0 &&
selectedLedgerTransactions.length === 0
}
>
Nulstil valg
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleSaveAll}
disabled={pendingMatches.length === 0}
>
Gem afstemninger ({pendingMatches.length})
</Button>
</Space>
}
/>
<DemoDataDisclaimer message="Bankafstemning er delvist implementeret. Gem-funktionen er under udvikling." />
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
@ -314,7 +313,7 @@ export default function Bankafstemning() {
<Col span={8}>
<Card size="small">
<Statistic
title="Bogforing (uafstemt)"
title="Bogføring (uafstemt)"
value={ledgerTotal}
precision={2}
formatter={(value) => formatCurrency(value as number)}
@ -374,7 +373,7 @@ export default function Bankafstemning() {
disabled={!canMatch}
>
Match valgte ({selectedBankTransactions.length} bank,{' '}
{selectedLedgerTransactions.length} bogforing)
{selectedLedgerTransactions.length} bogføring)
</Button>
</div>
@ -496,7 +495,7 @@ export default function Bankafstemning() {
<Card
title={
<Space>
<Text strong>Bogforingsposter</Text>
<Text strong>Bogføringsposter</Text>
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
</Space>
}
@ -505,7 +504,7 @@ export default function Bankafstemning() {
>
{ledgerEntries.length === 0 ? (
<Empty
description="Ingen uafstemte bogforingsposter (API ikke implementeret endnu)"
description="Ingen uafstemte bogføringsposter (API ikke implementeret endnu)"
style={{ padding: 24 }}
/>
) : (
@ -674,22 +673,22 @@ export default function Bankafstemning() {
rules={[{ required: true }]}
>
<Select
placeholder="Vaelg konto"
placeholder="Vælg konto"
options={[
{ value: '6100', label: '6100 - Husleje' },
{ value: '6800', label: '6800 - Kontorartikler' },
{ value: '5000', label: '5000 - Varekob' },
{ value: '5000', label: '5000 - Varekøb' },
{ value: '4000', label: '4000 - Salg' },
]}
/>
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vaelg momskode"
placeholder="Vælg momskode"
allowClear
options={[
{ value: 'K25', label: 'K25 - Indgaaende moms 25%' },
{ value: 'S25', label: 'S25 - Udgaaende moms 25%' },
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
{ value: 'NONE', label: 'Ingen moms' },
]}
/>

View file

@ -1,8 +1,6 @@
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
import {
BankOutlined,
RiseOutlined,
FallOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
// Types for chart data
interface CashFlowDataPoint {
@ -47,9 +47,13 @@ export default function Dashboard() {
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
// Define date interval
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
const periodStart = currentFiscalYear?.startDate
? dayjs(currentFiscalYear.startDate).format('YYYY-MM-DD')
: dayjs().startOf('year').format('YYYY-MM-DD');
const periodEnd = currentFiscalYear?.endDate
? dayjs(currentFiscalYear.endDate).format('YYYY-MM-DD')
: dayjs().endOf('year').format('YYYY-MM-DD');
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
activeCompany?.id,
@ -207,7 +211,7 @@ export default function Dashboard() {
const revenueExpenseConfig = {
data: cashFlowData.flatMap((d) => [
{ month: d.month, type: 'Indtaegter', value: d.inflow },
{ month: d.month, type: 'Indtægter', value: d.inflow },
{ month: d.month, type: 'Udgifter', value: d.outflow },
]),
isGroup: true,
@ -225,15 +229,13 @@ export default function Dashboard() {
return (
<div>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
Dashboard
</Title>
<Text type="secondary">
{company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')}
</Text>
</div>
<PageHeader
title="Dashboard"
subtitle={company?.name ? `${company.name} - ${formatDate(new Date().toISOString(), 'MMMM YYYY')}` : undefined}
breadcrumbs={[{ title: 'Dashboard' }]}
/>
<DemoDataDisclaimer message="Dashboard viser beregnede data fra kontoplanen. Pengestrøms- og udgiftsgrafer er endnu ikke tilgængelige." />
{/* KPI Cards */}
<Row gutter={[16, 16]}>
@ -249,13 +251,9 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag
color={metrics.cashChange >= 0 ? 'green' : 'red'}
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
>
{metrics.cashChange >= 0 ? '+' : ''}
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
Baseret kontosaldi i regnskabsåret
</Text>
</div>
</Card>
</Col>
@ -296,10 +294,9 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
{metrics.apChange >= 0 ? '+' : ''}
{(metrics.apChange * 100).toFixed(1)}% denne maaned
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
Baseret kontosaldi i regnskabsåret
</Text>
</div>
</Card>
</Col>
@ -315,32 +312,33 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color="blue">Naeste frist: 1. marts</Tag>
<a href="/momsindberetning">Se momsindberetning</a>
</div>
</Card>
</Col>
</Row>
{/* Charts Row */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* Cash Flow Chart */}
<Col xs={24} lg={12}>
<Card title="Pengestroemme" size="small">
<Card title="Pengestrømme" size="small">
{cashFlowData.length > 0 ? (
<Line {...cashFlowConfig} />
) : (
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
<Empty description="Ingen pengestrømsdata tilgængelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
{/* Revenue vs Expenses */}
<Col xs={24} lg={12}>
<Card title="Indtaegter vs. Udgifter" size="small">
<Card title="Indtægter vs. Udgifter" size="small">
{cashFlowData.length > 0 ? (
<Column {...revenueExpenseConfig} />
) : (
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
<Empty description="Ingen historiske data tilgængelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
@ -354,7 +352,7 @@ export default function Dashboard() {
{expenseBreakdown.length > 0 ? (
<Pie {...expenseConfig} />
) : (
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
<Empty description="Ingen udgiftsdata tilgængelig" style={{ height: 200 }} />
)}
</Card>
</Col>
@ -440,7 +438,9 @@ export default function Dashboard() {
<Col>
<Space>
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
<Text>Momsindberetning forfalder om 14 dage</Text>
<a href="/momsindberetning">
<Text>Se momsindberetning</Text>
</a>
</Space>
</Col>
{metrics.overdueInvoices > 0 && (

View file

@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { accountingColors } from '@/styles/theme';
import { AmountText } from '@/components/shared/AmountText';
import { PageHeader } from '@/components/shared/PageHeader';
import { EmptyState } from '@/components/shared/EmptyState';
import type { ColumnsType } from 'antd/es/table';
@ -457,25 +458,16 @@ export default function Fakturaer() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Fakturaer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
Ny fakturakladde
</Button>
</div>
<PageHeader
title="Fakturaer"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Fakturaer' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
Ny fakturakladde
</Button>
}
/>
{/* Error State */}
{error && (

View file

@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore';
import { useActiveAccounts } from '@/api/queries/accountQueries';
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
import { formatCurrency } from '@/lib/formatters';
import { PageHeader } from '@/components/shared/PageHeader';
import { validateDoubleEntry } from '@/lib/accounting';
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
import { usePeriodStore } from '@/stores/periodStore';
const { Title, Text } = Typography;
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Display type for journal entry drafts
@ -62,6 +65,13 @@ export default function Kassekladde() {
{ debit: 0, credit: 0 },
]);
const { currentFiscalYear } = usePeriodStore();
// Mutation hooks
const createDraftMutation = useCreateJournalEntryDraft();
const updateDraftMutation = useUpdateJournalEntryDraft();
const discardDraftMutation = useDiscardJournalEntryDraft();
// Fetch accounts and drafts from API
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id);
@ -124,7 +134,7 @@ export default function Kassekladde() {
return <Tag color="red">Annulleret</Tag>;
}
return value ? (
<Tag color="green">Bogfort</Tag>
<Tag color="green">Bogført</Tag>
) : (
<Tag color="orange">Kladde</Tag>
);
@ -189,17 +199,56 @@ export default function Kassekladde() {
setIsModalOpen(true);
break;
case 'copy':
message.success(`Bilag ${record.transactionNumber} kopieret`);
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
break;
}
(async () => {
try {
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: `Kopi af ${record.description}`,
description: record.description,
fiscalYearId: currentFiscalYear?.id,
});
// Copy lines to the new draft
if (record.lines && record.lines.length > 0) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: record.lines.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId,
debitAmount: l.debitAmount || 0,
creditAmount: l.creditAmount || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success(`Bilag ${record.transactionNumber} kopieret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved kopiering: ${error.message}`);
}
}
})();
break;
case 'void':
Modal.confirm({
title: 'Annuller bilag',
content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`,
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
okText: 'Annuller bilag',
okType: 'danger',
cancelText: 'Fortryd',
onOk: () => {
message.success(`Bilag ${record.transactionNumber} annulleret`);
onOk: async () => {
try {
await discardDraftMutation.mutateAsync(record.id);
message.success(`Bilag ${record.transactionNumber} annulleret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved annullering: ${error.message}`);
}
}
},
});
break;
@ -238,18 +287,72 @@ export default function Kassekladde() {
const validation = validateDoubleEntry(lines as TransactionLine[]);
if (!validation.valid) {
message.error(
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
);
return;
}
console.log('Submitting:', { ...values, lines });
message.success('Bilag oprettet');
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
return;
}
if (editingDraft) {
// Update existing draft
await updateDraftMutation.mutateAsync({
id: editingDraft.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
message.success('Bilag opdateret');
} else {
// Create new draft
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
});
// Update the draft with lines
if (lines.some(l => l.accountId)) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success('Bilag oprettet');
}
setIsModalOpen(false);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
@ -258,21 +361,11 @@ export default function Kassekladde() {
if (isLoading) {
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kassekladde
</Title>
<Text type="secondary">{activeCompany?.name}</Text>
</div>
</div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
/>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
@ -280,32 +373,23 @@ export default function Kassekladde() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kassekladde
</Title>
<Text type="secondary">{activeCompany?.name}</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingDraft(null);
setIsModalOpen(true);
}}
>
Nyt bilag
</Button>
</div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingDraft(null);
setIsModalOpen(true);
}}
>
Nyt bilag
</Button>
}
/>
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
@ -329,7 +413,7 @@ export default function Kassekladde() {
style={{ width: 120 }}
allowClear
options={[
{ value: 'posted', label: 'Bogfort' },
{ value: 'posted', label: 'Bogført' },
{ value: 'draft', label: 'Kladde' },
{ value: 'discarded', label: 'Annulleret' },
]}
@ -373,7 +457,7 @@ export default function Kassekladde() {
<Form.Item
name="date"
label="Dato"
rules={[{ required: true, message: 'Vaelg dato' }]}
rules={[{ required: true, message: 'Vælg dato' }]}
initialValue={dayjs()}
>
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
@ -409,7 +493,7 @@ export default function Kassekladde() {
<td style={{ padding: 4 }}>
<Select
style={{ width: '100%' }}
placeholder="Vaelg konto"
placeholder="Vælg konto"
showSearch
optionFilterProp="label"
value={line.accountId}
@ -478,7 +562,7 @@ export default function Kassekladde() {
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
<td style={{ padding: 8 }}>
<Button type="dashed" size="small" onClick={handleAddLine}>
+ Tilfoej linje
+ Tilføj linje
</Button>
</td>
<td

View file

@ -1,46 +1,45 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Tree,
Table,
Space,
Tag,
Modal,
Drawer,
Form,
Input,
Select,
Tabs,
Statistic,
message,
Grid,
Skeleton,
Empty,
Switch,
Divider,
Descriptions,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
FolderOutlined,
FileOutlined,
SearchOutlined,
MoreOutlined,
HistoryOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useCompanyStore } from '@/stores/companyStore';
import { usePeriodStore } from '@/stores/periodStore';
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
import { formatCurrency } from '@/lib/formatters';
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
import { getAccountTypeName } from '@/lib/accounting';
import { accountingColors } from '@/styles/theme';
import { spacing } from '@/styles/designTokens';
import { PageHeader } from '@/components/shared/PageHeader';
import { EmptyState } from '@/components/shared/EmptyState';
import type { Account, AccountType } from '@/types/accounting';
import { useCreateAccount } from '@/api/mutations/accountMutations';
const { Text } = Typography;
const { useBreakpoint } = Grid;
const accountTypes: AccountType[] = [
'asset',
@ -54,17 +53,23 @@ const accountTypes: AccountType[] = [
'extraordinary',
];
interface AccountWithBalance extends Account {
balance: number;
}
export default function Kontooversigt() {
const navigate = useNavigate();
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
const screens = useBreakpoint();
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [selectedAccount, setSelectedAccount] = useState<AccountWithBalance | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [form] = Form.useForm();
const isMobile = !screens.md;
// Mutation hooks
const createAccountMutation = useCreateAccount();
// Fetch accounts and balances from API
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
@ -78,161 +83,176 @@ export default function Kontooversigt() {
const isLoading = accountsLoading || balancesLoading;
// Combine accounts with balances
const accountsWithBalances = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
// Build tree data from accounts
const buildTreeData = (): DataNode[] => {
return accountTypes.map((type) => {
const range = getAccountNumberRange(type);
const typeAccounts = accountsWithBalances.filter((acc) => acc.type === type);
const typeBalance = typeAccounts.reduce((sum, acc) => sum + acc.balance, 0);
return {
key: type,
title: (
<Space>
<Text strong>{getAccountTypeName(type)}</Text>
<Text type="secondary">({range.min}-{range.max})</Text>
<Text
className="tabular-nums"
style={{
color: typeBalance >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(Math.abs(typeBalance))}
</Text>
</Space>
),
icon: <FolderOutlined />,
children: typeAccounts
.filter((acc) =>
searchText === '' ||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
acc.accountNumber.includes(searchText)
)
.map((acc) => ({
key: acc.id,
title: (
<Space>
<Text code>{acc.accountNumber}</Text>
<Text>{acc.name}</Text>
{!acc.isActive && <Tag color="red">Inaktiv</Tag>}
<Text
className="tabular-nums"
style={{
color: acc.balance >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(Math.abs(acc.balance))}
</Text>
</Space>
),
icon: <FileOutlined />,
isLeaf: true,
})),
};
// Combine accounts with balances and filter
const tableData = useMemo(() => {
const combined = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
};
const handleSelectAccount = (selectedKeys: React.Key[]) => {
const key = selectedKeys[0];
if (key && !accountTypes.includes(key as AccountType)) {
const account = accountsWithBalances.find((acc) => acc.id === key);
setSelectedAccount(account || null);
}
return combined
.filter(acc => showInactive || acc.isActive)
.filter(acc =>
searchText === '' ||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
acc.accountNumber.includes(searchText)
)
.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
}, [accounts, balances, searchText, showInactive]);
// Calculate totals for KPI cards
const kpiData = useMemo(() => {
const data = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
return {
assets: data.filter(a => a.type === 'asset').reduce((sum, a) => sum + a.balance, 0),
liabilities: data.filter(a => ['liability', 'equity'].includes(a.type)).reduce((sum, a) => sum + Math.abs(a.balance), 0),
revenue: data.filter(a => a.type === 'revenue').reduce((sum, a) => sum + Math.abs(a.balance), 0),
expenses: data.filter(a => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type)).reduce((sum, a) => sum + a.balance, 0),
};
}, [accounts, balances]);
const handleRowClick = (record: AccountWithBalance) => {
setSelectedAccount(record);
setIsEditMode(false);
setIsDrawerOpen(true);
};
const handleCreateAccount = () => {
setEditingAccount(null);
setSelectedAccount(null);
form.resetFields();
setIsModalOpen(true);
setIsEditMode(true);
setIsDrawerOpen(true);
};
const handleEditAccount = (account: Account) => {
setEditingAccount(account);
form.setFieldsValue(account);
setIsModalOpen(true);
const handleEditAccount = () => {
if (selectedAccount) {
form.setFieldsValue(selectedAccount);
setIsEditMode(true);
}
};
const handleCloseDrawer = () => {
setIsDrawerOpen(false);
setIsEditMode(false);
setSelectedAccount(null);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
console.log('Submitting account:', values);
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
setIsModalOpen(false);
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
return;
}
if (selectedAccount) {
// TODO: Backend does not yet have an updateAccount mutation.
// For now, show a message indicating this is not yet supported.
message.warning('Redigering af konti er endnu ikke understottet i backend');
} else {
// Create new account
await createAccountMutation.mutateAsync({
companyId: activeCompany.id,
accountNumber: values.accountNumber,
name: values.name,
accountType: values.type,
description: values.description,
vatCodeId: values.vatCode,
});
message.success('Konto oprettet');
}
handleCloseDrawer();
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
// Calculate totals from actual data
const totalAssets = accountsWithBalances
.filter((a) => a.type === 'asset')
.reduce((sum, a) => sum + a.balance, 0);
const totalLiabilities = accountsWithBalances
.filter((a) => ['liability', 'equity'].includes(a.type))
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalRevenue = accountsWithBalances
.filter((a) => a.type === 'revenue')
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalExpenses = accountsWithBalances
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
.reduce((sum, a) => sum + a.balance, 0);
if (isLoading) {
return (
<div>
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
const columns: ColumnsType<AccountWithBalance> = [
{
title: 'Nr.',
dataIndex: 'accountNumber',
key: 'accountNumber',
width: 100,
render: (text) => <Text code>{text}</Text>,
sorter: (a, b) => a.accountNumber.localeCompare(b.accountNumber),
},
{
title: 'Navn',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Text strong>{text}</Text>
{!record.isActive && <Tag color="default" bordered={false}>Inaktiv</Tag>}
</Space>
),
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 150,
filters: accountTypes.map(type => ({ text: getAccountTypeName(type), value: type })),
onFilter: (value, record) => record.type === value,
render: (type) => <Tag>{getAccountTypeName(type)}</Tag>,
},
{
title: 'Moms',
dataIndex: 'vatCode',
key: 'vatCode',
width: 100,
render: (code) => code ? <Tag color="blue">{code}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'Saldo',
dataIndex: 'balance',
key: 'balance',
align: 'right',
width: 150,
render: (value) => (
<Text
strong
style={{
color: value >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(value)}
</Text>
),
sorter: (a, b) => a.balance - b.balance,
},
{
key: 'action',
width: 50,
render: (_, record) => (
<Button
type="text"
icon={<MoreOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRowClick(record);
}}
/>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (accounts.length === 0) {
return (
<div>
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateAccount}
aria-label="Opret ny konto"
>
Ny konto
</Button>
}
/>
<Empty description="Ingen konti fundet. Opret en ny konto for at komme i gang." />
</div>
);
}
),
},
];
return (
<div>
{/* Header */}
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Bogføring', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
extra={
@ -240,280 +260,262 @@ export default function Kontooversigt() {
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateAccount}
aria-label="Opret ny konto"
>
Ny konto
</Button>
}
/>
{/* Summary Cards */}
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
{/* KPI Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Aktiver total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
<Statistic
title="Aktiver"
value={totalAssets}
precision={2}
suffix="kr."
value={kpiData.assets}
precision={0}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Passiver total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
<Statistic
title="Passiver"
value={totalLiabilities}
precision={2}
suffix="kr."
value={kpiData.liabilities}
precision={0}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Omsaetning total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
<Statistic
title="Omsaetning"
value={totalRevenue}
precision={2}
suffix="kr."
title="Omsætning"
value={kpiData.revenue}
precision={0}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Omkostninger total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
<Statistic
title="Omkostninger"
value={totalExpenses}
precision={2}
suffix="kr."
value={kpiData.expenses}
precision={0}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
</Row>
{/* Main Content */}
<Row gutter={spacing.lg}>
{/* Account Tree */}
<Col xs={24} lg={10}>
<Card title="Kontoplan" size="small">
{/* Search moved outside extra for better mobile UX */}
<Input
placeholder="Sog efter konto eller kontonummer..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ marginBottom: spacing.md }}
allowClear
aria-label="Sog i kontoplan"
/>
<Tree
showIcon
defaultExpandAll
treeData={buildTreeData()}
onSelect={handleSelectAccount}
selectedKeys={selectedAccount ? [selectedAccount.id] : []}
style={{
maxHeight: isMobile ? 300 : 450,
overflow: 'auto',
}}
aria-label="Kontoplan hierarki"
/>
</Card>
</Col>
{/* Account Details */}
<Col xs={24} lg={14}>
{selectedAccount ? (
<Card
title={
<Space>
<Text code>{selectedAccount.accountNumber}</Text>
<Text strong>{selectedAccount.name}</Text>
{!selectedAccount.isActive && (
<Tag color="red">Inaktiv</Tag>
)}
</Space>
}
size="small"
extra={
<Button
icon={<EditOutlined />}
onClick={() => handleEditAccount(selectedAccount)}
aria-label={`Rediger konto ${selectedAccount.accountNumber}`}
>
Rediger
</Button>
}
role="region"
aria-label={`Detaljer for konto ${selectedAccount.accountNumber}`}
>
<Tabs
items={[
{
key: 'transactions',
label: 'Bevaegelser',
children: (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Statistic
title="Saldo"
value={selectedAccount.balance}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color:
selectedAccount.balance >= 0
? accountingColors.credit
: accountingColors.debit,
}}
/>
</div>
<Empty description="Ingen bevaegelser" />
</div>
),
},
{
key: 'info',
label: 'Kontooplysninger',
children: (
<div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Text type="secondary">Kontonummer</Text>
<div>
<Text strong>{selectedAccount.accountNumber}</Text>
</div>
</Col>
<Col span={12}>
<Text type="secondary">Kontotype</Text>
<div>
<Tag>{getAccountTypeName(selectedAccount.type)}</Tag>
</div>
</Col>
<Col span={12}>
<Text type="secondary">Status</Text>
<div>
{selectedAccount.isActive ? (
<Tag color="green">Aktiv</Tag>
) : (
<Tag color="red">Inaktiv</Tag>
)}
</div>
</Col>
<Col span={12}>
<Text type="secondary">Momskode</Text>
<div>
<Text>{selectedAccount.vatCode || 'Ingen'}</Text>
</div>
</Col>
</Row>
</div>
),
},
]}
/>
</Card>
) : (
<Card size="small">
<EmptyState
variant="accounts"
icon={<FileOutlined style={{ fontSize: 48 }} />}
title="Ingen konto valgt"
description="Vaelg en konto i kontoplanen til venstre for at se detaljer og bevaegelser."
compact
/>
</Card>
)}
</Col>
</Row>
{/* Create/Edit Account Modal */}
<Modal
title={editingAccount ? 'Rediger konto' : 'Opret konto'}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
onOk={handleSubmit}
okText="Gem"
cancelText="Annuller"
<Card
bordered={false}
bodyStyle={{ padding: 0 }}
title={
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Søg på navn eller nummer..."
style={{ width: 300 }}
allowClear
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
}
extra={
<Space>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
name="accountNumber"
label="Kontonummer"
rules={[
{ required: true, message: 'Indtast kontonummer' },
{
pattern: /^\d{4}$/,
message: 'Kontonummer skal vaere 4 cifre',
},
]}
>
<Input placeholder="F.eks. 1000" maxLength={4} />
</Form.Item>
<Table
columns={columns}
dataSource={tableData}
rowKey="id"
loading={isLoading}
pagination={{ pageSize: 50, showSizeChanger: true }}
size="middle"
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: 'pointer' },
})}
/>
</Card>
<Form.Item
name="name"
label="Kontonavn"
rules={[{ required: true, message: 'Indtast kontonavn' }]}
>
<Input placeholder="F.eks. Bankkonto" />
</Form.Item>
{/* Details/Edit Drawer */}
<Drawer
title={
isEditMode
? (selectedAccount ? 'Rediger konto' : 'Ny konto')
: (
<Space>
{selectedAccount?.name}
<Tag>{selectedAccount?.accountNumber}</Tag>
</Space>
)
}
width={500}
open={isDrawerOpen}
onClose={handleCloseDrawer}
extra={
!isEditMode && selectedAccount && (
<Button type="primary" icon={<EditOutlined />} onClick={handleEditAccount}>
Rediger
</Button>
)
}
footer={
isEditMode && (
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => isEditMode && selectedAccount ? setIsEditMode(false) : handleCloseDrawer()}>
Annuller
</Button>
<Button type="primary" onClick={handleSubmit}>
Gem konto
</Button>
</Space>
</div>
)
}
>
{isEditMode ? (
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="accountNumber"
label="Kontonummer"
rules={[
{ required: true, message: 'Påkrævet' },
{ pattern: /^\d{4}$/, message: 'Skal være 4 cifre' },
]}
>
<Input maxLength={4} placeholder="1234" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="type"
label="Kontotype"
rules={[{ required: true, message: 'Påkrævet' }]}
>
<Select options={accountTypes.map(t => ({ label: getAccountTypeName(t), value: t }))} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="type"
label="Kontotype"
rules={[{ required: true, message: 'Vaelg kontotype' }]}
>
<Select
placeholder="Vaelg type"
options={accountTypes.map((type) => ({
value: type,
label: getAccountTypeName(type),
}))}
/>
</Form.Item>
<Form.Item
name="name"
label="Kontonavn"
rules={[{ required: true, message: 'Påkrævet' }]}
>
<Input placeholder="F.eks. Salg af varer" />
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vaelg momskode"
allowClear
options={[
{ value: 'S25', label: 'S25 - Udgaende moms 25%' },
{ value: 'K25', label: 'K25 - Indgaende moms 25%' },
{ value: 'E0', label: 'E0 - EU-varekob 0%' },
{ value: 'U0', label: 'U0 - Eksport 0%' },
]}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="vatCode" label="Momskode">
<Select
allowClear
placeholder="Vælg..."
options={[
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
{ value: 'E0', label: 'E0 - EU-salg' },
{ value: 'U0', label: 'U0 - Eksport' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="isActive" label="Status" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="Aktiv" unCheckedChildren="Inaktiv" />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
</Form.Item>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={4} placeholder="Interne noter til denne konto..." />
</Form.Item>
</Form>
) : selectedAccount ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Balance Summary */}
<Card size="small" bordered={false} style={{ background: '#f5f5f5' }}>
<Statistic
title="Nuværende Saldo"
value={selectedAccount.balance}
precision={2}
suffix="kr."
valueStyle={{
color: selectedAccount.balance >= 0 ? accountingColors.credit : accountingColors.debit,
fontSize: 24,
}}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
Beregnet for indeværende regnskabsår
</Text>
</Card>
<Form.Item name="isActive" label="Status" initialValue={true}>
<Select
options={[
{ value: true, label: 'Aktiv' },
{ value: false, label: 'Inaktiv' },
]}
/>
</Form.Item>
</Form>
</Modal>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Kontonummer">{selectedAccount.accountNumber}</Descriptions.Item>
<Descriptions.Item label="Kontotype">{getAccountTypeName(selectedAccount.type)}</Descriptions.Item>
<Descriptions.Item label="Moms">{selectedAccount.vatCode || <Text type="secondary">Ingen</Text>}</Descriptions.Item>
<Descriptions.Item label="Status">
{selectedAccount.isActive ? <Tag color="success">Aktiv</Tag> : <Tag color="default">Inaktiv</Tag>}
</Descriptions.Item>
{selectedAccount.description && (
<Descriptions.Item label="Beskrivelse">
{selectedAccount.description}
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left" plain><HistoryOutlined /> Seneste Bevægelser</Divider>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Text type="secondary">Ingen posteringer i den valgte periode.</Text>
<br />
<Button type="link" size="small" onClick={() => navigate('/kassekladde')}> til kassekladde</Button>
</div>
<Divider plain />
<Space direction="vertical" style={{ width: '100%' }}>
<AlertInfo
message="Systemkonto"
description="Denne konto bruges automatisk af systemet til specifikke posteringer."
show={!!selectedAccount.isSystemAccount}
/>
</Space>
</Space>
) : null}
</Drawer>
</div>
);
}
// Helper component for alerts
function AlertInfo({ message, description, show }: { message: string, description: string, show: boolean }) {
if (!show) return null;
return (
<div style={{ background: '#e6f7ff', border: '1px solid #91d5ff', padding: '8px 12px', borderRadius: 4 }}>
<Space align="start">
<InfoCircleOutlined style={{ color: '#1890ff', marginTop: 4 }} />
<div>
<Text strong style={{ color: '#1890ff' }}>{message}</Text>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>{description}</div>
</div>
</Space>
</div>
);
}

View file

@ -124,9 +124,9 @@ export default function Kreditnotaer() {
// Fetch customers for dropdown
const { data: customers = [] } = useActiveCustomers(company?.id);
// Fetch invoices for applying credit notes (only when modal is open)
// Fetch invoices for applying credit notes and for original invoice selector
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
enabled: !!company?.id && isApplyModalOpen,
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
});
const openInvoices: Invoice[] = allInvoices.filter(
@ -623,6 +623,15 @@ export default function Kreditnotaer() {
allowClear
placeholder="Vælg faktura der krediteres"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={allInvoices
.filter((i: Invoice) => !i.isCreditNote && i.status !== 'voided')
.map((i: Invoice) => ({
value: i.id,
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountTotal)})`,
}))}
/>
</Form.Item>
<Form.Item name="reason" label="Årsag">

View file

@ -11,6 +11,7 @@ import {
Form,
Input,
Select,
Switch,
Spin,
Alert,
Drawer,
@ -48,6 +49,7 @@ import { formatDate, validateCVRModulus11 } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
const { Title, Text } = Typography;
@ -310,27 +312,18 @@ export default function Kunder() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kunder
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<ShortcutTooltip shortcutId="newCustomer">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Ny kunde
</Button>
</ShortcutTooltip>
</div>
<PageHeader
title="Kunder"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Kunder' }]}
extra={
<ShortcutTooltip shortcutId="newCustomer">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Ny kunde
</Button>
</ShortcutTooltip>
}
/>
{/* Error State */}
{error && (
@ -391,15 +384,10 @@ export default function Kunder() {
style={{ width: 250 }}
allowClear
/>
<Select
value={showInactive ? 'all' : 'active'}
onChange={(value) => setShowInactive(value === 'all')}
style={{ width: 150 }}
options={[
{ value: 'active', label: 'Kun aktive' },
{ value: 'all', label: 'Alle kunder' },
]}
/>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
</Card>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Typography,
Card,
@ -15,24 +15,26 @@ import {
Alert,
Modal,
Descriptions,
message,
Empty,
Skeleton,
} from 'antd';
import {
DownloadOutlined,
SendOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { Pie } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency, formatDate, formatPeriod } from '@/lib/formatters';
import { useCompanyStore } from '@/stores/companyStore';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatPeriod } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
// Danish VAT boxes (Rubrikker)
// Danish VAT boxes (Rubrikker) - mapped from backend VatReport
interface VATBox {
boxNumber: number;
nameDanish: string;
@ -42,134 +44,90 @@ interface VATBox {
basis?: number;
}
const mockVATReport: VATBox[] = [
{
boxNumber: 1,
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
amount: 62500,
basis: 250000,
},
{
boxNumber: 2,
nameDanish: 'Moms af varekøb i udlandet (EU)',
nameEnglish: 'VAT on goods from EU',
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
amount: 5000,
basis: 20000,
},
{
boxNumber: 3,
nameDanish: 'Moms af ydelseskøb i udlandet',
nameEnglish: 'VAT on services from abroad',
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
amount: 2500,
basis: 10000,
},
{
boxNumber: 4,
nameDanish: 'Købsmoms',
nameEnglish: 'Input VAT',
description: 'Fradragsberettiget moms af køb',
amount: 35000,
basis: 140000,
},
{
boxNumber: 5,
nameDanish: 'Olie- og flaskegasafgift',
nameEnglish: 'Oil and gas duty',
description: 'Godtgørelse af olie- og flaskegasafgift',
amount: 0,
},
{
boxNumber: 6,
nameDanish: 'Elafgift',
nameEnglish: 'Electricity duty',
description: 'Godtgørelse af elafgift',
amount: 1200,
},
{
boxNumber: 7,
nameDanish: 'Naturgas- og bygasafgift',
nameEnglish: 'Natural gas duty',
description: 'Godtgørelse af naturgas- og bygasafgift',
amount: 0,
},
{
boxNumber: 8,
nameDanish: 'Kulafgift',
nameEnglish: 'Coal duty',
description: 'Godtgørelse af kulafgift',
amount: 0,
},
{
boxNumber: 9,
nameDanish: 'CO2-afgift',
nameEnglish: 'CO2 duty',
description: 'Godtgørelse af CO2-afgift',
amount: 300,
},
];
// Historical submissions
const mockSubmissions = [
{
id: '1',
period: '2024-10',
submittedAt: '2024-11-28',
status: 'accepted',
netVAT: 28500,
referenceNumber: 'SKAT-2024-123456',
},
{
id: '2',
period: '2024-07',
submittedAt: '2024-08-30',
status: 'accepted',
netVAT: 32100,
referenceNumber: 'SKAT-2024-789012',
},
{
id: '3',
period: '2024-04',
submittedAt: '2024-05-29',
status: 'accepted',
netVAT: -5600,
referenceNumber: 'SKAT-2024-345678',
},
];
export default function Momsindberetning() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
dayjs().subtract(1, 'month').startOf('month')
);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
// Calculate totals
const outputVAT = mockVATReport
.filter((box) => [1, 2, 3].includes(box.boxNumber))
.reduce((sum, box) => sum + box.amount, 0);
// Calculate period dates based on selection
const periodStart = useMemo(() => {
if (periodType === 'quarterly') {
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
}
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
const inputVAT = mockVATReport
.filter((box) => box.boxNumber === 4)
.reduce((sum, box) => sum + box.amount, 0);
const periodEnd = useMemo(() => {
if (periodType === 'quarterly') {
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
}
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
const energyDuties = mockVATReport
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
.reduce((sum, box) => sum + box.amount, 0);
// Fetch VAT report from backend
const { data: vatReport, isLoading, error } = useVatReport(
activeCompany?.id,
periodStart,
periodEnd
);
const netVAT = outputVAT - inputVAT - energyDuties;
// Map backend VatReport to UI's rubrik display
const vatBoxes: VATBox[] = useMemo(() => {
if (!vatReport) return [];
return [
{
boxNumber: 1,
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
amount: vatReport.boxA,
basis: vatReport.basis1,
},
{
boxNumber: 2,
nameDanish: 'Moms af varekob i udlandet (EU)',
nameEnglish: 'VAT on goods from EU',
description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande',
amount: vatReport.boxC,
basis: vatReport.basis3,
},
{
boxNumber: 3,
nameDanish: 'Moms af ydelseskob i udlandet',
nameEnglish: 'VAT on services from abroad',
description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt',
amount: vatReport.boxD,
basis: vatReport.basis4,
},
{
boxNumber: 4,
nameDanish: 'Kobsmoms',
nameEnglish: 'Input VAT',
description: 'Fradragsberettiget moms af kob',
amount: vatReport.boxB,
basis: undefined, // Backend doesn't provide a specific basis for input VAT
},
];
}, [vatReport]);
// Calculate totals from real data
const outputVAT = vatReport?.totalOutputVat ?? 0;
const inputVAT = vatReport?.totalInputVat ?? 0;
const netVAT = vatReport?.netVat ?? 0;
// Pie chart config
const pieData = [
{ type: 'Salgsmoms', value: mockVATReport[0].amount },
{ type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount },
{ type: 'Købsmoms (fradrag)', value: inputVAT },
{ type: 'Energiafgifter (fradrag)', value: energyDuties },
];
const pieData = useMemo(() => {
if (!vatReport) return [];
return [
{ type: 'Salgsmoms', value: vatReport.boxA },
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
].filter(d => d.value > 0);
}, [vatReport, inputVAT]);
const pieConfig = {
data: pieData,
@ -243,96 +201,49 @@ export default function Momsindberetning() {
},
];
const handleSubmit = () => {
Modal.confirm({
title: 'Indsend momsangivelse',
icon: <ExclamationCircleOutlined />,
content: (
<div>
<p>Du er ved at indsende momsangivelse for:</p>
<p>
<Text strong>Periode:</Text> {formatPeriod(selectedPeriod.toDate())}
</p>
<p>
<Text strong>Moms til betaling:</Text>{' '}
<Text
style={{
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(Math.abs(netVAT))}
{netVAT < 0 ? ' (tilgode)' : ''}
</Text>
</p>
<Alert
message="Denne handling kan ikke fortrydes"
type="warning"
showIcon
style={{ marginTop: 16 }}
/>
</div>
),
okText: 'Indsend til SKAT',
cancelText: 'Annuller',
onOk: () => {
message.success('Momsangivelse indsendt til SKAT');
},
});
};
const getStatusTag = (status: string) => {
switch (status) {
case 'accepted':
return (
<Tag color="green" icon={<CheckCircleOutlined />}>
Godkendt
</Tag>
);
case 'pending':
return (
<Tag color="blue" icon={<ClockCircleOutlined />}>
Afventer
</Tag>
);
case 'rejected':
return (
<Tag color="red" icon={<ExclamationCircleOutlined />}>
Afvist
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
};
// Loading state
if (isLoading) {
return (
<div>
<PageHeader
title="Momsindberetning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
/>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Momsindberetning
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button icon={<DownloadOutlined />}>Eksporter</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={() => setIsPreviewOpen(true)}
>
Forhåndsvis
</Button>
</Space>
</div>
<PageHeader
title="Momsindberetning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
extra={
<Space>
<Button icon={<DownloadOutlined />}>Eksporter</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={() => setIsPreviewOpen(true)}
disabled={!vatReport}
>
Forhåndsvis
</Button>
</Space>
}
/>
{/* SKAT submission notice */}
<Alert
type="warning"
message="Indberetning skal ske manuelt på skat.dk"
description="Automatisk indberetning til SKAT er endnu ikke implementeret. Brug disse tal til at udfylde momsangivelsen på skat.dk manuelt."
showIcon
style={{ marginBottom: 16 }}
/>
{/* Period Selection */}
<Card size="small" style={{ marginBottom: 16 }}>
@ -343,7 +254,7 @@ export default function Momsindberetning() {
onChange={setPeriodType}
style={{ width: 120 }}
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'monthly', label: 'Maanedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
]}
/>
@ -356,12 +267,26 @@ export default function Momsindberetning() {
<Tag color="blue">
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
</Tag>
{vatReport && (
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
)}
</Space>
</Card>
{/* Error state */}
{error && (
<Alert
type="error"
message="Fejl ved indlaesning af momsdata"
description={error.message}
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title="Udgående moms"
@ -372,7 +297,7 @@ export default function Momsindberetning() {
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title="Indgående moms (fradrag)"
@ -383,18 +308,7 @@ export default function Momsindberetning() {
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Energiafgifter (fradrag)"
value={energyDuties}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.credit }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
@ -413,87 +327,57 @@ export default function Momsindberetning() {
<Row gutter={16}>
<Col xs={24} lg={16}>
<Card title="Momsangivelse - Rubrikker" size="small">
<Table
dataSource={mockVATReport}
columns={columns}
rowKey="boxNumber"
pagination={false}
size="small"
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3}>
<Text strong>Moms til betaling / tilgode</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text
strong
className="tabular-nums"
style={{
fontSize: 16,
color:
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{netVAT >= 0 ? '' : '-'}
{formatCurrency(Math.abs(netVAT))}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
{vatBoxes.length > 0 ? (
<Table
dataSource={vatBoxes}
columns={columns}
rowKey="boxNumber"
pagination={false}
size="small"
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3}>
<Text strong>Moms til betaling / tilgode</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text
strong
className="tabular-nums"
style={{
fontSize: 16,
color:
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{netVAT >= 0 ? '' : '-'}
{formatCurrency(Math.abs(netVAT))}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
) : (
<Empty description="Ingen momsdata for den valgte periode" />
)}
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
<Pie {...pieConfig} />
{pieData.length > 0 ? (
<Pie {...pieConfig} />
) : (
<Empty description="Ingen data at vise" style={{ height: 200 }} />
)}
</Card>
<Card title="Tidligere indberetninger" size="small">
{mockSubmissions.map((sub) => (
<div
key={sub.id}
style={{
padding: '8px 0',
borderBottom: '1px solid #f0f0f0',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<Text strong>
{dayjs(sub.period, 'YYYY-MM').format('MMMM YYYY')}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Indsendt {formatDate(sub.submittedAt)}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
{getStatusTag(sub.status)}
<br />
<Text
className="tabular-nums"
style={{
color:
sub.netVAT >= 0
? accountingColors.debit
: accountingColors.credit,
}}
>
{formatCurrency(sub.netVAT)}
</Text>
</div>
</div>
</div>
))}
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
<Text type="secondary">
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
</Text>
</Card>
</Col>
</Row>
@ -512,18 +396,25 @@ export default function Momsindberetning() {
Download PDF
</Button>,
<Button
key="submit"
key="skat-link"
type="primary"
icon={<SendOutlined />}
onClick={() => {
window.open('https://skat.dk', '_blank');
setIsPreviewOpen(false);
handleSubmit();
}}
>
Indsend til SKAT
Ga til skat.dk
</Button>,
]}
>
<Alert
type="info"
message="Brug disse tal til at udfylde momsangivelsen på skat.dk"
showIcon
style={{ marginBottom: 16 }}
/>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
@ -537,22 +428,24 @@ export default function Momsindberetning() {
<Divider />
<Table
dataSource={mockVATReport}
columns={[
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
{ dataIndex: 'nameDanish', title: 'Felt' },
{
dataIndex: 'amount',
title: 'Beløb',
align: 'right',
render: (v) => formatCurrency(v),
},
]}
rowKey="boxNumber"
pagination={false}
size="small"
/>
{vatBoxes.length > 0 && (
<Table
dataSource={vatBoxes}
columns={[
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
{ dataIndex: 'nameDanish', title: 'Felt' },
{
dataIndex: 'amount',
title: 'Belob',
align: 'right',
render: (v: number) => formatCurrency(v),
},
]}
rowKey="boxNumber"
pagination={false}
size="small"
/>
)}
<Divider />

View file

@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens';
import { accountingColors } from '@/styles/theme';
import { AmountText } from '@/components/shared/AmountText';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
import type { Order, OrderLine, OrderStatus } from '@/types/order';
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
@ -140,7 +141,7 @@ export default function Ordrer() {
const handleSubmitCreate = async () => {
if (!company || !currentFiscalYear) {
showError('Virksomhed eller regnskabsaar ikke valgt');
showError('Virksomhed eller regnskabsår ikke valgt');
return;
}
try {
@ -226,12 +227,12 @@ export default function Ordrer() {
const handleConfirmOrder = async () => {
if (!selectedOrder) return;
if (selectedOrder.lines.length === 0) {
showWarning('Tilfoej mindst en linje foer bekraeftelse');
showWarning('Tilføj mindst en linje før bekræftelse');
return;
}
try {
await confirmOrderMutation.mutateAsync(selectedOrder.id);
showSuccess('Ordre bekraeftet');
showSuccess('Ordre bekræftet');
// Refresh would happen via query invalidation
} catch (err) {
if (err instanceof Error) {
@ -276,7 +277,7 @@ export default function Ordrer() {
const handleSubmitConvert = async () => {
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
showWarning('Vaelg mindst en linje at fakturere');
showWarning('Vælg mindst en linje at fakturere');
return;
}
try {
@ -346,7 +347,7 @@ export default function Ordrer() {
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
},
{
title: 'Beloeb',
title: 'Beløb',
dataIndex: 'amountTotal',
key: 'amountTotal',
width: 120,
@ -371,7 +372,7 @@ export default function Ordrer() {
align: 'center',
filters: [
{ text: 'Kladde', value: 'draft' },
{ text: 'Bekraeftet', value: 'confirmed' },
{ text: 'Bekræftet', value: 'confirmed' },
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
{ text: 'Annulleret', value: 'cancelled' },
@ -399,37 +400,28 @@ export default function Ordrer() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Ordrer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
Ny ordre
</Button>
</div>
<PageHeader
title="Ordrer"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Ordrer' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
Ny ordre
</Button>
}
/>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlaesning af ordrer"
message="Fejl ved indlæsning af ordrer"
description={error.message}
type="error"
showIcon
style={{ marginBottom: spacing.lg }}
action={
<Button size="small" onClick={() => refetch()}>
Proev igen
Prøv igen
</Button>
}
/>
@ -454,7 +446,7 @@ export default function Ordrer() {
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Bekraeftede"
title="Bekræftede"
value={stats.confirmed}
valueStyle={{ color: accountingColors.credit }}
/>
@ -463,7 +455,7 @@ export default function Ordrer() {
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Samlet vaerdi"
title="Samlet værdi"
value={stats.totalValue}
precision={2}
valueStyle={{ color: accountingColors.credit }}
@ -477,7 +469,7 @@ export default function Ordrer() {
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Soeg ordre..."
placeholder="Søg ordre..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
@ -491,7 +483,7 @@ export default function Ordrer() {
options={[
{ value: 'all', label: 'Alle status' },
{ value: 'draft', label: 'Kladde' },
{ value: 'confirmed', label: 'Bekraeftet' },
{ value: 'confirmed', label: 'Bekræftet' },
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
{ value: 'cancelled', label: 'Annulleret' },
@ -503,7 +495,7 @@ export default function Ordrer() {
{/* Order Table */}
<Card size="small">
{loading ? (
<Spin tip="Indlaeser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredOrders.length > 0 ? (
@ -518,7 +510,7 @@ export default function Ordrer() {
<EmptyState
variant="default"
title="Ingen ordrer"
description={searchText ? 'Ingen ordrer matcher din soegning' : 'Opret din foerste ordre'}
description={searchText ? 'Ingen ordrer matcher din søgning' : 'Opret din første ordre'}
primaryAction={
!searchText
? {
@ -546,11 +538,11 @@ export default function Ordrer() {
<Form.Item
name="customerId"
label="Kunde"
rules={[{ required: true, message: 'Vaelg kunde' }]}
rules={[{ required: true, message: 'Vælg kunde' }]}
>
<Select
showSearch
placeholder="Vaelg kunde"
placeholder="Vælg kunde"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
@ -576,7 +568,7 @@ export default function Ordrer() {
<Form.Item name="reference" label="Reference">
<Input placeholder="Projektnavn, tilbudsnr., etc." />
</Form.Item>
<Form.Item name="notes" label="Bemaerkninger">
<Form.Item name="notes" label="Bemærkninger">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
@ -612,7 +604,7 @@ export default function Ordrer() {
onClick={handleOpenAddLineModal}
loading={addOrderLineMutation.isPending}
>
Tilfoej linje
Tilføj linje
</Button>
<Button
type="primary"
@ -621,13 +613,13 @@ export default function Ordrer() {
loading={confirmOrderMutation.isPending}
disabled={selectedOrder.lines.length === 0}
>
Bekraeft
Bekræft
</Button>
</>
)}
{canShowConvertToInvoice(selectedOrder) && (
<Tooltip
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
title={selectedOrder.status === 'draft' ? 'Bekræft ordren først' : undefined}
>
<Button
type="primary"
@ -720,7 +712,7 @@ export default function Ordrer() {
) : (
<Alert
message="Ingen linjer endnu"
description="Tilfoej linjer for at kunne bekraefte ordren."
description="Tilføj linjer for at kunne bekræfte ordren."
type="info"
showIcon
/>
@ -732,13 +724,13 @@ export default function Ordrer() {
<Col span={12}>
{selectedOrder.notes && (
<>
<Text type="secondary">Bemaerkninger:</Text>
<Text type="secondary">Bemærkninger:</Text>
<p>{selectedOrder.notes}</p>
</>
)}
{selectedOrder.cancelledReason && (
<>
<Text type="secondary">Annulleringsaarsag:</Text>
<Text type="secondary">Annulleringsårsag:</Text>
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
</>
)}
@ -746,7 +738,7 @@ export default function Ordrer() {
<Col span={12}>
<div style={{ textAlign: 'right' }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Beloeb ex. moms: </Text>
<Text type="secondary">Beløb ex. moms: </Text>
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
@ -794,7 +786,7 @@ export default function Ordrer() {
>
<Alert
message="Advarsel"
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uaendrede."
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uændrede."
type="warning"
showIcon
style={{ marginBottom: spacing.lg }}
@ -802,8 +794,8 @@ export default function Ordrer() {
<Form form={cancelForm} layout="vertical">
<Form.Item
name="reason"
label="Aarsag til annullering"
rules={[{ required: true, message: 'Angiv aarsag' }]}
label="Årsag til annullering"
rules={[{ required: true, message: 'Angiv årsag' }]}
>
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
</Form.Item>
@ -812,14 +804,14 @@ export default function Ordrer() {
{/* Add Line Modal */}
<Modal
title="Tilfoej linje"
title="Tilføj linje"
open={isAddLineModalOpen}
onCancel={() => {
setIsAddLineModalOpen(false);
setSelectedProductId(null);
}}
onOk={handleSubmitAddLine}
okText="Tilfoej"
okText="Tilføj"
cancelText="Annuller"
confirmLoading={addOrderLineMutation.isPending}
width={550}
@ -840,7 +832,7 @@ export default function Ordrer() {
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="product">Vaelg produkt</Radio.Button>
<Radio.Button value="product">Vælg produkt</Radio.Button>
<Radio.Button value="freetext">Fritekst</Radio.Button>
</Radio.Group>
</Form.Item>
@ -850,11 +842,11 @@ export default function Ordrer() {
label="Produkt"
required
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined}
>
<Select
showSearch
placeholder="Soeg efter produkt..."
placeholder="Søg efter produkt..."
optionFilterProp="children"
value={selectedProductId}
onChange={handleProductSelect}
@ -922,7 +914,7 @@ export default function Ordrer() {
<Form.Item
name="vatCode"
label="Momskode"
rules={[{ required: true, message: 'Vaelg momskode' }]}
rules={[{ required: true, message: 'Vælg momskode' }]}
>
<Select
disabled={addLineMode === 'product' && !!selectedProductId}
@ -952,8 +944,8 @@ export default function Ordrer() {
width={600}
>
<Alert
message="Vaelg linjer til fakturering"
description="Vaelg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
message="Vælg linjer til fakturering"
description="Vælg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
type="info"
showIcon
style={{ marginBottom: spacing.lg }}

View file

@ -12,7 +12,6 @@ import {
InputNumber,
Select,
AutoComplete,
Spin,
Alert,
Drawer,
Descriptions,
@ -20,6 +19,8 @@ import {
Row,
Col,
Statistic,
Switch,
Skeleton,
} from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import {
@ -45,9 +46,10 @@ import { formatDate, formatCurrency } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
const { Title, Text } = Typography;
const { Text } = Typography;
// VAT code options
const vatCodeOptions = [
@ -310,33 +312,57 @@ export default function Produkter() {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: spacing.xl }}>
<Spin size="large" />
<div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
/>
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
</Row>
<Card><Skeleton active paragraph={{ rows: 8 }} /></Card>
</div>
);
}
if (error) {
return (
<Alert
message="Fejl ved indlæsning af produkter"
description={error.message}
type="error"
showIcon
/>
<div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
/>
<Alert
message="Fejl ved indlæsning af produkter"
description={error.message}
type="error"
showIcon
action={
<Button size="small" onClick={() => window.location.reload()}>
Prøv igen
</Button>
}
/>
</div>
);
}
return (
<div>
<div style={{ marginBottom: spacing.lg, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
Produkter
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Opret produkt
</Button>
</div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Opret produkt
</Button>
}
/>
{/* Statistics */}
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
@ -373,12 +399,10 @@ export default function Produkter() {
allowClear
style={{ width: 300 }}
/>
<Button
type={showInactive ? 'primary' : 'default'}
onClick={() => setShowInactive(!showInactive)}
>
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
</Button>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
</Card>

View file

@ -12,7 +12,7 @@ import {
Divider,
message,
Space,
Tag,
Empty,
} from 'antd';
import {
SaveOutlined,
@ -22,6 +22,8 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import { useCompany } from '@/hooks/useCompany';
import { useUpdateCompany } from '@/api/mutations/companyMutations';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
const { Title, Text } = Typography;
@ -29,24 +31,45 @@ export default function Settings() {
const { company } = useCompany();
const [companyForm] = Form.useForm();
const [preferencesForm] = Form.useForm();
const updateCompanyMutation = useUpdateCompany();
const handleSaveCompany = async () => {
try {
const values = await companyForm.validateFields();
console.log('Saving company:', values);
if (!company?.id) {
message.error('Ingen virksomhed valgt');
return;
}
await updateCompanyMutation.mutateAsync({
id: company.id,
input: {
name: values.name,
cvr: values.cvr,
address: values.address,
city: values.city,
postalCode: values.postalCode,
},
});
message.success('Virksomhedsoplysninger gemt');
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl ved gemning: ${error.message}`);
}
}
};
const handleSavePreferences = async () => {
try {
const values = await preferencesForm.validateFields();
console.log('Saving preferences:', values);
message.success('Præferencer gemt');
await preferencesForm.validateFields();
// TODO: Backend does not yet have a preferences mutation.
// Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint.
message.info('Præferencer er endnu ikke forbundet til backend');
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl ved gemning: ${error.message}`);
}
}
};
@ -282,56 +305,19 @@ export default function Settings() {
<Title level={5} style={{ margin: 0 }}>
Tilknyttede bankkonti
</Title>
<Button type="primary">Tilføj bankkonto</Button>
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
</div>
<Divider />
{/* Mock bank accounts */}
{[
{
id: '1',
bankName: 'Danske Bank',
accountName: 'Erhvervskonto',
accountNumber: '1234-5678901234',
ledgerAccount: '1000 - Bank',
isActive: true,
},
{
id: '2',
bankName: 'Nordea',
accountName: 'Opsparingskonto',
accountNumber: '9876-5432109876',
ledgerAccount: '1010 - Bank opsparing',
isActive: true,
},
].map((account) => (
<Card key={account.id} size="small">
<Row align="middle" justify="space-between">
<Col>
<Space direction="vertical" size={0}>
<Space>
<Text strong>{account.bankName}</Text>
<Tag color="blue">{account.accountName}</Tag>
{account.isActive && <Tag color="green">Aktiv</Tag>}
</Space>
<Text type="secondary">{account.accountNumber}</Text>
<Text type="secondary">
Bogføringskonto: {account.ledgerAccount}
</Text>
</Space>
</Col>
<Col>
<Space>
<Button size="small">Rediger</Button>
<Button size="small" danger>
Fjern
</Button>
</Space>
</Col>
</Row>
</Card>
))}
<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>
),
@ -345,6 +331,7 @@ export default function Settings() {
),
children: (
<Card>
<DemoDataDisclaimer message="Brugerstyring er under udvikling" />
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
@ -356,50 +343,15 @@ export default function Settings() {
<Title level={5} style={{ margin: 0 }}>
Brugere med adgang
</Title>
<Button type="primary">Inviter bruger</Button>
<Button type="primary" disabled>Inviter bruger</Button>
</div>
<Divider />
{/* Mock users */}
{[
{
id: '1',
name: 'Admin Bruger',
email: 'admin@example.com',
role: 'Administrator',
lastLogin: '2025-01-17',
},
{
id: '2',
name: 'Bogholder',
email: 'bogholder@example.com',
role: 'Bogholder',
lastLogin: '2025-01-16',
},
].map((user) => (
<Card key={user.id} size="small">
<Row align="middle" justify="space-between">
<Col>
<Space direction="vertical" size={0}>
<Text strong>{user.name}</Text>
<Text type="secondary">{user.email}</Text>
</Space>
</Col>
<Col>
<Space>
<Tag color={user.role === 'Administrator' ? 'gold' : 'blue'}>
{user.role}
</Tag>
<Text type="secondary">
Sidste login: {user.lastLogin}
</Text>
<Button size="small">Rediger</Button>
</Space>
</Col>
</Row>
</Card>
))}
<Text type="secondary">
Brugere med adgang til denne virksomhed vil blive vist her,
når funktionen er implementeret.
</Text>
</Space>
</Card>
),

View file

@ -1,18 +1,18 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Company, CompanyRole } from '@/types/accounting';
import type { CompanyRole, CompanyWithRole } from '@/types/accounting';
interface CompanyState {
// Current active company
activeCompany: Company | null;
// List of available companies
companies: Company[];
// Current active company (includes role from myCompanies query)
activeCompany: CompanyWithRole | null;
// List of available companies (includes role from myCompanies query)
companies: CompanyWithRole[];
// Loading state
isLoading: boolean;
// Actions
setActiveCompany: (company: Company) => void;
setCompanies: (companies: Company[]) => void;
setActiveCompany: (company: CompanyWithRole) => void;
setCompanies: (companies: CompanyWithRole[]) => void;
setLoading: (loading: boolean) => void;
clearActiveCompany: () => void;
}
@ -53,11 +53,11 @@ export const useCompanies = () =>
useCompanyStore((state) => state.companies);
// Get the current user's role for the active company
// Returns 'owner' as default for now - in production this would come from the server
// Returns the role from the myCompanies query data stored on the active company
export const useActiveCompanyRole = (): CompanyRole => {
// Placeholder: In a real implementation, this would check the user's role
// for the currently active company from the server/auth context
return 'owner';
const activeCompany = useCompanyStore((state) => state.activeCompany);
// Return the actual role from the CompanyWithRole data, default to 'viewer' if not set
return activeCompany?.role ?? 'viewer';
};
// Helper functions for user roles
@ -88,9 +88,8 @@ export function getRoleColor(role: CompanyRole): string {
}
// Hook to check if current user can administer the company
// This is a placeholder - in a real app, this would check the user's role
// Checks if the user has Owner role for the active company
export function useCanAdmin(): boolean {
// For now, return true to allow all users to manage access
// In production, this should check the current user's role
return true;
const role = useActiveCompanyRole();
return role === 'owner';
}

View file

@ -23,6 +23,7 @@ export interface Account {
type: AccountType;
parentId?: string;
isActive: boolean;
isSystemAccount?: boolean;
description?: string;
vatCode?: string;
balance: number;

View file

@ -22,8 +22,8 @@ export type VATCode =
* VAT code type classification
*/
export type VATCodeType =
| 'output' // Udgaaende moms (salg)
| 'input' // Indgaaende moms (koeb)
| 'output' // Udgående moms (salg)
| 'input' // Indgående moms (køb)
| 'reverse_charge' // Omvendt betalingspligt
| 'exempt' // Momsfritaget
| 'none'; // Ingen moms

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}