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:
parent
effb06fc44
commit
8e05171b66
49 changed files with 1537 additions and 1192 deletions
|
|
@ -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-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-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
|
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
|
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
|
||||||
|
|
@ -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-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
|
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
|
||||||
{"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-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-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-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-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-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
||||||
|
|
|
||||||
|
|
@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests
|
||||||
public void MarkPosted_WhenActive_EmitsPostedEvent()
|
public void MarkPosted_WhenActive_EmitsPostedEvent()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var aggregate = CreateActiveDraft();
|
var aggregate = CreateActiveDraftWithLines();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
aggregate.MarkPosted("transaction-123", "user@example.com");
|
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var uncommittedEvents = aggregate.UncommittedEvents.ToList();
|
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.Should().NotBeNull();
|
||||||
postedEvent!.TransactionId.Should().Be("transaction-123");
|
postedEvent!.TransactionId.Should().Be("transaction-123");
|
||||||
postedEvent.PostedBy.Should().Be("user@example.com");
|
postedEvent.PostedBy.Should().Be("user@example.com");
|
||||||
|
postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
|
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var aggregate = CreateActiveDraft();
|
var aggregate = CreateActiveDraftWithLines();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => aggregate.MarkPosted(" ", "user@example.com");
|
var act = () => aggregate.MarkPosted(" ", "user@example.com");
|
||||||
|
|
@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests
|
||||||
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
|
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var aggregate = CreateActiveDraft();
|
var aggregate = CreateActiveDraftWithLines();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => aggregate.MarkPosted("transaction-123", "");
|
var act = () => aggregate.MarkPosted("transaction-123", "");
|
||||||
|
|
@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JournalEntryDraftAggregate CreatePostedDraft()
|
private static JournalEntryDraftAggregate CreateActiveDraftWithLines()
|
||||||
{
|
{
|
||||||
var aggregate = CreateActiveDraft();
|
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");
|
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ namespace Books.Api.Authentication;
|
||||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||||
{
|
{
|
||||||
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
|
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(
|
public class ApiKeyAuthenticationHandler(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,35 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
using Books.Api.Domain.Accounts;
|
using Books.Api.Domain.Accounts;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using EventFlow.Commands;
|
using EventFlow.Commands;
|
||||||
|
|
||||||
namespace Books.Api.Commands.Accounts;
|
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,
|
AccountAggregate aggregate,
|
||||||
CreateAccountCommand command,
|
CreateAccountCommand command,
|
||||||
CancellationToken cancellationToken)
|
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(
|
aggregate.Create(
|
||||||
command.CompanyId,
|
command.CompanyId,
|
||||||
command.AccountNumber,
|
command.AccountNumber,
|
||||||
|
|
@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, Acco
|
||||||
command.VatCodeId,
|
command.VatCodeId,
|
||||||
command.IsSystemAccount,
|
command.IsSystemAccount,
|
||||||
command.StandardAccountNumber);
|
command.StandardAccountNumber);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,64 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
using Books.Api.Domain.FiscalYears;
|
using Books.Api.Domain.FiscalYears;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using EventFlow.Commands;
|
using EventFlow.Commands;
|
||||||
|
|
||||||
namespace Books.Api.Commands.FiscalYears;
|
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,
|
FiscalYearAggregate aggregate,
|
||||||
CreateFiscalYearCommand command,
|
CreateFiscalYearCommand command,
|
||||||
CancellationToken cancellationToken)
|
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(
|
aggregate.Create(
|
||||||
command.CompanyId,
|
command.CompanyId,
|
||||||
command.Name,
|
command.Name,
|
||||||
|
|
@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate
|
||||||
command.EndDate,
|
command.EndDate,
|
||||||
command.IsFirstFiscalYear,
|
command.IsFirstFiscalYear,
|
||||||
command.IsReorganization);
|
command.IsReorganization);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,39 @@
|
||||||
using Books.Api.Domain.Invoices;
|
using Books.Api.Domain.Invoices;
|
||||||
|
using Books.Api.Invoicing.Services;
|
||||||
using EventFlow.Commands;
|
using EventFlow.Commands;
|
||||||
|
|
||||||
namespace Books.Api.Commands.Invoices;
|
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>
|
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||||
{
|
{
|
||||||
public override Task ExecuteAsync(
|
public override async Task ExecuteAsync(
|
||||||
InvoiceAggregate aggregate,
|
InvoiceAggregate aggregate,
|
||||||
CreateInvoiceCommand command,
|
CreateInvoiceCommand command,
|
||||||
CancellationToken cancellationToken)
|
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(
|
aggregate.Create(
|
||||||
command.CompanyId,
|
command.CompanyId,
|
||||||
command.FiscalYearId,
|
command.FiscalYearId,
|
||||||
command.CustomerId,
|
command.CustomerId,
|
||||||
command.CustomerName,
|
command.CustomerName,
|
||||||
command.CustomerNumber,
|
command.CustomerNumber,
|
||||||
command.InvoiceNumber,
|
invoiceNumber,
|
||||||
command.InvoiceDate,
|
command.InvoiceDate,
|
||||||
command.DueDate,
|
command.DueDate,
|
||||||
command.PaymentTermsDays,
|
command.PaymentTermsDays,
|
||||||
|
|
@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler
|
||||||
command.Notes,
|
command.Notes,
|
||||||
command.Reference,
|
command.Reference,
|
||||||
command.CreatedBy);
|
command.CreatedBy);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
using Books.Api.Domain.JournalEntryDrafts;
|
using Books.Api.Domain.JournalEntryDrafts;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using EventFlow.Commands;
|
using EventFlow.Commands;
|
||||||
|
|
||||||
namespace Books.Api.Commands.JournalEntryDrafts;
|
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>
|
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
|
||||||
{
|
{
|
||||||
public override Task ExecuteAsync(
|
public override async Task ExecuteAsync(
|
||||||
JournalEntryDraftAggregate aggregate,
|
JournalEntryDraftAggregate aggregate,
|
||||||
MarkJournalEntryDraftPostedCommand command,
|
MarkJournalEntryDraftPostedCommand command,
|
||||||
CancellationToken cancellationToken)
|
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(
|
aggregate.MarkPosted(
|
||||||
command.TransactionId,
|
command.TransactionId,
|
||||||
command.PostedBy);
|
command.PostedBy);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ public class BankingController : ControllerBase
|
||||||
|
|
||||||
try
|
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)
|
// State contains the connection ID (set during StartBankConnection)
|
||||||
var connectionId = state;
|
var connectionId = state;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
|
|
||||||
namespace Books.Api.Domain.Invoices;
|
namespace Books.Api.Domain.Invoices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -65,15 +67,19 @@ public sealed record InvoiceLine
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the VAT rate for this line based on VatCode.
|
/// Gets the VAT rate for this line based on VatCode.
|
||||||
|
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private decimal GetVatRate() => VatCode switch
|
private decimal GetVatRate()
|
||||||
{
|
{
|
||||||
"U25" or "I25" => 0.25m, // Danish standard 25%
|
if (!VatCodes.IsValid(VatCode))
|
||||||
"UEU" or "IEU" => 0m, // EU sales (reverse charge)
|
{
|
||||||
"UEXP" or "IEXP" => 0m, // Export (no VAT)
|
throw new InvalidOperationException(
|
||||||
"INGEN" => 0m, // No VAT
|
$"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " +
|
||||||
_ => 0.25m // Default to Danish standard
|
$"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN");
|
||||||
};
|
}
|
||||||
|
|
||||||
|
return VatCodes.GetRate(VatCode);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates an InvoiceLine with validation.
|
/// Creates an InvoiceLine with validation.
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,20 @@ using EventFlow.Aggregates;
|
||||||
|
|
||||||
namespace Books.Api.Domain.JournalEntryDrafts.Events;
|
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(
|
public class JournalEntryDraftPostedEvent(
|
||||||
string transactionId,
|
string transactionId,
|
||||||
string postedBy) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
string postedBy,
|
||||||
|
DateTimeOffset postedAt) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
||||||
{
|
{
|
||||||
public string TransactionId { get; } = transactionId;
|
public string TransactionId { get; } = transactionId;
|
||||||
public string PostedBy { get; } = postedBy;
|
public string PostedBy { get; } = postedBy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exact timestamp when the draft was posted to the ledger.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset PostedAt { get; } = postedAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
IEmit<JournalEntryDraftPostedEvent>,
|
IEmit<JournalEntryDraftPostedEvent>,
|
||||||
IEmit<JournalEntryDraftDiscardedEvent>
|
IEmit<JournalEntryDraftDiscardedEvent>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tolerance for floating-point rounding when comparing debit/credit totals.
|
||||||
|
/// </summary>
|
||||||
|
private const decimal BalanceTolerance = 0.01m;
|
||||||
|
|
||||||
private bool _isCreated;
|
private bool _isCreated;
|
||||||
private DraftStatus _status = DraftStatus.Active;
|
private DraftStatus _status = DraftStatus.Active;
|
||||||
private string _companyId = string.Empty;
|
private string _companyId = string.Empty;
|
||||||
private string _voucherNumber = string.Empty;
|
private string _voucherNumber = string.Empty;
|
||||||
|
private string? _fiscalYearId;
|
||||||
|
private List<DraftLine> _lines = [];
|
||||||
|
|
||||||
public string CompanyId => _companyId;
|
public string CompanyId => _companyId;
|
||||||
public string VoucherNumber => _voucherNumber;
|
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
|
#region Apply Methods
|
||||||
|
|
||||||
public void Apply(JournalEntryDraftCreatedEvent e)
|
public void Apply(JournalEntryDraftCreatedEvent e)
|
||||||
|
|
@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
|
|
||||||
public void Apply(JournalEntryDraftUpdatedEvent e)
|
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)
|
public void Apply(JournalEntryDraftPostedEvent e)
|
||||||
|
|
@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a journal entry draft (auto-save).
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="name">Draft name</param>
|
/// <param name="name">Draft name</param>
|
||||||
/// <param name="documentDate">Bilagsdato - the date of the transaction/document (e.g., invoice date)</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(
|
Emit(new JournalEntryDraftUpdatedEvent(
|
||||||
name?.Trim(),
|
name?.Trim(),
|
||||||
documentDate,
|
documentDate,
|
||||||
|
|
@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
attachmentIds));
|
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)
|
public void MarkPosted(string transactionId, string postedBy)
|
||||||
{
|
{
|
||||||
EnsureCanModify();
|
EnsureCanModify();
|
||||||
|
|
@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
"Posted by is required",
|
"Posted by is required",
|
||||||
"Bogført af er påkrævet");
|
"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)
|
public void Discard(string discardedBy)
|
||||||
|
|
@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
"Kassekladden er blevet kasseret");
|
"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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel,
|
||||||
public string AttachmentIds { get; set; } = "[]";
|
public string AttachmentIds { get; set; } = "[]";
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public string? TransactionId { get; set; }
|
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 string CreatedBy { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full AI extraction data stored as JSON string.
|
/// Full AI extraction data stored as JSON string.
|
||||||
|
|
@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel,
|
||||||
|
|
||||||
Status = "posted";
|
Status = "posted";
|
||||||
TransactionId = domainEvent.AggregateEvent.TransactionId;
|
TransactionId = domainEvent.AggregateEvent.TransactionId;
|
||||||
|
PostedAt = domainEvent.AggregateEvent.PostedAt;
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto
|
||||||
public string AttachmentIds { get; set; } = "[]";
|
public string AttachmentIds { get; set; } = "[]";
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public string? TransactionId { get; set; }
|
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 string CreatedBy { get; set; } = string.Empty;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal
|
||||||
attachment_ids AS AttachmentIds,
|
attachment_ids AS AttachmentIds,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
transaction_id AS TransactionId,
|
transaction_id AS TransactionId,
|
||||||
|
posted_at AS PostedAt,
|
||||||
created_by AS CreatedBy,
|
created_by AS CreatedBy,
|
||||||
create_time AS CreatedAt,
|
create_time AS CreatedAt,
|
||||||
updated_time AS UpdatedAt
|
updated_time AS UpdatedAt,
|
||||||
|
extraction_data AS ExtractionData
|
||||||
""";
|
""";
|
||||||
|
|
||||||
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(
|
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Books.Api.Authorization;
|
||||||
using Books.Api.Commands.Companies;
|
using Books.Api.Commands.Companies;
|
||||||
|
using Books.Api.Commands.UserAccess;
|
||||||
using Books.Api.Domain.Companies;
|
using Books.Api.Domain.Companies;
|
||||||
|
using Books.Api.Domain.UserAccess;
|
||||||
using Books.Api.EventFlow.Repositories;
|
using Books.Api.EventFlow.Repositories;
|
||||||
using Books.Api.GraphQL.InputTypes;
|
using Books.Api.GraphQL.InputTypes;
|
||||||
using Books.Api.GraphQL.Types;
|
using Books.Api.GraphQL.Types;
|
||||||
|
|
@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType
|
||||||
|
|
||||||
await commandBus.PublishAsync(command, ctx.CancellationToken);
|
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 the created company (eventually consistent)
|
||||||
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
||||||
});
|
});
|
||||||
|
|
@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType
|
||||||
.ResolveAsync(async ctx =>
|
.ResolveAsync(async ctx =>
|
||||||
{
|
{
|
||||||
var id = ctx.GetArgument<string>("id");
|
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 input = ctx.GetArgument<UpdateCompanyInput>("input");
|
||||||
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
||||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using Books.Api.Authorization;
|
||||||
using Books.Api.Domain.Companies;
|
using Books.Api.Domain.Companies;
|
||||||
|
using Books.Api.Domain.UserAccess;
|
||||||
using Books.Api.EventFlow.Repositories;
|
using Books.Api.EventFlow.Repositories;
|
||||||
using Books.Api.GraphQL.Types;
|
using Books.Api.GraphQL.Types;
|
||||||
using GraphQL;
|
using GraphQL;
|
||||||
|
|
@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType
|
||||||
|
|
||||||
// companies: [CompanyType]
|
// companies: [CompanyType]
|
||||||
Field<ListGraphType<CompanyType>>("companies")
|
Field<ListGraphType<CompanyType>>("companies")
|
||||||
.Description("Get all companies")
|
.Description("Get all companies accessible to the current user")
|
||||||
.ResolveAsync(async ctx =>
|
.ResolveAsync(async ctx =>
|
||||||
{
|
{
|
||||||
|
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
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
|
// company(id: ID!): CompanyType
|
||||||
|
|
@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType
|
||||||
.ResolveAsync(async ctx =>
|
.ResolveAsync(async ctx =>
|
||||||
{
|
{
|
||||||
var id = ctx.GetArgument<string>("id");
|
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 repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||||
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
|
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
|
||||||
return companies.FirstOrDefault();
|
return companies.FirstOrDefault();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Books.Api;
|
using Books.Api;
|
||||||
|
using Books.Api.Authorization;
|
||||||
using Books.Api.GraphQL;
|
using Books.Api.GraphQL;
|
||||||
using GraphQL;
|
using GraphQL;
|
||||||
using GraphQL.Server.Ui.Altair;
|
using GraphQL.Server.Ui.Altair;
|
||||||
|
|
@ -30,6 +31,25 @@ app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
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)
|
// Map controllers (for AuthController)
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ public class VatReportService(
|
||||||
ILogger<VatReportService> logger) : IVatReportService
|
ILogger<VatReportService> logger) : IVatReportService
|
||||||
{
|
{
|
||||||
// Standard Danish VAT account numbers
|
// 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 InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
|
||||||
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
||||||
|
|
||||||
|
|
@ -133,13 +135,28 @@ public class VatReportService(
|
||||||
report.TotalInputVat = report.BoxB;
|
report.TotalInputVat = report.BoxB;
|
||||||
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
||||||
|
|
||||||
// Basis amounts require tracking of original transaction amounts
|
// Basis1 (Felt 1): Net domestic turnover with VAT
|
||||||
// For now, calculate from VAT amounts assuming 25% rate
|
// 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)
|
if (report.BoxA > 0)
|
||||||
{
|
{
|
||||||
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
|
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(
|
logger.LogInformation(
|
||||||
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
||||||
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,15 @@ public class SaftExportService(
|
||||||
journals);
|
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)
|
private static string MapAccountType(string accountType)
|
||||||
{
|
{
|
||||||
return accountType.ToLowerInvariant() switch
|
return accountType.ToLowerInvariant() switch
|
||||||
|
|
@ -343,7 +352,10 @@ public class SaftExportService(
|
||||||
"cogs" => "Expense",
|
"cogs" => "Expense",
|
||||||
"expense" => "Expense",
|
"expense" => "Expense",
|
||||||
"personnel" => "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",
|
"extraordinary" => "Expense",
|
||||||
_ => "Asset"
|
_ => "Asset"
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Books.Api.Authentication;
|
using Books.Api.Authentication;
|
||||||
|
using Books.Api.Authorization;
|
||||||
using Books.Api.EventFlow.Extensions;
|
using Books.Api.EventFlow.Extensions;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using Books.Api.EventFlow.Infrastructure;
|
using Books.Api.EventFlow.Infrastructure;
|
||||||
using Books.Api.GraphQL;
|
using Books.Api.GraphQL;
|
||||||
using Books.Api.Infrastructure;
|
using Books.Api.Infrastructure;
|
||||||
|
|
@ -67,6 +69,13 @@ public static class Startup
|
||||||
// Read model repositories
|
// Read model repositories
|
||||||
services.AddRepositories();
|
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
|
// Logging decorators
|
||||||
services.DecorateAsyncEventHandlersWithLogging();
|
services.DecorateAsyncEventHandlersWithLogging();
|
||||||
|
|
||||||
|
|
@ -76,7 +85,7 @@ public static class Startup
|
||||||
.AddSystemTextJson()
|
.AddSystemTextJson()
|
||||||
.AddDataLoader()
|
.AddDataLoader()
|
||||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
|
||||||
|
|
||||||
// Memory cache for API key caching
|
// Memory cache for API key caching
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { GraphQLClient } from 'graphql-request';
|
import { GraphQLClient } from 'graphql-request';
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
|
|
||||||
// GraphQL endpoint - configure based on environment
|
// GraphQL endpoint - configure based on environment
|
||||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||||
|
|
@ -26,8 +27,8 @@ export const queryClient = new QueryClient({
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
// Retry mutations once
|
// Never retry mutations - non-idempotent operations could create duplicates
|
||||||
retry: 1,
|
retry: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -38,7 +39,16 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
|
||||||
variables?: TVariables
|
variables?: TVariables
|
||||||
): Promise<TData> {
|
): Promise<TData> {
|
||||||
try {
|
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;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error for debugging
|
// Log error for debugging
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export async function processDocument(
|
||||||
throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)');
|
throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)');
|
||||||
}
|
}
|
||||||
if (response.status === 503) {
|
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}`);
|
throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account {
|
||||||
description: acc.description,
|
description: acc.description,
|
||||||
vatCode: acc.vatCodeId,
|
vatCode: acc.vatCodeId,
|
||||||
isActive: acc.isActive,
|
isActive: acc.isActive,
|
||||||
|
isSystemAccount: acc.isSystemAccount,
|
||||||
balance: 0, // Not returned from backend yet
|
balance: 0, // Not returned from backend yet
|
||||||
createdAt: acc.createdAt,
|
createdAt: acc.createdAt,
|
||||||
updatedAt: acc.updatedAt,
|
updatedAt: acc.updatedAt,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) {
|
||||||
}
|
}
|
||||||
// Note: Users with existing companies CAN access the wizard to create more
|
// 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)
|
// Reset navigation ref when companies change (user created a company)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export function DocumentUploadModal({
|
||||||
message.success('Bogfoert!');
|
message.success('Bogfoert!');
|
||||||
onConfirm();
|
onConfirm();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error('Kunne ikke bogfoere. Proev igen.');
|
message.error('Kunne ikke bogføre. Prøv igen.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsPosting(false);
|
setIsPosting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +270,7 @@ export function DocumentUploadModal({
|
||||||
Annuller
|
Annuller
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="draft" onClick={handleSaveAsDraft}>
|
<Button key="draft" onClick={handleSaveAsDraft}>
|
||||||
Tilfoej til kladde
|
Tilføj til kladde
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
key="post"
|
key="post"
|
||||||
|
|
@ -451,7 +451,7 @@ function ExtractedInfoSection({
|
||||||
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
|
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Beloeb',
|
title: 'Beløb',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
|
|
@ -526,7 +526,7 @@ function ExtractedInfoSection({
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
{extraction.amountExVat != null && (
|
{extraction.amountExVat != null && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<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} />
|
<AmountText amount={extraction.amountExVat} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -551,7 +551,7 @@ function ExtractedInfoSection({
|
||||||
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
|
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text strong>Beloeb inkl. moms</Text>
|
<Text strong>Beløb inkl. moms</Text>
|
||||||
<Text
|
<Text
|
||||||
strong
|
strong
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
||||||
open: {
|
open: {
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: <CheckCircleOutlined />,
|
icon: <CheckCircleOutlined />,
|
||||||
label: 'Aben',
|
label: 'Åben',
|
||||||
},
|
},
|
||||||
closed: {
|
closed: {
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
|
|
@ -40,7 +40,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
||||||
locked: {
|
locked: {
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: <LockOutlined />,
|
icon: <LockOutlined />,
|
||||||
label: 'Last',
|
label: 'Låst',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,16 +84,19 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
||||||
if (fiscalYearsData.length > 0) {
|
if (fiscalYearsData.length > 0) {
|
||||||
setFiscalYears(fiscalYearsData);
|
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
|
// Validate currentFiscalYear belongs to this company's data
|
||||||
const isValid = currentFiscalYear &&
|
const isValid = current &&
|
||||||
fiscalYearsData.some(fy => fy.id === currentFiscalYear.id);
|
fiscalYearsData.some(fy => fy.id === current.id);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
const openYear = fiscalYearsData.find(y => y.status === 'open');
|
const openYear = fiscalYearsData.find(y => y.status === 'open');
|
||||||
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
|
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fiscalYearsData, currentFiscalYear, setFiscalYears, setCurrentFiscalYear]);
|
}, [fiscalYearsData, setFiscalYears, setCurrentFiscalYear]);
|
||||||
|
|
||||||
const handleFiscalYearChange = (yearId: string) => {
|
const handleFiscalYearChange = (yearId: string) => {
|
||||||
const year = fiscalYears.find((y) => y.id === yearId);
|
const year = fiscalYears.find((y) => y.id === yearId);
|
||||||
|
|
@ -146,7 +149,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
||||||
style={{ minWidth: 200 }}
|
style={{ minWidth: 200 }}
|
||||||
optionLabelProp="label"
|
optionLabelProp="label"
|
||||||
popupMatchSelectWidth={false}
|
popupMatchSelectWidth={false}
|
||||||
dropdownRender={(menu) => (
|
popupRender={(menu) => (
|
||||||
<>
|
<>
|
||||||
{menu}
|
{menu}
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
@ -157,7 +160,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
||||||
onClick={handleCreateNew}
|
onClick={handleCreateNew}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
Opret nyt regnskabsar
|
Opret nyt regnskabsår
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -172,7 +175,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
||||||
)}
|
)}
|
||||||
options={sortedYears.map((year) => ({
|
options={sortedYears.map((year) => ({
|
||||||
value: year.id,
|
value: year.id,
|
||||||
label: `Regnskabsar ${year.name}`,
|
label: `Regnskabsår ${year.name}`,
|
||||||
year,
|
year,
|
||||||
}))}
|
}))}
|
||||||
optionRender={(option) => {
|
optionRender={(option) => {
|
||||||
|
|
|
||||||
|
|
@ -125,29 +125,25 @@ export default function Header({ isMobile = false }: HeaderProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Help */}
|
{/* Help */}
|
||||||
|
<Tooltip title="Hjælp">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<QuestionCircleOutlined />}
|
icon={<QuestionCircleOutlined />}
|
||||||
|
onClick={() => window.open('https://help.books.dk', '_blank')}
|
||||||
aria-label="Hjælp"
|
aria-label="Hjælp"
|
||||||
title="Hjælp"
|
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
|
<Tooltip title="Notifikationer">
|
||||||
|
<Badge count={0} size="small">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<BellOutlined />}
|
icon={<BellOutlined />}
|
||||||
|
onClick={() => navigate('/indstillinger')}
|
||||||
aria-label="Notifikationer"
|
aria-label="Notifikationer"
|
||||||
title="Notifikationer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<Tooltip title="Log ud">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<LogoutOutlined />}
|
|
||||||
onClick={logout}
|
|
||||||
aria-label="Log ud"
|
|
||||||
/>
|
/>
|
||||||
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Layout, Menu } from 'antd';
|
import { Layout, Menu } from 'antd';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
|
|
@ -42,7 +43,7 @@ function getItem(
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
getItem('Dashboard', '/', <DashboardOutlined />),
|
getItem('Dashboard', '/', <DashboardOutlined />),
|
||||||
|
|
||||||
getItem('Bogfoering', 'accounting', <BookOutlined />, [
|
getItem('Bogføring', 'accounting', <BookOutlined />, [
|
||||||
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
|
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
|
||||||
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
|
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
|
||||||
]),
|
]),
|
||||||
|
|
@ -61,7 +62,7 @@ const menuItems: MenuItem[] = [
|
||||||
|
|
||||||
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
|
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
|
||||||
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
|
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
|
||||||
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
|
getItem('Lønforståelse', '/loenforstaelse', <TeamOutlined />),
|
||||||
getItem('Eksport', '/eksport', <ExportOutlined />),
|
getItem('Eksport', '/eksport', <ExportOutlined />),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
|
@ -99,6 +100,17 @@ interface SidebarMenuProps {
|
||||||
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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 }) => {
|
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
if (key.startsWith('/')) {
|
if (key.startsWith('/')) {
|
||||||
|
|
@ -107,6 +119,10 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (keys: string[]) => {
|
||||||
|
setOpenKeys(keys);
|
||||||
|
};
|
||||||
|
|
||||||
const selectedKeys = [location.pathname];
|
const selectedKeys = [location.pathname];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -114,7 +130,8 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
defaultOpenKeys={getOpenKeys(location.pathname)}
|
openKeys={openKeys}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
style={{ borderRight: 0 }}
|
style={{ borderRight: 0 }}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import {
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
import type { FiscalYear } from '@/types/periods';
|
import type { FiscalYear } from '@/types/periods';
|
||||||
import type { Account, Transaction } from '@/types/accounting';
|
import type { Account, Transaction } from '@/types/accounting';
|
||||||
|
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
const { Text, Title, Paragraph } = Typography;
|
const { Text, Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
@ -82,6 +84,8 @@ export default function CloseFiscalYearWizard({
|
||||||
lockPeriod,
|
lockPeriod,
|
||||||
} = usePeriodStore();
|
} = usePeriodStore();
|
||||||
|
|
||||||
|
const closeFiscalYearMutation = useCloseFiscalYear();
|
||||||
|
|
||||||
// Reset wizard when opened
|
// Reset wizard when opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -160,27 +164,41 @@ export default function CloseFiscalYearWizard({
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
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) {
|
if (closeOpenPeriods) {
|
||||||
for (const period of openPeriodsInYear) {
|
for (const period of openPeriodsInYear) {
|
||||||
closePeriod(period.id, 'system');
|
closePeriod(period.id, 'system');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lock all periods in the year
|
// 2. Lock all periods in the year (local store)
|
||||||
for (const period of yearPeriods) {
|
for (const period of yearPeriods) {
|
||||||
lockPeriod(period.id, 'system');
|
lockPeriod(period.id, 'system');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 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');
|
closeFiscalYear(fiscalYear.id, 'system');
|
||||||
lockFiscalYear(fiscalYear.id, 'system');
|
lockFiscalYear(fiscalYear.id, 'system');
|
||||||
|
|
||||||
// 4. Move to complete step
|
// 5. Move to complete step
|
||||||
setCurrentStep('complete');
|
setCurrentStep('complete');
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl ved arsafslutning: ${error.message}`);
|
||||||
|
}
|
||||||
console.error('Failed to close fiscal year:', error);
|
console.error('Failed to close fiscal year:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import {
|
||||||
} from '@/lib/fiscalYear';
|
} from '@/lib/fiscalYear';
|
||||||
import { generateAccountingPeriods } from '@/lib/periods';
|
import { generateAccountingPeriods } from '@/lib/periods';
|
||||||
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
|
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
|
||||||
|
import { useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
@ -58,6 +60,7 @@ export default function CreateFiscalYearModal({
|
||||||
|
|
||||||
const { activeCompany } = useCompanyStore();
|
const { activeCompany } = useCompanyStore();
|
||||||
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
|
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
|
||||||
|
const createFiscalYearMutation = useCreateFiscalYear();
|
||||||
|
|
||||||
// Calculate suggested fiscal year boundaries
|
// Calculate suggested fiscal year boundaries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -114,20 +117,18 @@ export default function CreateFiscalYearModal({
|
||||||
const startDate = values.dateRange[0].format('YYYY-MM-DD');
|
const startDate = values.dateRange[0].format('YYYY-MM-DD');
|
||||||
const endDate = values.dateRange[1].format('YYYY-MM-DD');
|
const endDate = values.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
|
||||||
// Create fiscal year object
|
// Call backend mutation - let the backend generate the ID
|
||||||
const newFiscalYear: FiscalYear = {
|
const newFiscalYear = await createFiscalYearMutation.mutateAsync({
|
||||||
id: `fy-${values.name}-${Date.now()}`,
|
|
||||||
companyId: activeCompany.id,
|
companyId: activeCompany.id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
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 generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const newPeriods = generatedPeriods.map((p, idx) => ({
|
const newPeriods = generatedPeriods.map((p, idx) => ({
|
||||||
|
|
@ -136,9 +137,6 @@ export default function CreateFiscalYearModal({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add to store
|
|
||||||
addFiscalYear(newFiscalYear);
|
|
||||||
setPeriods([...periods, ...newPeriods]);
|
setPeriods([...periods, ...newPeriods]);
|
||||||
|
|
||||||
// Set as current if this is the first or most recent
|
// Set as current if this is the first or most recent
|
||||||
|
|
@ -153,6 +151,9 @@ export default function CreateFiscalYearModal({
|
||||||
onSuccess?.(newFiscalYear);
|
onSuccess?.(newFiscalYear);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl ved oprettelse: ${error.message}`);
|
||||||
|
}
|
||||||
console.error('Failed to create fiscal year:', error);
|
console.error('Failed to create fiscal year:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -180,7 +181,7 @@ export default function CreateFiscalYearModal({
|
||||||
loading: isSubmitting,
|
loading: isSubmitting,
|
||||||
}}
|
}}
|
||||||
width={520}
|
width={520}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{!activeCompany && (
|
{!activeCompany && (
|
||||||
<Alert
|
<Alert
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
||||||
lines.push({
|
lines.push({
|
||||||
accountId: `vat-input-${vatCode}`,
|
accountId: `vat-input-${vatCode}`,
|
||||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||||
accountName: 'Indgaaende moms',
|
accountName: 'Indgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: vatAmount,
|
debit: vatAmount,
|
||||||
credit: 0,
|
credit: 0,
|
||||||
|
|
@ -347,7 +347,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
||||||
lines.push({
|
lines.push({
|
||||||
accountId: 'vat-output',
|
accountId: 'vat-output',
|
||||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||||
accountName: 'Udgaaende moms',
|
accountName: 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: vatAmount,
|
credit: vatAmount,
|
||||||
|
|
@ -431,7 +431,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
||||||
generatedLines.push({
|
generatedLines.push({
|
||||||
accountId: `vat-input-${splitLine.vatCode}`,
|
accountId: `vat-input-${splitLine.vatCode}`,
|
||||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||||
accountName: 'Indgaaende moms',
|
accountName: 'Indgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: lineVat,
|
debit: lineVat,
|
||||||
credit: 0,
|
credit: 0,
|
||||||
|
|
@ -506,7 +506,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
||||||
generatedLines.push({
|
generatedLines.push({
|
||||||
accountId: 'vat-output',
|
accountId: 'vat-output',
|
||||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||||
accountName: 'Udgaaende moms',
|
accountName: 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: lineVat,
|
credit: lineVat,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import type { VATPeriodicitet } from '@/types/periods';
|
||||||
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
S25: {
|
S25: {
|
||||||
code: 'S25',
|
code: 'S25',
|
||||||
nameDanish: 'Udgaaende moms 25%',
|
nameDanish: 'Udgående moms 25%',
|
||||||
nameEnglish: 'Output VAT 25%',
|
nameEnglish: 'Output VAT 25%',
|
||||||
rate: 0.25,
|
rate: 0.25,
|
||||||
type: 'output',
|
type: 'output',
|
||||||
|
|
@ -30,7 +30,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
},
|
},
|
||||||
K25: {
|
K25: {
|
||||||
code: 'K25',
|
code: 'K25',
|
||||||
nameDanish: 'Indgaaende moms 25%',
|
nameDanish: 'Indgående moms 25%',
|
||||||
nameEnglish: 'Input VAT 25%',
|
nameEnglish: 'Input VAT 25%',
|
||||||
rate: 0.25,
|
rate: 0.25,
|
||||||
type: 'input',
|
type: 'input',
|
||||||
|
|
@ -230,8 +230,8 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
||||||
* Default VAT accounts for automatic double-entry
|
* Default VAT accounts for automatic double-entry
|
||||||
*/
|
*/
|
||||||
export const VAT_ACCOUNTS = {
|
export const VAT_ACCOUNTS = {
|
||||||
inputVAT: '5610', // Indgaaende moms (fradrag)
|
inputVAT: '5610', // Indgående moms (fradrag)
|
||||||
outputVAT: '5710', // Udgaaende moms (skyld)
|
outputVAT: '5710', // Udgående moms (skyld)
|
||||||
euVAT: '5620', // EU-moms (erhvervelsesmoms)
|
euVAT: '5620', // EU-moms (erhvervelsesmoms)
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,13 @@ import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useUser } from '@/stores/authStore';
|
import { useUser } from '@/stores/authStore';
|
||||||
|
import { useCanAdmin } from '@/stores/companyStore';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { graphqlClient } from '@/api/client';
|
import { graphqlClient } from '@/api/client';
|
||||||
import { gql } from 'graphql-request';
|
import { gql } from 'graphql-request';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
// Admin email that has access
|
|
||||||
const ADMIN_EMAIL = 'nhh@softwarehuset.com';
|
|
||||||
|
|
||||||
// Derive backend base URL from GraphQL endpoint
|
// Derive backend base URL from GraphQL endpoint
|
||||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
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 [form] = Form.useForm();
|
||||||
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
// Check if user is admin
|
// Check if user has Owner role for the active company
|
||||||
const isAdmin = user?.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase();
|
const isAdmin = useCanAdmin();
|
||||||
|
|
||||||
// Fetch available read model types
|
// Fetch available read model types
|
||||||
const { data: readModelTypes, isLoading: typesLoading } = useQuery({
|
const { data: readModelTypes, isLoading: typesLoading } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries
|
||||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import type { BankTransaction } from '@/types/accounting';
|
import type { BankTransaction } from '@/types/accounting';
|
||||||
|
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
// Type for ledger entries (API not implemented yet)
|
// Type for ledger entries (API not implemented yet)
|
||||||
|
|
@ -160,7 +162,7 @@ export default function Bankafstemning() {
|
||||||
ledgerTransactionId: ledgerEntry.id,
|
ledgerTransactionId: ledgerEntry.id,
|
||||||
matchType: 'existing',
|
matchType: 'existing',
|
||||||
});
|
});
|
||||||
message.success('Match tilfojet');
|
message.success('Match tilføjet');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -177,8 +179,9 @@ export default function Bankafstemning() {
|
||||||
const handleSubmitCreate = async () => {
|
const handleSubmitCreate = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
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) {
|
if (selectedBankTx) {
|
||||||
addPendingMatch({
|
addPendingMatch({
|
||||||
bankTransactionId: selectedBankTx.id,
|
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);
|
setIsCreateModalOpen(false);
|
||||||
setSelectedBankTx(null);
|
setSelectedBankTx(null);
|
||||||
} catch (error) {
|
} 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');
|
message.warning('Ingen matches at gemme');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: Send to GraphQL mutation
|
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
|
||||||
console.log('Saving matches:', pendingMatches);
|
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
|
||||||
message.success(`${pendingMatches.length} afstemninger gemt`);
|
// mark them as reconciled, and create journal entries for new transactions.
|
||||||
|
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
||||||
|
|
@ -241,21 +247,11 @@ export default function Bankafstemning() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Bankafstemning"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Bank', path: '/bankafstemning' }, { title: 'Bankafstemning' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Bankafstemning
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
icon={<UndoOutlined />}
|
icon={<UndoOutlined />}
|
||||||
|
|
@ -276,7 +272,10 @@ export default function Bankafstemning() {
|
||||||
Gem afstemninger ({pendingMatches.length})
|
Gem afstemninger ({pendingMatches.length})
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DemoDataDisclaimer message="Bankafstemning er delvist implementeret. Gem-funktionen er under udvikling." />
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
|
@ -314,7 +313,7 @@ export default function Bankafstemning() {
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Bogforing (uafstemt)"
|
title="Bogføring (uafstemt)"
|
||||||
value={ledgerTotal}
|
value={ledgerTotal}
|
||||||
precision={2}
|
precision={2}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
|
|
@ -374,7 +373,7 @@ export default function Bankafstemning() {
|
||||||
disabled={!canMatch}
|
disabled={!canMatch}
|
||||||
>
|
>
|
||||||
Match valgte ({selectedBankTransactions.length} bank,{' '}
|
Match valgte ({selectedBankTransactions.length} bank,{' '}
|
||||||
{selectedLedgerTransactions.length} bogforing)
|
{selectedLedgerTransactions.length} bogføring)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -496,7 +495,7 @@ export default function Bankafstemning() {
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong>Bogforingsposter</Text>
|
<Text strong>Bogføringsposter</Text>
|
||||||
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
|
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|
@ -505,7 +504,7 @@ export default function Bankafstemning() {
|
||||||
>
|
>
|
||||||
{ledgerEntries.length === 0 ? (
|
{ledgerEntries.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
description="Ingen uafstemte bogforingsposter (API ikke implementeret endnu)"
|
description="Ingen uafstemte bogføringsposter (API ikke implementeret endnu)"
|
||||||
style={{ padding: 24 }}
|
style={{ padding: 24 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -674,22 +673,22 @@ export default function Bankafstemning() {
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vaelg konto"
|
placeholder="Vælg konto"
|
||||||
options={[
|
options={[
|
||||||
{ value: '6100', label: '6100 - Husleje' },
|
{ value: '6100', label: '6100 - Husleje' },
|
||||||
{ value: '6800', label: '6800 - Kontorartikler' },
|
{ value: '6800', label: '6800 - Kontorartikler' },
|
||||||
{ value: '5000', label: '5000 - Varekob' },
|
{ value: '5000', label: '5000 - Varekøb' },
|
||||||
{ value: '4000', label: '4000 - Salg' },
|
{ value: '4000', label: '4000 - Salg' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="vatCode" label="Momskode">
|
<Form.Item name="vatCode" label="Momskode">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vaelg momskode"
|
placeholder="Vælg momskode"
|
||||||
allowClear
|
allowClear
|
||||||
options={[
|
options={[
|
||||||
{ value: 'K25', label: 'K25 - Indgaaende moms 25%' },
|
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
|
||||||
{ value: 'S25', label: 'S25 - Udgaaende moms 25%' },
|
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
|
||||||
{ value: 'NONE', label: 'Ingen moms' },
|
{ value: 'NONE', label: 'Ingen moms' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
|
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
|
||||||
import {
|
import {
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
RiseOutlined,
|
|
||||||
FallOutlined,
|
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
|
@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries';
|
||||||
import { useVatReport } from '@/api/queries/vatQueries';
|
import { useVatReport } from '@/api/queries/vatQueries';
|
||||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
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
|
// Types for chart data
|
||||||
interface CashFlowDataPoint {
|
interface CashFlowDataPoint {
|
||||||
|
|
@ -47,9 +47,13 @@ export default function Dashboard() {
|
||||||
const { activeCompany } = useCompanyStore();
|
const { activeCompany } = useCompanyStore();
|
||||||
const { currentFiscalYear } = usePeriodStore();
|
const { currentFiscalYear } = usePeriodStore();
|
||||||
|
|
||||||
// Define date interval
|
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
|
||||||
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
|
const periodStart = currentFiscalYear?.startDate
|
||||||
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
|
? 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(
|
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
|
||||||
activeCompany?.id,
|
activeCompany?.id,
|
||||||
|
|
@ -207,7 +211,7 @@ export default function Dashboard() {
|
||||||
|
|
||||||
const revenueExpenseConfig = {
|
const revenueExpenseConfig = {
|
||||||
data: cashFlowData.flatMap((d) => [
|
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 },
|
{ month: d.month, type: 'Udgifter', value: d.outflow },
|
||||||
]),
|
]),
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
|
|
@ -225,15 +229,13 @@ export default function Dashboard() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div style={{ marginBottom: 24 }}>
|
title="Dashboard"
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
subtitle={company?.name ? `${company.name} - ${formatDate(new Date().toISOString(), 'MMMM YYYY')}` : undefined}
|
||||||
Dashboard
|
breadcrumbs={[{ title: 'Dashboard' }]}
|
||||||
</Title>
|
/>
|
||||||
<Text type="secondary">
|
|
||||||
{company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')}
|
<DemoDataDisclaimer message="Dashboard viser beregnede data fra kontoplanen. Pengestrøms- og udgiftsgrafer er endnu ikke tilgængelige." />
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
|
|
@ -249,13 +251,9 @@ export default function Dashboard() {
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Tag
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
color={metrics.cashChange >= 0 ? 'green' : 'red'}
|
Baseret på kontosaldi i regnskabsåret
|
||||||
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
</Text>
|
||||||
>
|
|
||||||
{metrics.cashChange >= 0 ? '+' : ''}
|
|
||||||
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -296,10 +294,9 @@ export default function Dashboard() {
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{metrics.apChange >= 0 ? '+' : ''}
|
Baseret på kontosaldi i regnskabsåret
|
||||||
{(metrics.apChange * 100).toFixed(1)}% denne maaned
|
</Text>
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -315,32 +312,33 @@ export default function Dashboard() {
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Tag color="blue">Naeste frist: 1. marts</Tag>
|
<a href="/momsindberetning">Se momsindberetning</a>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|
||||||
{/* Charts Row */}
|
{/* Charts Row */}
|
||||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
{/* Cash Flow Chart */}
|
{/* Cash Flow Chart */}
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="Pengestroemme" size="small">
|
<Card title="Pengestrømme" size="small">
|
||||||
{cashFlowData.length > 0 ? (
|
{cashFlowData.length > 0 ? (
|
||||||
<Line {...cashFlowConfig} />
|
<Line {...cashFlowConfig} />
|
||||||
) : (
|
) : (
|
||||||
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
|
<Empty description="Ingen pengestrømsdata tilgængelig endnu" style={{ height: 200 }} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* Revenue vs Expenses */}
|
{/* Revenue vs Expenses */}
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="Indtaegter vs. Udgifter" size="small">
|
<Card title="Indtægter vs. Udgifter" size="small">
|
||||||
{cashFlowData.length > 0 ? (
|
{cashFlowData.length > 0 ? (
|
||||||
<Column {...revenueExpenseConfig} />
|
<Column {...revenueExpenseConfig} />
|
||||||
) : (
|
) : (
|
||||||
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
|
<Empty description="Ingen historiske data tilgængelig endnu" style={{ height: 200 }} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -354,7 +352,7 @@ export default function Dashboard() {
|
||||||
{expenseBreakdown.length > 0 ? (
|
{expenseBreakdown.length > 0 ? (
|
||||||
<Pie {...expenseConfig} />
|
<Pie {...expenseConfig} />
|
||||||
) : (
|
) : (
|
||||||
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
|
<Empty description="Ingen udgiftsdata tilgængelig" style={{ height: 200 }} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -440,7 +438,9 @@ export default function Dashboard() {
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
||||||
<Text>Momsindberetning forfalder om 14 dage</Text>
|
<a href="/momsindberetning">
|
||||||
|
<Text>Se momsindberetning</Text>
|
||||||
|
</a>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
{metrics.overdueInvoices > 0 && (
|
{metrics.overdueInvoices > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||||
import { spacing } from '@/styles/designTokens';
|
import { spacing } from '@/styles/designTokens';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { AmountText } from '@/components/shared/AmountText';
|
import { AmountText } from '@/components/shared/AmountText';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
|
@ -457,25 +458,16 @@ export default function Fakturaer() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Fakturaer"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Fakturaer' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
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}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
||||||
Ny fakturakladde
|
Ny fakturakladde
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore';
|
||||||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||||
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import { validateDoubleEntry } from '@/lib/accounting';
|
import { validateDoubleEntry } from '@/lib/accounting';
|
||||||
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
|
import type { TransactionLine, JournalEntryDraft } 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;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
// Display type for journal entry drafts
|
// Display type for journal entry drafts
|
||||||
|
|
@ -62,6 +65,13 @@ export default function Kassekladde() {
|
||||||
{ debit: 0, credit: 0 },
|
{ debit: 0, credit: 0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { currentFiscalYear } = usePeriodStore();
|
||||||
|
|
||||||
|
// Mutation hooks
|
||||||
|
const createDraftMutation = useCreateJournalEntryDraft();
|
||||||
|
const updateDraftMutation = useUpdateJournalEntryDraft();
|
||||||
|
const discardDraftMutation = useDiscardJournalEntryDraft();
|
||||||
|
|
||||||
// Fetch accounts and drafts from API
|
// Fetch accounts and drafts from API
|
||||||
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
|
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
|
||||||
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(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 <Tag color="red">Annulleret</Tag>;
|
||||||
}
|
}
|
||||||
return value ? (
|
return value ? (
|
||||||
<Tag color="green">Bogfort</Tag>
|
<Tag color="green">Bogført</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="orange">Kladde</Tag>
|
<Tag color="orange">Kladde</Tag>
|
||||||
);
|
);
|
||||||
|
|
@ -189,17 +199,56 @@ export default function Kassekladde() {
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'copy':
|
case 'copy':
|
||||||
|
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`);
|
message.success(`Bilag ${record.transactionNumber} kopieret`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl ved kopiering: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
break;
|
break;
|
||||||
case 'void':
|
case 'void':
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Annuller bilag',
|
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',
|
okText: 'Annuller bilag',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: 'Fortryd',
|
cancelText: 'Fortryd',
|
||||||
onOk: () => {
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await discardDraftMutation.mutateAsync(record.id);
|
||||||
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl ved annullering: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
@ -238,18 +287,72 @@ export default function Kassekladde() {
|
||||||
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
message.error(
|
message.error(
|
||||||
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
|
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Submitting:', { ...values, lines });
|
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');
|
message.success('Bilag oprettet');
|
||||||
|
}
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -258,21 +361,11 @@ export default function Kassekladde() {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<PageHeader
|
||||||
style={{
|
title="Kassekladde"
|
||||||
display: 'flex',
|
subtitle={activeCompany?.name}
|
||||||
justifyContent: 'space-between',
|
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||||
alignItems: 'center',
|
/>
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Kassekladde
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{activeCompany?.name}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton active paragraph={{ rows: 10 }} />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -280,21 +373,11 @@ export default function Kassekladde() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Kassekladde"
|
||||||
style={{
|
subtitle={activeCompany?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Kassekladde
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{activeCompany?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
|
|
@ -305,7 +388,8 @@ export default function Kassekladde() {
|
||||||
>
|
>
|
||||||
Nyt bilag
|
Nyt bilag
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
|
@ -329,7 +413,7 @@ export default function Kassekladde() {
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
allowClear
|
allowClear
|
||||||
options={[
|
options={[
|
||||||
{ value: 'posted', label: 'Bogfort' },
|
{ value: 'posted', label: 'Bogført' },
|
||||||
{ value: 'draft', label: 'Kladde' },
|
{ value: 'draft', label: 'Kladde' },
|
||||||
{ value: 'discarded', label: 'Annulleret' },
|
{ value: 'discarded', label: 'Annulleret' },
|
||||||
]}
|
]}
|
||||||
|
|
@ -373,7 +457,7 @@ export default function Kassekladde() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="date"
|
name="date"
|
||||||
label="Dato"
|
label="Dato"
|
||||||
rules={[{ required: true, message: 'Vaelg dato' }]}
|
rules={[{ required: true, message: 'Vælg dato' }]}
|
||||||
initialValue={dayjs()}
|
initialValue={dayjs()}
|
||||||
>
|
>
|
||||||
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
||||||
|
|
@ -409,7 +493,7 @@ export default function Kassekladde() {
|
||||||
<td style={{ padding: 4 }}>
|
<td style={{ padding: 4 }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="Vaelg konto"
|
placeholder="Vælg konto"
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
value={line.accountId}
|
value={line.accountId}
|
||||||
|
|
@ -478,7 +562,7 @@ export default function Kassekladde() {
|
||||||
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
||||||
<td style={{ padding: 8 }}>
|
<td style={{ padding: 8 }}>
|
||||||
<Button type="dashed" size="small" onClick={handleAddLine}>
|
<Button type="dashed" size="small" onClick={handleAddLine}>
|
||||||
+ Tilfoej linje
|
+ Tilføj linje
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,45 @@
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Tree,
|
Table,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
Modal,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Tabs,
|
|
||||||
Statistic,
|
Statistic,
|
||||||
message,
|
message,
|
||||||
Grid,
|
Switch,
|
||||||
Skeleton,
|
Divider,
|
||||||
Empty,
|
Descriptions,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FolderOutlined,
|
|
||||||
FileOutlined,
|
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
import { usePeriodStore } from '@/stores/periodStore';
|
import { usePeriodStore } from '@/stores/periodStore';
|
||||||
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
|
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
|
import { getAccountTypeName } from '@/lib/accounting';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { spacing } from '@/styles/designTokens';
|
|
||||||
import { PageHeader } from '@/components/shared/PageHeader';
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
|
||||||
import type { Account, AccountType } from '@/types/accounting';
|
import type { Account, AccountType } from '@/types/accounting';
|
||||||
|
import { useCreateAccount } from '@/api/mutations/accountMutations';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
const accountTypes: AccountType[] = [
|
const accountTypes: AccountType[] = [
|
||||||
'asset',
|
'asset',
|
||||||
|
|
@ -54,17 +53,23 @@ const accountTypes: AccountType[] = [
|
||||||
'extraordinary',
|
'extraordinary',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface AccountWithBalance extends Account {
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Kontooversigt() {
|
export default function Kontooversigt() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { activeCompany } = useCompanyStore();
|
const { activeCompany } = useCompanyStore();
|
||||||
const { currentFiscalYear } = usePeriodStore();
|
const { currentFiscalYear } = usePeriodStore();
|
||||||
const screens = useBreakpoint();
|
const [selectedAccount, setSelectedAccount] = useState<AccountWithBalance | null>(null);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const isMobile = !screens.md;
|
// Mutation hooks
|
||||||
|
const createAccountMutation = useCreateAccount();
|
||||||
|
|
||||||
// Fetch accounts and balances from API
|
// Fetch accounts and balances from API
|
||||||
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
|
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
|
||||||
|
|
@ -78,135 +83,176 @@ export default function Kontooversigt() {
|
||||||
|
|
||||||
const isLoading = accountsLoading || balancesLoading;
|
const isLoading = accountsLoading || balancesLoading;
|
||||||
|
|
||||||
// Combine accounts with balances
|
// Combine accounts with balances and filter
|
||||||
const accountsWithBalances = accounts.map(acc => {
|
const tableData = useMemo(() => {
|
||||||
|
const combined = accounts.map(acc => {
|
||||||
const balance = balances.find(b => b.id === acc.id);
|
const balance = balances.find(b => b.id === acc.id);
|
||||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build tree data from accounts
|
return combined
|
||||||
const buildTreeData = (): DataNode[] => {
|
.filter(acc => showInactive || acc.isActive)
|
||||||
return accountTypes.map((type) => {
|
.filter(acc =>
|
||||||
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 === '' ||
|
searchText === '' ||
|
||||||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
acc.accountNumber.includes(searchText)
|
acc.accountNumber.includes(searchText)
|
||||||
)
|
)
|
||||||
.map((acc) => ({
|
.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
|
||||||
key: acc.id,
|
}, [accounts, balances, searchText, showInactive]);
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAccount = (selectedKeys: React.Key[]) => {
|
// Calculate totals for KPI cards
|
||||||
const key = selectedKeys[0];
|
const kpiData = useMemo(() => {
|
||||||
if (key && !accountTypes.includes(key as AccountType)) {
|
const data = accounts.map(acc => {
|
||||||
const account = accountsWithBalances.find((acc) => acc.id === key);
|
const balance = balances.find(b => b.id === acc.id);
|
||||||
setSelectedAccount(account || null);
|
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 = () => {
|
const handleCreateAccount = () => {
|
||||||
setEditingAccount(null);
|
setSelectedAccount(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setIsModalOpen(true);
|
setIsEditMode(true);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditAccount = (account: Account) => {
|
const handleEditAccount = () => {
|
||||||
setEditingAccount(account);
|
if (selectedAccount) {
|
||||||
form.setFieldsValue(account);
|
form.setFieldsValue(selectedAccount);
|
||||||
setIsModalOpen(true);
|
setIsEditMode(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDrawer = () => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setIsEditMode(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
console.log('Submitting account:', values);
|
|
||||||
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
|
if (!activeCompany) {
|
||||||
setIsModalOpen(false);
|
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) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate totals from actual data
|
const columns: ColumnsType<AccountWithBalance> = [
|
||||||
const totalAssets = accountsWithBalances
|
{
|
||||||
.filter((a) => a.type === 'asset')
|
title: 'Nr.',
|
||||||
.reduce((sum, a) => sum + a.balance, 0);
|
dataIndex: 'accountNumber',
|
||||||
const totalLiabilities = accountsWithBalances
|
key: 'accountNumber',
|
||||||
.filter((a) => ['liability', 'equity'].includes(a.type))
|
width: 100,
|
||||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
render: (text) => <Text code>{text}</Text>,
|
||||||
const totalRevenue = accountsWithBalances
|
sorter: (a, b) => a.accountNumber.localeCompare(b.accountNumber),
|
||||||
.filter((a) => a.type === 'revenue')
|
},
|
||||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
{
|
||||||
const totalExpenses = accountsWithBalances
|
title: 'Navn',
|
||||||
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
|
dataIndex: 'name',
|
||||||
.reduce((sum, a) => sum + a.balance, 0);
|
key: 'name',
|
||||||
|
render: (text, record) => (
|
||||||
if (isLoading) {
|
<Space>
|
||||||
return (
|
<Text strong>{text}</Text>
|
||||||
<div>
|
{!record.isActive && <Tag color="default" bordered={false}>Inaktiv</Tag>}
|
||||||
<PageHeader
|
</Space>
|
||||||
title="Kontooversigt"
|
),
|
||||||
subtitle={activeCompany?.name}
|
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||||
breadcrumbs={[
|
},
|
||||||
{ title: 'Bogforing', path: '/bogforing' },
|
{
|
||||||
{ title: 'Kontooversigt' },
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Kontooversigt"
|
title="Kontooversigt"
|
||||||
subtitle={activeCompany?.name}
|
subtitle={activeCompany?.name}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ title: 'Bogforing', path: '/bogforing' },
|
{ title: 'Bogføring', path: '/bogforing' },
|
||||||
{ title: 'Kontooversigt' },
|
{ title: 'Kontooversigt' },
|
||||||
]}
|
]}
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -214,306 +260,262 @@ export default function Kontooversigt() {
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={handleCreateAccount}
|
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: 'Kontooversigt' },
|
|
||||||
]}
|
|
||||||
extra={
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleCreateAccount}
|
|
||||||
aria-label="Opret ny konto"
|
|
||||||
>
|
>
|
||||||
Ny konto
|
Ny konto
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* KPI Cards */}
|
||||||
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={12} sm={6}>
|
<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
|
<Statistic
|
||||||
title="Aktiver"
|
title="Aktiver"
|
||||||
value={totalAssets}
|
value={kpiData.assets}
|
||||||
precision={2}
|
precision={0}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.credit }}
|
valueStyle={{ color: accountingColors.credit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(val) => formatCurrency(val as number)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<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
|
<Statistic
|
||||||
title="Passiver"
|
title="Passiver"
|
||||||
value={totalLiabilities}
|
value={kpiData.liabilities}
|
||||||
precision={2}
|
precision={0}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.debit }}
|
valueStyle={{ color: accountingColors.debit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(val) => formatCurrency(val as number)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<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
|
<Statistic
|
||||||
title="Omsaetning"
|
title="Omsætning"
|
||||||
value={totalRevenue}
|
value={kpiData.revenue}
|
||||||
precision={2}
|
precision={0}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.credit }}
|
valueStyle={{ color: accountingColors.credit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(val) => formatCurrency(val as number)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<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
|
<Statistic
|
||||||
title="Omkostninger"
|
title="Omkostninger"
|
||||||
value={totalExpenses}
|
value={kpiData.expenses}
|
||||||
precision={2}
|
precision={0}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.debit }}
|
valueStyle={{ color: accountingColors.debit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(val) => formatCurrency(val as number)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
title={
|
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 code>{selectedAccount.accountNumber}</Text>
|
<Space>
|
||||||
<Text strong>{selectedAccount.name}</Text>
|
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||||
{!selectedAccount.isActive && (
|
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||||
<Tag color="red">Inaktiv</Tag>
|
</Space>
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
|
||||||
extra={
|
|
||||||
<Button
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleEditAccount(selectedAccount)}
|
|
||||||
aria-label={`Rediger konto ${selectedAccount.accountNumber}`}
|
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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
|
Rediger
|
||||||
</Button>
|
</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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
|
{isEditMode ? (
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="accountNumber"
|
name="accountNumber"
|
||||||
label="Kontonummer"
|
label="Kontonummer"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Indtast kontonummer' },
|
{ required: true, message: 'Påkrævet' },
|
||||||
{
|
{ pattern: /^\d{4}$/, message: 'Skal være 4 cifre' },
|
||||||
pattern: /^\d{4}$/,
|
|
||||||
message: 'Kontonummer skal vaere 4 cifre',
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="F.eks. 1000" maxLength={4} />
|
<Input maxLength={4} placeholder="1234" />
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="Kontonavn"
|
label="Kontonavn"
|
||||||
rules={[{ required: true, message: 'Indtast kontonavn' }]}
|
rules={[{ required: true, message: 'Påkrævet' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="F.eks. Bankkonto" />
|
<Input placeholder="F.eks. Salg af varer" />
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
<Form.Item name="vatCode" label="Momskode">
|
<Form.Item name="vatCode" label="Momskode">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vaelg momskode"
|
|
||||||
allowClear
|
allowClear
|
||||||
|
placeholder="Vælg..."
|
||||||
options={[
|
options={[
|
||||||
{ value: 'S25', label: 'S25 - Udgaende moms 25%' },
|
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
|
||||||
{ value: 'K25', label: 'K25 - Indgaende moms 25%' },
|
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
|
||||||
{ value: 'E0', label: 'E0 - EU-varekob 0%' },
|
{ value: 'E0', label: 'E0 - EU-salg' },
|
||||||
{ value: 'U0', label: 'U0 - Eksport 0%' },
|
{ value: 'U0', label: 'U0 - Eksport' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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">
|
<Form.Item name="description" label="Beskrivelse">
|
||||||
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
|
<Input.TextArea rows={4} placeholder="Interne noter til denne konto..." />
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="isActive" label="Status" initialValue={true}>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: true, label: 'Aktiv' },
|
|
||||||
{ value: false, label: 'Inaktiv' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
) : 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>
|
||||||
|
|
||||||
|
<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')}>Gå 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -124,9 +124,9 @@ export default function Kreditnotaer() {
|
||||||
// Fetch customers for dropdown
|
// Fetch customers for dropdown
|
||||||
const { data: customers = [] } = useActiveCustomers(company?.id);
|
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, {
|
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
|
||||||
enabled: !!company?.id && isApplyModalOpen,
|
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
|
||||||
});
|
});
|
||||||
|
|
||||||
const openInvoices: Invoice[] = allInvoices.filter(
|
const openInvoices: Invoice[] = allInvoices.filter(
|
||||||
|
|
@ -623,6 +623,15 @@ export default function Kreditnotaer() {
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="Vælg faktura der krediteres"
|
placeholder="Vælg faktura der krediteres"
|
||||||
optionFilterProp="children"
|
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>
|
||||||
<Form.Item name="reason" label="Årsag">
|
<Form.Item name="reason" label="Årsag">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
|
Switch,
|
||||||
Spin,
|
Spin,
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
|
@ -48,6 +49,7 @@ import { formatDate, validateCVRModulus11 } from '@/lib/formatters';
|
||||||
import { spacing } from '@/styles/designTokens';
|
import { spacing } from '@/styles/designTokens';
|
||||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -310,27 +312,18 @@ export default function Kunder() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Kunder"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Kunder' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Kunder
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<ShortcutTooltip shortcutId="newCustomer">
|
<ShortcutTooltip shortcutId="newCustomer">
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
Ny kunde
|
Ny kunde
|
||||||
</Button>
|
</Button>
|
||||||
</ShortcutTooltip>
|
</ShortcutTooltip>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -391,15 +384,10 @@ export default function Kunder() {
|
||||||
style={{ width: 250 }}
|
style={{ width: 250 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Select
|
<Space>
|
||||||
value={showInactive ? 'all' : 'active'}
|
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||||
onChange={(value) => setShowInactive(value === 'all')}
|
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||||
style={{ width: 150 }}
|
</Space>
|
||||||
options={[
|
|
||||||
{ value: 'active', label: 'Kun aktive' },
|
|
||||||
{ value: 'all', label: 'Alle kunder' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -15,24 +15,26 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
message,
|
Empty,
|
||||||
|
Skeleton,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
CheckCircleOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Pie } from '@ant-design/charts';
|
import { Pie } from '@ant-design/charts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
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 { 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 {
|
interface VATBox {
|
||||||
boxNumber: number;
|
boxNumber: number;
|
||||||
nameDanish: string;
|
nameDanish: string;
|
||||||
|
|
@ -42,134 +44,90 @@ interface VATBox {
|
||||||
basis?: number;
|
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() {
|
export default function Momsindberetning() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
|
const { activeCompany } = useCompanyStore();
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
||||||
dayjs().subtract(1, 'month').startOf('month')
|
dayjs().subtract(1, 'month').startOf('month')
|
||||||
);
|
);
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
|
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate period dates based on selection
|
||||||
const outputVAT = mockVATReport
|
const periodStart = useMemo(() => {
|
||||||
.filter((box) => [1, 2, 3].includes(box.boxNumber))
|
if (periodType === 'quarterly') {
|
||||||
.reduce((sum, box) => sum + box.amount, 0);
|
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
|
||||||
|
}, [selectedPeriod, periodType]);
|
||||||
|
|
||||||
const inputVAT = mockVATReport
|
const periodEnd = useMemo(() => {
|
||||||
.filter((box) => box.boxNumber === 4)
|
if (periodType === 'quarterly') {
|
||||||
.reduce((sum, box) => sum + box.amount, 0);
|
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
|
||||||
|
}, [selectedPeriod, periodType]);
|
||||||
|
|
||||||
const energyDuties = mockVATReport
|
// Fetch VAT report from backend
|
||||||
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
|
const { data: vatReport, isLoading, error } = useVatReport(
|
||||||
.reduce((sum, box) => sum + box.amount, 0);
|
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
|
// Pie chart config
|
||||||
const pieData = [
|
const pieData = useMemo(() => {
|
||||||
{ type: 'Salgsmoms', value: mockVATReport[0].amount },
|
if (!vatReport) return [];
|
||||||
{ type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount },
|
return [
|
||||||
{ type: 'Købsmoms (fradrag)', value: inputVAT },
|
{ type: 'Salgsmoms', value: vatReport.boxA },
|
||||||
{ type: 'Energiafgifter (fradrag)', value: energyDuties },
|
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
|
||||||
];
|
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
|
||||||
|
].filter(d => d.value > 0);
|
||||||
|
}, [vatReport, inputVAT]);
|
||||||
|
|
||||||
const pieConfig = {
|
const pieConfig = {
|
||||||
data: pieData,
|
data: pieData,
|
||||||
|
|
@ -243,96 +201,49 @@ export default function Momsindberetning() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubmit = () => {
|
// Loading state
|
||||||
Modal.confirm({
|
if (isLoading) {
|
||||||
title: 'Indsend momsangivelse',
|
return (
|
||||||
icon: <ExclamationCircleOutlined />,
|
|
||||||
content: (
|
|
||||||
<div>
|
<div>
|
||||||
<p>Du er ved at indsende momsangivelse for:</p>
|
<PageHeader
|
||||||
<p>
|
title="Momsindberetning"
|
||||||
<Text strong>Periode:</Text> {formatPeriod(selectedPeriod.toDate())}
|
subtitle={company?.name}
|
||||||
</p>
|
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||||
<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 }}
|
|
||||||
/>
|
/>
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
</div>
|
</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>;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Momsindberetning"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Momsindberetning
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={() => setIsPreviewOpen(true)}
|
onClick={() => setIsPreviewOpen(true)}
|
||||||
|
disabled={!vatReport}
|
||||||
>
|
>
|
||||||
Forhåndsvis
|
Forhåndsvis
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Period Selection */}
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|
@ -343,7 +254,7 @@ export default function Momsindberetning() {
|
||||||
onChange={setPeriodType}
|
onChange={setPeriodType}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'monthly', label: 'Månedlig' },
|
{ value: 'monthly', label: 'Maanedlig' },
|
||||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -356,12 +267,26 @@ export default function Momsindberetning() {
|
||||||
<Tag color="blue">
|
<Tag color="blue">
|
||||||
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
|
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
{vatReport && (
|
||||||
|
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message="Fejl ved indlaesning af momsdata"
|
||||||
|
description={error.message}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Udgående moms"
|
title="Udgående moms"
|
||||||
|
|
@ -372,7 +297,7 @@ export default function Momsindberetning() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Indgående moms (fradrag)"
|
title="Indgående moms (fradrag)"
|
||||||
|
|
@ -383,18 +308,7 @@ export default function Momsindberetning() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<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}>
|
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
|
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
|
||||||
|
|
@ -413,8 +327,9 @@ export default function Momsindberetning() {
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} lg={16}>
|
<Col xs={24} lg={16}>
|
||||||
<Card title="Momsangivelse - Rubrikker" size="small">
|
<Card title="Momsangivelse - Rubrikker" size="small">
|
||||||
|
{vatBoxes.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={mockVATReport}
|
dataSource={vatBoxes}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="boxNumber"
|
rowKey="boxNumber"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|
@ -443,57 +358,26 @@ export default function Momsindberetning() {
|
||||||
</Table.Summary>
|
</Table.Summary>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="Ingen momsdata for den valgte periode" />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
|
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
{pieData.length > 0 ? (
|
||||||
<Pie {...pieConfig} />
|
<Pie {...pieConfig} />
|
||||||
|
) : (
|
||||||
|
<Empty description="Ingen data at vise" style={{ height: 200 }} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Tidligere indberetninger" size="small">
|
<Card title="Tidligere indberetninger" size="small">
|
||||||
{mockSubmissions.map((sub) => (
|
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
||||||
<div
|
<Text type="secondary">
|
||||||
key={sub.id}
|
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
|
||||||
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>
|
</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>
|
|
||||||
))}
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -512,18 +396,25 @@ export default function Momsindberetning() {
|
||||||
Download PDF
|
Download PDF
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
key="submit"
|
key="skat-link"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
window.open('https://skat.dk', '_blank');
|
||||||
setIsPreviewOpen(false);
|
setIsPreviewOpen(false);
|
||||||
handleSubmit();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Indsend til SKAT
|
Ga til skat.dk
|
||||||
</Button>,
|
</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 bordered column={1} size="small">
|
||||||
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
|
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
|
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
|
||||||
|
|
@ -537,22 +428,24 @@ export default function Momsindberetning() {
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{vatBoxes.length > 0 && (
|
||||||
<Table
|
<Table
|
||||||
dataSource={mockVATReport}
|
dataSource={vatBoxes}
|
||||||
columns={[
|
columns={[
|
||||||
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
||||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||||
{
|
{
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
title: 'Beløb',
|
title: 'Belob',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v) => formatCurrency(v),
|
render: (v: number) => formatCurrency(v),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
rowKey="boxNumber"
|
rowKey="boxNumber"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { AmountText } from '@/components/shared/AmountText';
|
import { AmountText } from '@/components/shared/AmountText';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Order, OrderLine, OrderStatus } from '@/types/order';
|
import type { Order, OrderLine, OrderStatus } from '@/types/order';
|
||||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
|
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
|
||||||
|
|
@ -140,7 +141,7 @@ export default function Ordrer() {
|
||||||
|
|
||||||
const handleSubmitCreate = async () => {
|
const handleSubmitCreate = async () => {
|
||||||
if (!company || !currentFiscalYear) {
|
if (!company || !currentFiscalYear) {
|
||||||
showError('Virksomhed eller regnskabsaar ikke valgt');
|
showError('Virksomhed eller regnskabsår ikke valgt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -226,12 +227,12 @@ export default function Ordrer() {
|
||||||
const handleConfirmOrder = async () => {
|
const handleConfirmOrder = async () => {
|
||||||
if (!selectedOrder) return;
|
if (!selectedOrder) return;
|
||||||
if (selectedOrder.lines.length === 0) {
|
if (selectedOrder.lines.length === 0) {
|
||||||
showWarning('Tilfoej mindst en linje foer bekraeftelse');
|
showWarning('Tilføj mindst en linje før bekræftelse');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await confirmOrderMutation.mutateAsync(selectedOrder.id);
|
await confirmOrderMutation.mutateAsync(selectedOrder.id);
|
||||||
showSuccess('Ordre bekraeftet');
|
showSuccess('Ordre bekræftet');
|
||||||
// Refresh would happen via query invalidation
|
// Refresh would happen via query invalidation
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
|
@ -276,7 +277,7 @@ export default function Ordrer() {
|
||||||
|
|
||||||
const handleSubmitConvert = async () => {
|
const handleSubmitConvert = async () => {
|
||||||
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
|
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
|
||||||
showWarning('Vaelg mindst en linje at fakturere');
|
showWarning('Vælg mindst en linje at fakturere');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -346,7 +347,7 @@ export default function Ordrer() {
|
||||||
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
|
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Beloeb',
|
title: 'Beløb',
|
||||||
dataIndex: 'amountTotal',
|
dataIndex: 'amountTotal',
|
||||||
key: 'amountTotal',
|
key: 'amountTotal',
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|
@ -371,7 +372,7 @@ export default function Ordrer() {
|
||||||
align: 'center',
|
align: 'center',
|
||||||
filters: [
|
filters: [
|
||||||
{ text: 'Kladde', value: 'draft' },
|
{ text: 'Kladde', value: 'draft' },
|
||||||
{ text: 'Bekraeftet', value: 'confirmed' },
|
{ text: 'Bekræftet', value: 'confirmed' },
|
||||||
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
|
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
|
||||||
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
|
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
|
||||||
{ text: 'Annulleret', value: 'cancelled' },
|
{ text: 'Annulleret', value: 'cancelled' },
|
||||||
|
|
@ -399,37 +400,28 @@ export default function Ordrer() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Ordrer"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Ordrer' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
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}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
|
||||||
Ny ordre
|
Ny ordre
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Fejl ved indlaesning af ordrer"
|
message="Fejl ved indlæsning af ordrer"
|
||||||
description={error.message}
|
description={error.message}
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: spacing.lg }}
|
style={{ marginBottom: spacing.lg }}
|
||||||
action={
|
action={
|
||||||
<Button size="small" onClick={() => refetch()}>
|
<Button size="small" onClick={() => refetch()}>
|
||||||
Proev igen
|
Prøv igen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -454,7 +446,7 @@ export default function Ordrer() {
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Bekraeftede"
|
title="Bekræftede"
|
||||||
value={stats.confirmed}
|
value={stats.confirmed}
|
||||||
valueStyle={{ color: accountingColors.credit }}
|
valueStyle={{ color: accountingColors.credit }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -463,7 +455,7 @@ export default function Ordrer() {
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="Samlet vaerdi"
|
title="Samlet værdi"
|
||||||
value={stats.totalValue}
|
value={stats.totalValue}
|
||||||
precision={2}
|
precision={2}
|
||||||
valueStyle={{ color: accountingColors.credit }}
|
valueStyle={{ color: accountingColors.credit }}
|
||||||
|
|
@ -477,7 +469,7 @@ export default function Ordrer() {
|
||||||
<Card size="small" style={{ marginBottom: spacing.lg }}>
|
<Card size="small" style={{ marginBottom: spacing.lg }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Soeg ordre..."
|
placeholder="Søg ordre..."
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
|
@ -491,7 +483,7 @@ export default function Ordrer() {
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: 'Alle status' },
|
{ value: 'all', label: 'Alle status' },
|
||||||
{ value: 'draft', label: 'Kladde' },
|
{ value: 'draft', label: 'Kladde' },
|
||||||
{ value: 'confirmed', label: 'Bekraeftet' },
|
{ value: 'confirmed', label: 'Bekræftet' },
|
||||||
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
|
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
|
||||||
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
|
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
|
||||||
{ value: 'cancelled', label: 'Annulleret' },
|
{ value: 'cancelled', label: 'Annulleret' },
|
||||||
|
|
@ -503,7 +495,7 @@ export default function Ordrer() {
|
||||||
{/* Order Table */}
|
{/* Order Table */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
{loading ? (
|
{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 }} />
|
<div style={{ minHeight: 200 }} />
|
||||||
</Spin>
|
</Spin>
|
||||||
) : filteredOrders.length > 0 ? (
|
) : filteredOrders.length > 0 ? (
|
||||||
|
|
@ -518,7 +510,7 @@ export default function Ordrer() {
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="default"
|
variant="default"
|
||||||
title="Ingen ordrer"
|
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={
|
primaryAction={
|
||||||
!searchText
|
!searchText
|
||||||
? {
|
? {
|
||||||
|
|
@ -546,11 +538,11 @@ export default function Ordrer() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="customerId"
|
name="customerId"
|
||||||
label="Kunde"
|
label="Kunde"
|
||||||
rules={[{ required: true, message: 'Vaelg kunde' }]}
|
rules={[{ required: true, message: 'Vælg kunde' }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="Vaelg kunde"
|
placeholder="Vælg kunde"
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) =>
|
||||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
|
@ -576,7 +568,7 @@ export default function Ordrer() {
|
||||||
<Form.Item name="reference" label="Reference">
|
<Form.Item name="reference" label="Reference">
|
||||||
<Input placeholder="Projektnavn, tilbudsnr., etc." />
|
<Input placeholder="Projektnavn, tilbudsnr., etc." />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="Bemaerkninger">
|
<Form.Item name="notes" label="Bemærkninger">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
@ -612,7 +604,7 @@ export default function Ordrer() {
|
||||||
onClick={handleOpenAddLineModal}
|
onClick={handleOpenAddLineModal}
|
||||||
loading={addOrderLineMutation.isPending}
|
loading={addOrderLineMutation.isPending}
|
||||||
>
|
>
|
||||||
Tilfoej linje
|
Tilføj linje
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -621,13 +613,13 @@ export default function Ordrer() {
|
||||||
loading={confirmOrderMutation.isPending}
|
loading={confirmOrderMutation.isPending}
|
||||||
disabled={selectedOrder.lines.length === 0}
|
disabled={selectedOrder.lines.length === 0}
|
||||||
>
|
>
|
||||||
Bekraeft
|
Bekræft
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canShowConvertToInvoice(selectedOrder) && (
|
{canShowConvertToInvoice(selectedOrder) && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
|
title={selectedOrder.status === 'draft' ? 'Bekræft ordren først' : undefined}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -720,7 +712,7 @@ export default function Ordrer() {
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
message="Ingen linjer endnu"
|
message="Ingen linjer endnu"
|
||||||
description="Tilfoej linjer for at kunne bekraefte ordren."
|
description="Tilføj linjer for at kunne bekræfte ordren."
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
|
|
@ -732,13 +724,13 @@ export default function Ordrer() {
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
{selectedOrder.notes && (
|
{selectedOrder.notes && (
|
||||||
<>
|
<>
|
||||||
<Text type="secondary">Bemaerkninger:</Text>
|
<Text type="secondary">Bemærkninger:</Text>
|
||||||
<p>{selectedOrder.notes}</p>
|
<p>{selectedOrder.notes}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedOrder.cancelledReason && (
|
{selectedOrder.cancelledReason && (
|
||||||
<>
|
<>
|
||||||
<Text type="secondary">Annulleringsaarsag:</Text>
|
<Text type="secondary">Annulleringsårsag:</Text>
|
||||||
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
|
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -746,7 +738,7 @@ export default function Ordrer() {
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
<Text type="secondary">Beloeb ex. moms: </Text>
|
<Text type="secondary">Beløb ex. moms: </Text>
|
||||||
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
|
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 4 }}>
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
|
@ -794,7 +786,7 @@ export default function Ordrer() {
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
message="Advarsel"
|
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"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: spacing.lg }}
|
style={{ marginBottom: spacing.lg }}
|
||||||
|
|
@ -802,8 +794,8 @@ export default function Ordrer() {
|
||||||
<Form form={cancelForm} layout="vertical">
|
<Form form={cancelForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="reason"
|
name="reason"
|
||||||
label="Aarsag til annullering"
|
label="Årsag til annullering"
|
||||||
rules={[{ required: true, message: 'Angiv aarsag' }]}
|
rules={[{ required: true, message: 'Angiv årsag' }]}
|
||||||
>
|
>
|
||||||
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
|
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -812,14 +804,14 @@ export default function Ordrer() {
|
||||||
|
|
||||||
{/* Add Line Modal */}
|
{/* Add Line Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Tilfoej linje"
|
title="Tilføj linje"
|
||||||
open={isAddLineModalOpen}
|
open={isAddLineModalOpen}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsAddLineModalOpen(false);
|
setIsAddLineModalOpen(false);
|
||||||
setSelectedProductId(null);
|
setSelectedProductId(null);
|
||||||
}}
|
}}
|
||||||
onOk={handleSubmitAddLine}
|
onOk={handleSubmitAddLine}
|
||||||
okText="Tilfoej"
|
okText="Tilføj"
|
||||||
cancelText="Annuller"
|
cancelText="Annuller"
|
||||||
confirmLoading={addOrderLineMutation.isPending}
|
confirmLoading={addOrderLineMutation.isPending}
|
||||||
width={550}
|
width={550}
|
||||||
|
|
@ -840,7 +832,7 @@ export default function Ordrer() {
|
||||||
optionType="button"
|
optionType="button"
|
||||||
buttonStyle="solid"
|
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.Button value="freetext">Fritekst</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -850,11 +842,11 @@ export default function Ordrer() {
|
||||||
label="Produkt"
|
label="Produkt"
|
||||||
required
|
required
|
||||||
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
|
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
|
||||||
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
|
help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="Soeg efter produkt..."
|
placeholder="Søg efter produkt..."
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
value={selectedProductId}
|
value={selectedProductId}
|
||||||
onChange={handleProductSelect}
|
onChange={handleProductSelect}
|
||||||
|
|
@ -922,7 +914,7 @@ export default function Ordrer() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="vatCode"
|
name="vatCode"
|
||||||
label="Momskode"
|
label="Momskode"
|
||||||
rules={[{ required: true, message: 'Vaelg momskode' }]}
|
rules={[{ required: true, message: 'Vælg momskode' }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
disabled={addLineMode === 'product' && !!selectedProductId}
|
disabled={addLineMode === 'product' && !!selectedProductId}
|
||||||
|
|
@ -952,8 +944,8 @@ export default function Ordrer() {
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
message="Vaelg linjer til fakturering"
|
message="Vælg linjer til fakturering"
|
||||||
description="Vaelg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
|
description="Vælg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: spacing.lg }}
|
style={{ marginBottom: spacing.lg }}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Spin,
|
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
|
@ -20,6 +19,8 @@ import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
Switch,
|
||||||
|
Skeleton,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||||
import {
|
import {
|
||||||
|
|
@ -45,9 +46,10 @@ import { formatDate, formatCurrency } from '@/lib/formatters';
|
||||||
import { spacing } from '@/styles/designTokens';
|
import { spacing } from '@/styles/designTokens';
|
||||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// VAT code options
|
// VAT code options
|
||||||
const vatCodeOptions = [
|
const vatCodeOptions = [
|
||||||
|
|
@ -310,33 +312,57 @@ export default function Produkter() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: spacing.xl }}>
|
<div>
|
||||||
<Spin size="large" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Produkter"
|
||||||
|
subtitle={company?.name}
|
||||||
|
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||||
|
/>
|
||||||
<Alert
|
<Alert
|
||||||
message="Fejl ved indlæsning af produkter"
|
message="Fejl ved indlæsning af produkter"
|
||||||
description={error.message}
|
description={error.message}
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={() => window.location.reload()}>
|
||||||
|
Prøv igen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: spacing.lg, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<PageHeader
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
title="Produkter"
|
||||||
Produkter
|
subtitle={company?.name}
|
||||||
</Title>
|
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||||
|
extra={
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
Opret produkt
|
Opret produkt
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics */}
|
||||||
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
||||||
|
|
@ -373,12 +399,10 @@ export default function Produkter() {
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 300 }}
|
style={{ width: 300 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Space>
|
||||||
type={showInactive ? 'primary' : 'default'}
|
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||||
onClick={() => setShowInactive(!showInactive)}
|
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||||
>
|
</Space>
|
||||||
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
message,
|
message,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
|
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
||||||
|
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -29,24 +31,45 @@ export default function Settings() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
const [companyForm] = Form.useForm();
|
const [companyForm] = Form.useForm();
|
||||||
const [preferencesForm] = Form.useForm();
|
const [preferencesForm] = Form.useForm();
|
||||||
|
const updateCompanyMutation = useUpdateCompany();
|
||||||
|
|
||||||
const handleSaveCompany = async () => {
|
const handleSaveCompany = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await companyForm.validateFields();
|
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');
|
message.success('Virksomhedsoplysninger gemt');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
if (error instanceof Error) {
|
||||||
|
message.error(`Fejl ved gemning: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePreferences = async () => {
|
const handleSavePreferences = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await preferencesForm.validateFields();
|
await preferencesForm.validateFields();
|
||||||
console.log('Saving preferences:', values);
|
// TODO: Backend does not yet have a preferences mutation.
|
||||||
message.success('Præferencer gemt');
|
// Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint.
|
||||||
|
message.info('Præferencer er endnu ikke forbundet til backend');
|
||||||
} catch (error) {
|
} 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 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Tilknyttede bankkonti
|
Tilknyttede bankkonti
|
||||||
</Title>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Mock bank accounts */}
|
<Empty
|
||||||
{[
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
{
|
description="Ingen bankkonti tilknyttet endnu"
|
||||||
id: '1',
|
>
|
||||||
bankName: 'Danske Bank',
|
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
|
||||||
accountName: 'Erhvervskonto',
|
Tilføj bankkonto
|
||||||
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>
|
</Button>
|
||||||
</Space>
|
</Empty>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
|
|
@ -345,6 +331,7 @@ export default function Settings() {
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<Card>
|
<Card>
|
||||||
|
<DemoDataDisclaimer message="Brugerstyring er under udvikling" />
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -356,50 +343,15 @@ export default function Settings() {
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Brugere med adgang
|
Brugere med adgang
|
||||||
</Title>
|
</Title>
|
||||||
<Button type="primary">Inviter bruger</Button>
|
<Button type="primary" disabled>Inviter bruger</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<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">
|
<Text type="secondary">
|
||||||
Sidste login: {user.lastLogin}
|
Brugere med adgang til denne virksomhed vil blive vist her,
|
||||||
|
når funktionen er implementeret.
|
||||||
</Text>
|
</Text>
|
||||||
<Button size="small">Rediger</Button>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Company, CompanyRole } from '@/types/accounting';
|
import type { CompanyRole, CompanyWithRole } from '@/types/accounting';
|
||||||
|
|
||||||
interface CompanyState {
|
interface CompanyState {
|
||||||
// Current active company
|
// Current active company (includes role from myCompanies query)
|
||||||
activeCompany: Company | null;
|
activeCompany: CompanyWithRole | null;
|
||||||
// List of available companies
|
// List of available companies (includes role from myCompanies query)
|
||||||
companies: Company[];
|
companies: CompanyWithRole[];
|
||||||
// Loading state
|
// Loading state
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveCompany: (company: Company) => void;
|
setActiveCompany: (company: CompanyWithRole) => void;
|
||||||
setCompanies: (companies: Company[]) => void;
|
setCompanies: (companies: CompanyWithRole[]) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
clearActiveCompany: () => void;
|
clearActiveCompany: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -53,11 +53,11 @@ export const useCompanies = () =>
|
||||||
useCompanyStore((state) => state.companies);
|
useCompanyStore((state) => state.companies);
|
||||||
|
|
||||||
// Get the current user's role for the active company
|
// 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 => {
|
export const useActiveCompanyRole = (): CompanyRole => {
|
||||||
// Placeholder: In a real implementation, this would check the user's role
|
const activeCompany = useCompanyStore((state) => state.activeCompany);
|
||||||
// for the currently active company from the server/auth context
|
// Return the actual role from the CompanyWithRole data, default to 'viewer' if not set
|
||||||
return 'owner';
|
return activeCompany?.role ?? 'viewer';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions for user roles
|
// 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
|
// 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 {
|
export function useCanAdmin(): boolean {
|
||||||
// For now, return true to allow all users to manage access
|
const role = useActiveCompanyRole();
|
||||||
// In production, this should check the current user's role
|
return role === 'owner';
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface Account {
|
||||||
type: AccountType;
|
type: AccountType;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isSystemAccount?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
vatCode?: string;
|
vatCode?: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ export type VATCode =
|
||||||
* VAT code type classification
|
* VAT code type classification
|
||||||
*/
|
*/
|
||||||
export type VATCodeType =
|
export type VATCodeType =
|
||||||
| 'output' // Udgaaende moms (salg)
|
| 'output' // Udgående moms (salg)
|
||||||
| 'input' // Indgaaende moms (koeb)
|
| 'input' // Indgående moms (køb)
|
||||||
| 'reverse_charge' // Omvendt betalingspligt
|
| 'reverse_charge' // Omvendt betalingspligt
|
||||||
| 'exempt' // Momsfritaget
|
| 'exempt' // Momsfritaget
|
||||||
| 'none'; // Ingen moms
|
| 'none'; // Ingen moms
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue