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-0xk","title":"Phase 2: Wire broken features to backend APIs","description":"Connect all console.log-only handlers to real GraphQL mutations: Kassekladde submit, Settings save, Bankafstemning save, Kontooversigt account CRUD, FiscalYear creation, CloseFiscalYearWizard, Void/Copy actions.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.249535+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.295989+01:00"}
|
||||
{"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
|
||||
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
|
||||
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
|
||||
|
|
@ -7,8 +8,11 @@
|
|||
{"id":"books-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"}
|
||||
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
|
||||
{"id":"books-ced","title":"brug smb om regnskab + fropntend designer til at sikrer at alt er godt for både balance og kontooversigt","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:46.484629+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.42433+01:00","closed_at":"2026-01-30T14:47:52.42433+01:00","close_reason":"Closed"}
|
||||
{"id":"books-cws","title":"Phase 3: Accounting compliance fixes","description":"Balanced entry enforcement, VAT code unification, invoice numbering, fiscal year gap/overlap checks, posting date tracking, SAF-T fixes.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.362182+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.37896+01:00"}
|
||||
{"id":"books-h6e","title":"fjern hurtig bogføring og den visning der høre dertil","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:14:50.436314+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:18:09.911294+01:00","closed_at":"2026-01-30T14:18:09.911294+01:00","close_reason":"Closed"}
|
||||
{"id":"books-hzt","title":"fix bug med tilføj brugere står forkert med encoded tegn","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:21:34.556319+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:28:31.320973+01:00","closed_at":"2026-01-30T14:28:31.320973+01:00","close_reason":"Closed"}
|
||||
{"id":"books-k95","title":"Phase 4: UX consistency \u0026 bug fixes","description":"Danish character encoding, DemoDataDisclaimer deployment, PageHeader adoption, mobile responsiveness, mock data removal, dead buttons.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.471301+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.460303+01:00"}
|
||||
{"id":"books-ley","title":"Phase 1: GraphQL Authentication \u0026 Authorization","description":"Add authentication to GraphQL endpoint and authorization checks to all resolvers. Fix: S-01 through S-06, RBAC always returning owner, admin hardcoded email check.","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.131213+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:12:14.214637+01:00"}
|
||||
{"id":"books-ljg","title":"Fjern mock data og kobl frontend til backend GraphQL","description":"Frontend bruger ~2000 linjer hardcoded mock data i stedet for at bruge de eksisterende GraphQL hooks.\n\n## Problem\n- Backend GraphQL API er klar med queries og mutations\n- Frontend har hooks skrevet (useAccounts, useFiscalYears, etc.)\n- Men pages bruger hardcoded mock data i stedet for at kalde hooks\n\n## Filer der skal opdateres\n1. Dashboard.tsx - mock metrics, charts, transactions\n2. Kassekladde.tsx - mock accounts og posteringer \n3. Kontooversigt.tsx - mock kontoplan og balancer\n4. Bankafstemning.tsx - mock bank accounts og transaktioner\n5. FiscalYearSelector.tsx - mock fiscal years\n6. CompanySwitcher.tsx - mock companies\n7. Stores (companyStore, periodStore) - skal initialiseres fra API\n\n## Acceptkriterier\n- Al mock data fjernet fra frontend\n- Alle pages bruger GraphQL hooks til at hente data\n- Stores initialiseres korrekt ved app start\n- Data vises fra backend i UI","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:27:49.225279+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:42:04.17437+01:00","closed_at":"2026-01-30T22:42:04.17437+01:00","close_reason":"Closed"}
|
||||
{"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
|
||||
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
||||
|
|
|
|||
|
|
@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests
|
|||
public void MarkPosted_WhenActive_EmitsPostedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||
|
||||
// Assert
|
||||
var uncommittedEvents = aggregate.UncommittedEvents.ToList();
|
||||
uncommittedEvents.Should().HaveCount(2); // Created + Posted
|
||||
uncommittedEvents.Should().HaveCount(3); // Created + Updated + Posted
|
||||
|
||||
var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent;
|
||||
var postedEvent = uncommittedEvents[2].AggregateEvent as JournalEntryDraftPostedEvent;
|
||||
postedEvent.Should().NotBeNull();
|
||||
postedEvent!.TransactionId.Should().Be("transaction-123");
|
||||
postedEvent.PostedBy.Should().Be("user@example.com");
|
||||
postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
var act = () => aggregate.MarkPosted(" ", "user@example.com");
|
||||
|
|
@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests
|
|||
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
var act = () => aggregate.MarkPosted("transaction-123", "");
|
||||
|
|
@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests
|
|||
return aggregate;
|
||||
}
|
||||
|
||||
private static JournalEntryDraftAggregate CreatePostedDraft()
|
||||
private static JournalEntryDraftAggregate CreateActiveDraftWithLines()
|
||||
{
|
||||
var aggregate = CreateActiveDraft();
|
||||
var lines = new List<DraftLine>
|
||||
{
|
||||
new(1, "account-1", 1000m, 0m, "Debet"),
|
||||
new(2, "account-2", 0m, 1000m, "Kredit")
|
||||
};
|
||||
aggregate.Update("Test Draft", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
private static JournalEntryDraftAggregate CreatePostedDraft()
|
||||
{
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||
return aggregate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace Books.Api.Authentication;
|
|||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler(
|
||||
|
|
|
|||
|
|
@ -1,15 +1,35 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.Accounts;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Accounts;
|
||||
|
||||
public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
|
||||
/// <summary>
|
||||
/// Command handler for creating a new account.
|
||||
/// Validates that the account number is unique within the company.
|
||||
/// </summary>
|
||||
public class CreateAccountCommandHandler(
|
||||
IAccountRepository accountRepository)
|
||||
: CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
AccountAggregate aggregate,
|
||||
CreateAccountCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if an account with the same number already exists for this company
|
||||
var existingAccount = await accountRepository.GetByCompanyAndNumberAsync(
|
||||
command.CompanyId, command.AccountNumber, cancellationToken);
|
||||
|
||||
if (existingAccount != null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"ACCOUNT_NUMBER_EXISTS",
|
||||
$"Account number {command.AccountNumber} already exists for this company",
|
||||
$"Kontonummer {command.AccountNumber} eksisterer allerede");
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.AccountNumber,
|
||||
|
|
@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, Acco
|
|||
command.VatCodeId,
|
||||
command.IsSystemAccount,
|
||||
command.StandardAccountNumber);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,64 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.FiscalYears;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.FiscalYears;
|
||||
|
||||
public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
|
||||
/// <summary>
|
||||
/// Command handler for creating a new fiscal year.
|
||||
/// Validates overlap with existing fiscal years and checks for gaps.
|
||||
/// </summary>
|
||||
public class CreateFiscalYearCommandHandler(
|
||||
IFiscalYearRepository fiscalYearRepository)
|
||||
: CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
FiscalYearAggregate aggregate,
|
||||
CreateFiscalYearCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for overlapping fiscal years
|
||||
var hasOverlap = await fiscalYearRepository.HasOverlappingYearAsync(
|
||||
command.CompanyId,
|
||||
command.StartDate,
|
||||
command.EndDate,
|
||||
excludeId: null,
|
||||
cancellationToken);
|
||||
|
||||
if (hasOverlap)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_OVERLAP",
|
||||
"The fiscal year overlaps with an existing fiscal year",
|
||||
"Regnskabsåret overlapper med et eksisterende regnskabsår");
|
||||
}
|
||||
|
||||
// Check for gaps: verify the new start date follows the latest existing fiscal year
|
||||
if (!command.IsFirstFiscalYear)
|
||||
{
|
||||
var existingYears = await fiscalYearRepository.GetByCompanyIdAsync(
|
||||
command.CompanyId, cancellationToken);
|
||||
|
||||
if (existingYears.Count > 0)
|
||||
{
|
||||
// Find the latest end date among existing fiscal years
|
||||
var latestEndDate = existingYears
|
||||
.Select(fy => DateOnly.FromDateTime(fy.EndDate))
|
||||
.Max();
|
||||
|
||||
var expectedStartDate = latestEndDate.AddDays(1);
|
||||
|
||||
if (command.StartDate != expectedStartDate)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_GAP",
|
||||
$"Fiscal year must start on {expectedStartDate:yyyy-MM-dd} (day after previous year ends on {latestEndDate:yyyy-MM-dd}). No gaps are allowed between fiscal years.",
|
||||
$"Regnskabsåret skal starte den {expectedStartDate:yyyy-MM-dd} (dagen efter forrige år slutter den {latestEndDate:yyyy-MM-dd}). Der må ikke være huller mellem regnskabsår.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.Name,
|
||||
|
|
@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate
|
|||
command.EndDate,
|
||||
command.IsFirstFiscalYear,
|
||||
command.IsReorganization);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
using Books.Api.Domain.Invoices;
|
||||
using Books.Api.Invoicing.Services;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Invoices;
|
||||
|
||||
public class CreateInvoiceCommandHandler
|
||||
/// <summary>
|
||||
/// Command handler for creating invoices.
|
||||
/// Auto-assigns a sequential invoice number if one is not provided.
|
||||
/// </summary>
|
||||
public class CreateInvoiceCommandHandler(
|
||||
IInvoiceNumberService invoiceNumberService)
|
||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
InvoiceAggregate aggregate,
|
||||
CreateInvoiceCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Auto-assign invoice number if not provided
|
||||
var invoiceNumber = command.InvoiceNumber;
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
{
|
||||
invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
|
||||
command.CompanyId,
|
||||
command.InvoiceDate.Year,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.FiscalYearId,
|
||||
command.CustomerId,
|
||||
command.CustomerName,
|
||||
command.CustomerNumber,
|
||||
command.InvoiceNumber,
|
||||
invoiceNumber,
|
||||
command.InvoiceDate,
|
||||
command.DueDate,
|
||||
command.PaymentTermsDays,
|
||||
|
|
@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler
|
|||
command.Notes,
|
||||
command.Reference,
|
||||
command.CreatedBy);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.JournalEntryDrafts;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.JournalEntryDrafts;
|
||||
|
|
@ -42,19 +44,75 @@ public class UpdateJournalEntryDraftCommandHandler
|
|||
}
|
||||
}
|
||||
|
||||
public class MarkJournalEntryDraftPostedCommandHandler
|
||||
/// <summary>
|
||||
/// Command handler for posting a journal entry draft.
|
||||
/// Validates fiscal year status and date range before allowing the post.
|
||||
/// </summary>
|
||||
public class MarkJournalEntryDraftPostedCommandHandler(
|
||||
IJournalEntryDraftRepository draftRepository,
|
||||
IFiscalYearRepository fiscalYearRepository)
|
||||
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
JournalEntryDraftAggregate aggregate,
|
||||
MarkJournalEntryDraftPostedCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Load the draft read model to get fiscal year and document date
|
||||
var draft = await draftRepository.GetByIdAsync(
|
||||
aggregate.Id.Value, cancellationToken);
|
||||
|
||||
var fiscalYearId = draft?.FiscalYearId ?? aggregate.FiscalYearId;
|
||||
|
||||
// Validate fiscal year is set
|
||||
if (string.IsNullOrWhiteSpace(fiscalYearId))
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_REQUIRED",
|
||||
"Fiscal year is required for posting a journal entry",
|
||||
"Regnskabsår er påkrævet for bogføring af en postering");
|
||||
}
|
||||
|
||||
// Fetch and validate fiscal year
|
||||
var fiscalYear = await fiscalYearRepository.GetByIdAsync(
|
||||
fiscalYearId, cancellationToken);
|
||||
|
||||
if (fiscalYear == null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_NOT_FOUND",
|
||||
$"Fiscal year '{fiscalYearId}' not found",
|
||||
$"Regnskabsår '{fiscalYearId}' blev ikke fundet");
|
||||
}
|
||||
|
||||
// Validate fiscal year is open (not Closed or Locked)
|
||||
if (fiscalYear.Status != "Open")
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_NOT_OPEN",
|
||||
$"Fiscal year is {fiscalYear.Status}. Only open fiscal years allow posting.",
|
||||
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
||||
}
|
||||
|
||||
// Validate document date falls within fiscal year range (if document date is set)
|
||||
if (draft?.DocumentDate != null)
|
||||
{
|
||||
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
|
||||
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
||||
var fyEnd = DateOnly.FromDateTime(fiscalYear.EndDate);
|
||||
|
||||
if (documentDate < fyStart || documentDate > fyEnd)
|
||||
{
|
||||
throw new DomainException(
|
||||
"DOCUMENT_DATE_OUTSIDE_FISCAL_YEAR",
|
||||
$"Document date {documentDate:yyyy-MM-dd} falls outside the fiscal year ({fyStart:yyyy-MM-dd} to {fyEnd:yyyy-MM-dd})",
|
||||
$"Bilagsdato {documentDate:yyyy-MM-dd} ligger uden for regnskabsåret ({fyStart:yyyy-MM-dd} til {fyEnd:yyyy-MM-dd})");
|
||||
}
|
||||
}
|
||||
|
||||
aggregate.MarkPosted(
|
||||
command.TransactionId,
|
||||
command.PostedBy);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ public class BankingController : ControllerBase
|
|||
|
||||
try
|
||||
{
|
||||
// TODO: Add proper CSRF/state validation. Currently the state parameter
|
||||
// is used as the connection ID, but it should also include a CSRF token
|
||||
// that is validated against the user session to prevent cross-site request
|
||||
// forgery attacks on the OAuth callback.
|
||||
// State contains the connection ID (set during StartBankConnection)
|
||||
var connectionId = state;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -65,15 +67,19 @@ public sealed record InvoiceLine
|
|||
|
||||
/// <summary>
|
||||
/// Gets the VAT rate for this line based on VatCode.
|
||||
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
|
||||
/// </summary>
|
||||
private decimal GetVatRate() => VatCode switch
|
||||
private decimal GetVatRate()
|
||||
{
|
||||
"U25" or "I25" => 0.25m, // Danish standard 25%
|
||||
"UEU" or "IEU" => 0m, // EU sales (reverse charge)
|
||||
"UEXP" or "IEXP" => 0m, // Export (no VAT)
|
||||
"INGEN" => 0m, // No VAT
|
||||
_ => 0.25m // Default to Danish standard
|
||||
};
|
||||
if (!VatCodes.IsValid(VatCode))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " +
|
||||
$"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN");
|
||||
}
|
||||
|
||||
return VatCodes.GetRate(VatCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an InvoiceLine with validation.
|
||||
|
|
|
|||
|
|
@ -2,10 +2,20 @@ using EventFlow.Aggregates;
|
|||
|
||||
namespace Books.Api.Domain.JournalEntryDrafts.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a journal entry draft is posted to the ledger.
|
||||
/// Includes PostedAt timestamp for audit trail compliance.
|
||||
/// </summary>
|
||||
public class JournalEntryDraftPostedEvent(
|
||||
string transactionId,
|
||||
string postedBy) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
||||
string postedBy,
|
||||
DateTimeOffset postedAt) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
||||
{
|
||||
public string TransactionId { get; } = transactionId;
|
||||
public string PostedBy { get; } = postedBy;
|
||||
|
||||
/// <summary>
|
||||
/// The exact timestamp when the draft was posted to the ledger.
|
||||
/// </summary>
|
||||
public DateTimeOffset PostedAt { get; } = postedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
IEmit<JournalEntryDraftPostedEvent>,
|
||||
IEmit<JournalEntryDraftDiscardedEvent>
|
||||
{
|
||||
/// <summary>
|
||||
/// Tolerance for floating-point rounding when comparing debit/credit totals.
|
||||
/// </summary>
|
||||
private const decimal BalanceTolerance = 0.01m;
|
||||
|
||||
private bool _isCreated;
|
||||
private DraftStatus _status = DraftStatus.Active;
|
||||
private string _companyId = string.Empty;
|
||||
private string _voucherNumber = string.Empty;
|
||||
private string? _fiscalYearId;
|
||||
private List<DraftLine> _lines = [];
|
||||
|
||||
public string CompanyId => _companyId;
|
||||
public string VoucherNumber => _voucherNumber;
|
||||
|
||||
/// <summary>
|
||||
/// The fiscal year ID assigned during the last update.
|
||||
/// </summary>
|
||||
public string? FiscalYearId => _fiscalYearId;
|
||||
|
||||
/// <summary>
|
||||
/// The current draft lines (populated from the last update event).
|
||||
/// </summary>
|
||||
public IReadOnlyList<DraftLine> Lines => _lines.AsReadOnly();
|
||||
|
||||
#region Apply Methods
|
||||
|
||||
public void Apply(JournalEntryDraftCreatedEvent e)
|
||||
|
|
@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
public void Apply(JournalEntryDraftUpdatedEvent e)
|
||||
{
|
||||
// State is stored in read model, not in aggregate
|
||||
_fiscalYearId = e.FiscalYearId;
|
||||
_lines = e.Lines.ToList();
|
||||
}
|
||||
|
||||
public void Apply(JournalEntryDraftPostedEvent e)
|
||||
|
|
@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
/// <summary>
|
||||
/// Updates a journal entry draft (auto-save).
|
||||
/// Validates that each line has either DebitAmount or CreditAmount (not both),
|
||||
/// and that VAT codes are valid.
|
||||
/// </summary>
|
||||
/// <param name="name">Draft name</param>
|
||||
/// <param name="documentDate">Bilagsdato - the date of the transaction/document (e.g., invoice date)</param>
|
||||
|
|
@ -126,6 +146,12 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
}
|
||||
}
|
||||
|
||||
// Validate individual lines: cannot have both debit and credit amounts
|
||||
foreach (var line in lines)
|
||||
{
|
||||
ValidateDraftLine(line);
|
||||
}
|
||||
|
||||
Emit(new JournalEntryDraftUpdatedEvent(
|
||||
name?.Trim(),
|
||||
documentDate,
|
||||
|
|
@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
attachmentIds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the draft as posted after validation.
|
||||
/// Enforces double-entry bookkeeping: total debits must equal total credits.
|
||||
/// Requires at least 2 lines with valid account IDs.
|
||||
/// </summary>
|
||||
/// <param name="transactionId">The ledger transaction ID</param>
|
||||
/// <param name="postedBy">User who posted the draft</param>
|
||||
public void MarkPosted(string transactionId, string postedBy)
|
||||
{
|
||||
EnsureCanModify();
|
||||
|
|
@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
"Posted by is required",
|
||||
"Bogført af er påkrævet");
|
||||
|
||||
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy));
|
||||
// Validate minimum number of lines for double-entry bookkeeping
|
||||
if (_lines.Count < 2)
|
||||
throw new DomainException(
|
||||
"INSUFFICIENT_LINES",
|
||||
"A journal entry must have at least 2 lines for double-entry bookkeeping",
|
||||
"En postering skal have mindst 2 linjer for dobbelt bogholderi");
|
||||
|
||||
// Validate all lines have account IDs assigned
|
||||
var linesWithoutAccounts = _lines.Where(l => string.IsNullOrWhiteSpace(l.AccountId)).ToList();
|
||||
if (linesWithoutAccounts.Count > 0)
|
||||
{
|
||||
var lineNumbers = string.Join(", ", linesWithoutAccounts.Select(l => l.LineNumber));
|
||||
throw new DomainException(
|
||||
"ACCOUNT_REQUIRED",
|
||||
$"All lines must have an account. Lines without account: {lineNumbers}",
|
||||
$"Alle linjer skal have en konto. Linjer uden konto: {lineNumbers}");
|
||||
}
|
||||
|
||||
// Validate debit/credit balance (fundamental double-entry accounting principle)
|
||||
var totalDebit = _lines.Sum(l => l.DebitAmount);
|
||||
var totalCredit = _lines.Sum(l => l.CreditAmount);
|
||||
|
||||
if (Math.Abs(totalDebit - totalCredit) > BalanceTolerance)
|
||||
throw new DomainException(
|
||||
"UNBALANCED_ENTRY",
|
||||
$"Total debits must equal credits. Debit: {totalDebit:N2}, Credit: {totalCredit:N2}",
|
||||
$"Debet og kredit skal balancere. Debet: {totalDebit:N2}, Kredit: {totalCredit:N2}");
|
||||
|
||||
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public void Discard(string discardedBy)
|
||||
|
|
@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
"Kassekladden er blevet kasseret");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single draft line.
|
||||
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
||||
/// At least one of DebitAmount or CreditAmount must be > 0.
|
||||
/// </summary>
|
||||
private static void ValidateDraftLine(DraftLine line)
|
||||
{
|
||||
if (line.DebitAmount > 0 && line.CreditAmount > 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"INVALID_LINE_AMOUNTS",
|
||||
$"Line {line.LineNumber} cannot have both debit and credit amounts. Use separate lines.",
|
||||
$"Linje {line.LineNumber} kan ikke have både debet- og kreditbeløb. Brug separate linjer.");
|
||||
}
|
||||
|
||||
if (line.DebitAmount <= 0 && line.CreditAmount <= 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"MISSING_LINE_AMOUNT",
|
||||
$"Line {line.LineNumber} must have either a debit or credit amount greater than zero",
|
||||
$"Linje {line.LineNumber} skal have enten et debet- eller kreditbeløb større end nul");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel,
|
|||
public string AttachmentIds { get; set; } = "[]";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? TransactionId { get; set; }
|
||||
/// <summary>
|
||||
/// The exact timestamp when the draft was posted to the ledger.
|
||||
/// </summary>
|
||||
public DateTimeOffset? PostedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Full AI extraction data stored as JSON string.
|
||||
|
|
@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel,
|
|||
|
||||
Status = "posted";
|
||||
TransactionId = domainEvent.AggregateEvent.TransactionId;
|
||||
PostedAt = domainEvent.AggregateEvent.PostedAt;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto
|
|||
public string AttachmentIds { get; set; } = "[]";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? TransactionId { get; set; }
|
||||
/// <summary>
|
||||
/// The exact timestamp when the draft was posted to the ledger.
|
||||
/// </summary>
|
||||
public DateTimeOffset? PostedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal
|
|||
attachment_ids AS AttachmentIds,
|
||||
status AS Status,
|
||||
transaction_id AS TransactionId,
|
||||
posted_at AS PostedAt,
|
||||
created_by AS CreatedBy,
|
||||
create_time AS CreatedAt,
|
||||
updated_time AS UpdatedAt
|
||||
updated_time AS UpdatedAt,
|
||||
extraction_data AS ExtractionData
|
||||
""";
|
||||
|
||||
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
using System.Security.Claims;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.Commands.Companies;
|
||||
using Books.Api.Commands.UserAccess;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.Domain.UserAccess;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.InputTypes;
|
||||
using Books.Api.GraphQL.Types;
|
||||
|
|
@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType
|
|||
|
||||
await commandBus.PublishAsync(command, ctx.CancellationToken);
|
||||
|
||||
// Grant the creating user owner access to the new company
|
||||
var httpContext = ctx.RequestServices!.GetRequiredService<IHttpContextAccessor>().HttpContext;
|
||||
var userId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId != null)
|
||||
{
|
||||
var accessId = UserCompanyAccessId.FromUserAndCompany(userId, companyId.Value);
|
||||
var grantCmd = new GrantUserCompanyAccessCommand(
|
||||
accessId, userId, companyId.Value, CompanyRole.Owner, userId);
|
||||
await commandBus.PublishAsync(grantCmd, ctx.CancellationToken);
|
||||
}
|
||||
|
||||
// Return the created company (eventually consistent)
|
||||
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
||||
});
|
||||
|
|
@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType
|
|||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
|
||||
// Require Owner or Accountant role to update a company
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
await accessService.RequireAccessAsync(id, CompanyRole.Accountant, ctx.CancellationToken);
|
||||
|
||||
var input = ctx.GetArgument<UpdateCompanyInput>("input");
|
||||
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Authorization;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.Domain.UserAccess;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.Types;
|
||||
using GraphQL;
|
||||
|
|
@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType
|
|||
|
||||
// companies: [CompanyType]
|
||||
Field<ListGraphType<CompanyType>>("companies")
|
||||
.Description("Get all companies")
|
||||
.Description("Get all companies accessible to the current user")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
return await repository.GetAllAsync(ctx.CancellationToken);
|
||||
var userAccesses = await accessService.GetUserCompaniesAsync(ctx.CancellationToken);
|
||||
var companyIds = userAccesses.Select(a => CompanyId.With(a.CompanyId)).ToList();
|
||||
if (companyIds.Count == 0) return Enumerable.Empty<object>();
|
||||
return await repository.GetByIds(companyIds, ctx.CancellationToken);
|
||||
});
|
||||
|
||||
// company(id: ID!): CompanyType
|
||||
|
|
@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType
|
|||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
await accessService.RequireAccessAsync(id, CompanyRole.Viewer, ctx.CancellationToken);
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
|
||||
return companies.FirstOrDefault();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Books.Api;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.GraphQL;
|
||||
using GraphQL;
|
||||
using GraphQL.Server.Ui.Altair;
|
||||
|
|
@ -30,6 +31,25 @@ app.UseCors();
|
|||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Company context middleware - extracts X-Company-Id header and validates user access
|
||||
app.UseCompanyContext();
|
||||
|
||||
// Require authentication for the GraphQL endpoint
|
||||
app.UseWhen(
|
||||
context => context.Request.Path.StartsWithSegments("/graphql"),
|
||||
appBuilder => appBuilder.Use(async (context, next) =>
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync("{\"errors\":[{\"message\":\"Authentication required\"}]}");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
})
|
||||
);
|
||||
|
||||
// Map controllers (for AuthController)
|
||||
app.MapControllers();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ public class VatReportService(
|
|||
ILogger<VatReportService> logger) : IVatReportService
|
||||
{
|
||||
// Standard Danish VAT account numbers
|
||||
// TODO: These should ideally come from company-level configuration,
|
||||
// as different chart-of-accounts templates may use different numbers.
|
||||
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
|
||||
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
||||
|
||||
|
|
@ -133,13 +135,28 @@ public class VatReportService(
|
|||
report.TotalInputVat = report.BoxB;
|
||||
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
||||
|
||||
// Basis amounts require tracking of original transaction amounts
|
||||
// For now, calculate from VAT amounts assuming 25% rate
|
||||
// Basis1 (Felt 1): Net domestic turnover with VAT
|
||||
// TODO: Query actual net turnover from transactions with output VAT codes (U25)
|
||||
// instead of back-calculating from VAT amount, which is inaccurate when
|
||||
// mixed VAT rates or partial deductions are involved.
|
||||
// Ideally: query revenue account balances filtered by VAT code U25.
|
||||
// For now, back-calculate from output VAT assuming standard 25% rate
|
||||
if (report.BoxA > 0)
|
||||
{
|
||||
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
|
||||
}
|
||||
|
||||
// TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions.
|
||||
// Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods.
|
||||
// report.BoxC = sum of VAT calculated on IEUV transactions.
|
||||
// report.Basis3 = net purchase amount for IEUV transactions.
|
||||
|
||||
// TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions.
|
||||
// Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT
|
||||
// on services purchased from abroad.
|
||||
// report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions.
|
||||
// report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions.
|
||||
|
||||
logger.LogInformation(
|
||||
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
||||
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
||||
|
|
|
|||
|
|
@ -332,6 +332,15 @@ public class SaftExportService(
|
|||
journals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps internal account types to SAF-T standard account classifications.
|
||||
/// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts
|
||||
/// can represent either income (e.g., interest income, account 8000-8499) or
|
||||
/// expense (e.g., interest expense, account 8500-8999). Without the account
|
||||
/// number or balance direction, we cannot determine the correct mapping.
|
||||
/// A future improvement should inspect the account number range or actual
|
||||
/// balance direction to choose between "Income" and "Expense".
|
||||
/// </summary>
|
||||
private static string MapAccountType(string accountType)
|
||||
{
|
||||
return accountType.ToLowerInvariant() switch
|
||||
|
|
@ -343,7 +352,10 @@ public class SaftExportService(
|
|||
"cogs" => "Expense",
|
||||
"expense" => "Expense",
|
||||
"personnel" => "Expense",
|
||||
"financial" => "Income", // Could be either, defaulting to Income
|
||||
// Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999).
|
||||
// Defaulting to "Expense" is safer since most financial items are costs (interest, fees).
|
||||
// TODO: Determine mapping based on account number range or balance direction.
|
||||
"financial" => "Expense",
|
||||
"extraordinary" => "Expense",
|
||||
_ => "Asset"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Books.Api.Authentication;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.EventFlow.Extensions;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.EventFlow.Infrastructure;
|
||||
using Books.Api.GraphQL;
|
||||
using Books.Api.Infrastructure;
|
||||
|
|
@ -67,6 +69,13 @@ public static class Startup
|
|||
// Read model repositories
|
||||
services.AddRepositories();
|
||||
|
||||
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// User company access repository and access service
|
||||
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
|
||||
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
|
||||
|
||||
// Logging decorators
|
||||
services.DecorateAsyncEventHandlersWithLogging();
|
||||
|
||||
|
|
@ -76,7 +85,7 @@ public static class Startup
|
|||
.AddSystemTextJson()
|
||||
.AddDataLoader()
|
||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
|
||||
|
||||
// Memory cache for API key caching
|
||||
services.AddMemoryCache();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { GraphQLClient } from 'graphql-request';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
|
||||
// GraphQL endpoint - configure based on environment
|
||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||
|
|
@ -26,8 +27,8 @@ export const queryClient = new QueryClient({
|
|||
refetchOnWindowFocus: true,
|
||||
},
|
||||
mutations: {
|
||||
// Retry mutations once
|
||||
retry: 1,
|
||||
// Never retry mutations - non-idempotent operations could create duplicates
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -38,7 +39,16 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
|
|||
variables?: TVariables
|
||||
): Promise<TData> {
|
||||
try {
|
||||
const data = await graphqlClient.request<TData>(query, variables);
|
||||
// Get active company from store (outside React)
|
||||
const activeCompany = useCompanyStore.getState().activeCompany;
|
||||
|
||||
// Build headers with company ID if available
|
||||
const headers: Record<string, string> = {};
|
||||
if (activeCompany?.id) {
|
||||
headers['X-Company-Id'] = activeCompany.id;
|
||||
}
|
||||
|
||||
const data = await graphqlClient.request<TData>(query, variables, headers);
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export async function processDocument(
|
|||
throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)');
|
||||
}
|
||||
if (response.status === 503) {
|
||||
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgaengelig');
|
||||
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgængelig');
|
||||
}
|
||||
throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account {
|
|||
description: acc.description,
|
||||
vatCode: acc.vatCodeId,
|
||||
isActive: acc.isActive,
|
||||
isSystemAccount: acc.isSystemAccount,
|
||||
balance: 0, // Not returned from backend yet
|
||||
createdAt: acc.createdAt,
|
||||
updatedAt: acc.updatedAt,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) {
|
|||
}
|
||||
// Note: Users with existing companies CAN access the wizard to create more
|
||||
}
|
||||
}, [companies, isLoading, navigate, location.pathname]);
|
||||
}, [companies, isLoading, navigate]); // Note: location.pathname intentionally omitted to prevent infinite loop
|
||||
|
||||
// Reset navigation ref when companies change (user created a company)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function DocumentUploadModal({
|
|||
message.success('Bogfoert!');
|
||||
onConfirm();
|
||||
} catch (err) {
|
||||
message.error('Kunne ikke bogfoere. Proev igen.');
|
||||
message.error('Kunne ikke bogføre. Prøv igen.');
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
|
|
@ -270,7 +270,7 @@ export function DocumentUploadModal({
|
|||
Annuller
|
||||
</Button>,
|
||||
<Button key="draft" onClick={handleSaveAsDraft}>
|
||||
Tilfoej til kladde
|
||||
Tilføj til kladde
|
||||
</Button>,
|
||||
<Button
|
||||
key="post"
|
||||
|
|
@ -451,7 +451,7 @@ function ExtractedInfoSection({
|
|||
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Beloeb',
|
||||
title: 'Beløb',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
align: 'right' as const,
|
||||
|
|
@ -526,7 +526,7 @@ function ExtractedInfoSection({
|
|||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{extraction.amountExVat != null && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">Beloeb ekskl. moms</Text>
|
||||
<Text type="secondary">Beløb ekskl. moms</Text>
|
||||
<AmountText amount={extraction.amountExVat} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -551,7 +551,7 @@ function ExtractedInfoSection({
|
|||
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
<Text strong>Beloeb inkl. moms</Text>
|
||||
<Text strong>Beløb inkl. moms</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
|||
open: {
|
||||
color: 'success',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Aben',
|
||||
label: 'Åben',
|
||||
},
|
||||
closed: {
|
||||
color: 'warning',
|
||||
|
|
@ -40,7 +40,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
|
|||
locked: {
|
||||
color: 'error',
|
||||
icon: <LockOutlined />,
|
||||
label: 'Last',
|
||||
label: 'Låst',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -84,16 +84,19 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
if (fiscalYearsData.length > 0) {
|
||||
setFiscalYears(fiscalYearsData);
|
||||
|
||||
// Get current value without adding to dependencies to avoid infinite loop
|
||||
const current = usePeriodStore.getState().currentFiscalYear;
|
||||
|
||||
// Validate currentFiscalYear belongs to this company's data
|
||||
const isValid = currentFiscalYear &&
|
||||
fiscalYearsData.some(fy => fy.id === currentFiscalYear.id);
|
||||
const isValid = current &&
|
||||
fiscalYearsData.some(fy => fy.id === current.id);
|
||||
|
||||
if (!isValid) {
|
||||
const openYear = fiscalYearsData.find(y => y.status === 'open');
|
||||
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
|
||||
}
|
||||
}
|
||||
}, [fiscalYearsData, currentFiscalYear, setFiscalYears, setCurrentFiscalYear]);
|
||||
}, [fiscalYearsData, setFiscalYears, setCurrentFiscalYear]);
|
||||
|
||||
const handleFiscalYearChange = (yearId: string) => {
|
||||
const year = fiscalYears.find((y) => y.id === yearId);
|
||||
|
|
@ -146,7 +149,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
style={{ minWidth: 200 }}
|
||||
optionLabelProp="label"
|
||||
popupMatchSelectWidth={false}
|
||||
dropdownRender={(menu) => (
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
|
@ -157,7 +160,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
onClick={handleCreateNew}
|
||||
size="small"
|
||||
>
|
||||
Opret nyt regnskabsar
|
||||
Opret nyt regnskabsår
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
|
|
@ -172,7 +175,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
)}
|
||||
options={sortedYears.map((year) => ({
|
||||
value: year.id,
|
||||
label: `Regnskabsar ${year.name}`,
|
||||
label: `Regnskabsår ${year.name}`,
|
||||
year,
|
||||
}))}
|
||||
optionRender={(option) => {
|
||||
|
|
|
|||
|
|
@ -125,31 +125,27 @@ export default function Header({ isMobile = false }: HeaderProps) {
|
|||
)}
|
||||
|
||||
{/* Help */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
aria-label="Hjælp"
|
||||
title="Hjælp"
|
||||
/>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label="Notifikationer"
|
||||
title="Notifikationer"
|
||||
/>
|
||||
|
||||
{/* Logout */}
|
||||
<Tooltip title="Log ud">
|
||||
<Tooltip title="Hjælp">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
aria-label="Log ud"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
onClick={() => window.open('https://help.books.dk', '_blank')}
|
||||
aria-label="Hjælp"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Notifications */}
|
||||
<Tooltip title="Notifikationer">
|
||||
<Badge count={0} size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
onClick={() => navigate('/indstillinger')}
|
||||
aria-label="Notifikationer"
|
||||
/>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
|
|
@ -42,7 +43,7 @@ function getItem(
|
|||
const menuItems: MenuItem[] = [
|
||||
getItem('Dashboard', '/', <DashboardOutlined />),
|
||||
|
||||
getItem('Bogfoering', 'accounting', <BookOutlined />, [
|
||||
getItem('Bogføring', 'accounting', <BookOutlined />, [
|
||||
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
|
||||
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
|
||||
]),
|
||||
|
|
@ -61,7 +62,7 @@ const menuItems: MenuItem[] = [
|
|||
|
||||
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
|
||||
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
|
||||
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
|
||||
getItem('Lønforståelse', '/loenforstaelse', <TeamOutlined />),
|
||||
getItem('Eksport', '/eksport', <ExportOutlined />),
|
||||
]),
|
||||
|
||||
|
|
@ -99,6 +100,17 @@ interface SidebarMenuProps {
|
|||
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(getOpenKeys(location.pathname));
|
||||
|
||||
// Update openKeys when location changes
|
||||
useEffect(() => {
|
||||
const newOpenKeys = getOpenKeys(location.pathname);
|
||||
setOpenKeys((prev) => {
|
||||
// Merge: keep existing open keys but ensure the current path's group is open
|
||||
const merged = [...new Set([...prev, ...newOpenKeys])];
|
||||
return merged;
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key.startsWith('/')) {
|
||||
|
|
@ -107,6 +119,10 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
const selectedKeys = [location.pathname];
|
||||
|
||||
return (
|
||||
|
|
@ -114,7 +130,8 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
|||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
defaultOpenKeys={getOpenKeys(location.pathname)}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ borderRight: 0 }}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
import { formatCurrency } from '@/lib/formatters';
|
||||
import type { FiscalYear } from '@/types/periods';
|
||||
import type { Account, Transaction } from '@/types/accounting';
|
||||
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { message } from 'antd';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -82,6 +84,8 @@ export default function CloseFiscalYearWizard({
|
|||
lockPeriod,
|
||||
} = usePeriodStore();
|
||||
|
||||
const closeFiscalYearMutation = useCloseFiscalYear();
|
||||
|
||||
// Reset wizard when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -160,27 +164,41 @@ export default function CloseFiscalYearWizard({
|
|||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 1. Close open periods if requested
|
||||
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
|
||||
// in generateClosingEntries() but never actually posted to the ledger.
|
||||
// Before closing the fiscal year, these closing entries MUST be posted:
|
||||
// 1. Revenue accounts should be zeroed out to the result account
|
||||
// 2. Expense accounts should be zeroed out to the result account
|
||||
// 3. The net result should be transferred to the equity account (resultAccountId)
|
||||
// Without posting these entries, the opening balances for the next year will be incorrect.
|
||||
|
||||
// 1. Close open periods if requested (local store)
|
||||
if (closeOpenPeriods) {
|
||||
for (const period of openPeriodsInYear) {
|
||||
closePeriod(period.id, 'system');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lock all periods in the year
|
||||
// 2. Lock all periods in the year (local store)
|
||||
for (const period of yearPeriods) {
|
||||
lockPeriod(period.id, 'system');
|
||||
}
|
||||
|
||||
// 3. Close and lock the fiscal year
|
||||
// 3. Call backend mutation to close the fiscal year
|
||||
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
|
||||
|
||||
// 4. Also update local store
|
||||
closeFiscalYear(fiscalYear.id, 'system');
|
||||
lockFiscalYear(fiscalYear.id, 'system');
|
||||
|
||||
// 4. Move to complete step
|
||||
// 5. Move to complete step
|
||||
setCurrentStep('complete');
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved arsafslutning: ${error.message}`);
|
||||
}
|
||||
console.error('Failed to close fiscal year:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
} from '@/lib/fiscalYear';
|
||||
import { generateAccountingPeriods } from '@/lib/periods';
|
||||
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
|
||||
import { useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { message } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
|
@ -58,6 +60,7 @@ export default function CreateFiscalYearModal({
|
|||
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
|
||||
const createFiscalYearMutation = useCreateFiscalYear();
|
||||
|
||||
// Calculate suggested fiscal year boundaries
|
||||
useEffect(() => {
|
||||
|
|
@ -114,20 +117,18 @@ export default function CreateFiscalYearModal({
|
|||
const startDate = values.dateRange[0].format('YYYY-MM-DD');
|
||||
const endDate = values.dateRange[1].format('YYYY-MM-DD');
|
||||
|
||||
// Create fiscal year object
|
||||
const newFiscalYear: FiscalYear = {
|
||||
id: `fy-${values.name}-${Date.now()}`,
|
||||
// Call backend mutation - let the backend generate the ID
|
||||
const newFiscalYear = await createFiscalYearMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: values.name,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'open',
|
||||
openingBalancePosted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Generate accounting periods and add required fields
|
||||
// Also update local store as a cache layer
|
||||
addFiscalYear(newFiscalYear);
|
||||
|
||||
// Generate accounting periods locally for the UI
|
||||
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
|
||||
const now = new Date().toISOString();
|
||||
const newPeriods = generatedPeriods.map((p, idx) => ({
|
||||
|
|
@ -136,9 +137,6 @@ export default function CreateFiscalYearModal({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
// Add to store
|
||||
addFiscalYear(newFiscalYear);
|
||||
setPeriods([...periods, ...newPeriods]);
|
||||
|
||||
// Set as current if this is the first or most recent
|
||||
|
|
@ -153,6 +151,9 @@ export default function CreateFiscalYearModal({
|
|||
onSuccess?.(newFiscalYear);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved oprettelse: ${error.message}`);
|
||||
}
|
||||
console.error('Failed to create fiscal year:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -180,7 +181,7 @@ export default function CreateFiscalYearModal({
|
|||
loading: isSubmitting,
|
||||
}}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{!activeCompany && (
|
||||
<Alert
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
|||
lines.push({
|
||||
accountId: `vat-input-${vatCode}`,
|
||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||
accountName: 'Indgaaende moms',
|
||||
accountName: 'Indgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: vatAmount,
|
||||
credit: 0,
|
||||
|
|
@ -347,7 +347,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
|||
lines.push({
|
||||
accountId: 'vat-output',
|
||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||
accountName: 'Udgaaende moms',
|
||||
accountName: 'Udgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: 0,
|
||||
credit: vatAmount,
|
||||
|
|
@ -431,7 +431,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
|||
generatedLines.push({
|
||||
accountId: `vat-input-${splitLine.vatCode}`,
|
||||
accountNumber: VAT_ACCOUNTS.inputVAT,
|
||||
accountName: 'Indgaaende moms',
|
||||
accountName: 'Indgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: lineVat,
|
||||
credit: 0,
|
||||
|
|
@ -506,7 +506,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
|||
generatedLines.push({
|
||||
accountId: 'vat-output',
|
||||
accountNumber: VAT_ACCOUNTS.outputVAT,
|
||||
accountName: 'Udgaaende moms',
|
||||
accountName: 'Udgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: 0,
|
||||
credit: lineVat,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type { VATPeriodicitet } from '@/types/periods';
|
|||
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||
S25: {
|
||||
code: 'S25',
|
||||
nameDanish: 'Udgaaende moms 25%',
|
||||
nameDanish: 'Udgående moms 25%',
|
||||
nameEnglish: 'Output VAT 25%',
|
||||
rate: 0.25,
|
||||
type: 'output',
|
||||
|
|
@ -30,7 +30,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
},
|
||||
K25: {
|
||||
code: 'K25',
|
||||
nameDanish: 'Indgaaende moms 25%',
|
||||
nameDanish: 'Indgående moms 25%',
|
||||
nameEnglish: 'Input VAT 25%',
|
||||
rate: 0.25,
|
||||
type: 'input',
|
||||
|
|
@ -230,8 +230,8 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
|||
* Default VAT accounts for automatic double-entry
|
||||
*/
|
||||
export const VAT_ACCOUNTS = {
|
||||
inputVAT: '5610', // Indgaaende moms (fradrag)
|
||||
outputVAT: '5710', // Udgaaende moms (skyld)
|
||||
inputVAT: '5610', // Indgående moms (fradrag)
|
||||
outputVAT: '5710', // Udgående moms (skyld)
|
||||
euVAT: '5620', // EU-moms (erhvervelsesmoms)
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,14 +20,13 @@ import {
|
|||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useUser } from '@/stores/authStore';
|
||||
import { useCanAdmin } from '@/stores/companyStore';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { graphqlClient } from '@/api/client';
|
||||
import { gql } from 'graphql-request';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
// Admin email that has access
|
||||
const ADMIN_EMAIL = 'nhh@softwarehuset.com';
|
||||
|
||||
// Derive backend base URL from GraphQL endpoint
|
||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||
|
|
@ -59,8 +58,8 @@ export default function Admin() {
|
|||
const [form] = Form.useForm();
|
||||
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = user?.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase();
|
||||
// Check if user has Owner role for the active company
|
||||
const isAdmin = useCanAdmin();
|
||||
|
||||
// Fetch available read model types
|
||||
const { data: readModelTypes, isLoading: typesLoading } = useQuery({
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries
|
|||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import type { BankTransaction } from '@/types/accounting';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Type for ledger entries (API not implemented yet)
|
||||
|
|
@ -160,7 +162,7 @@ export default function Bankafstemning() {
|
|||
ledgerTransactionId: ledgerEntry.id,
|
||||
matchType: 'existing',
|
||||
});
|
||||
message.success('Match tilfojet');
|
||||
message.success('Match tilføjet');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -177,8 +179,9 @@ export default function Bankafstemning() {
|
|||
const handleSubmitCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Creating entry:', values);
|
||||
|
||||
// TODO: Backend mutation for creating a journal entry from bank transaction is needed.
|
||||
// This should create a JournalEntryDraft and then post it, linking it to the bank transaction.
|
||||
if (selectedBankTx) {
|
||||
addPendingMatch({
|
||||
bankTransactionId: selectedBankTx.id,
|
||||
|
|
@ -190,11 +193,13 @@ export default function Bankafstemning() {
|
|||
});
|
||||
}
|
||||
|
||||
message.success('Postering oprettet og matchet');
|
||||
message.success('Postering tilfojet til afventende matches');
|
||||
setIsCreateModalOpen(false);
|
||||
setSelectedBankTx(null);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -203,9 +208,10 @@ export default function Bankafstemning() {
|
|||
message.warning('Ingen matches at gemme');
|
||||
return;
|
||||
}
|
||||
// TODO: Send to GraphQL mutation
|
||||
console.log('Saving matches:', pendingMatches);
|
||||
message.success(`${pendingMatches.length} afstemninger gemt`);
|
||||
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
|
||||
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
|
||||
// mark them as reconciled, and create journal entries for new transactions.
|
||||
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
|
||||
};
|
||||
|
||||
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
||||
|
|
@ -241,42 +247,35 @@ export default function Bankafstemning() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Bankafstemning
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={clearAllSelections}
|
||||
disabled={
|
||||
selectedBankTransactions.length === 0 &&
|
||||
selectedLedgerTransactions.length === 0
|
||||
}
|
||||
>
|
||||
Nulstil valg
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={pendingMatches.length === 0}
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Bankafstemning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Bank', path: '/bankafstemning' }, { title: 'Bankafstemning' }]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={clearAllSelections}
|
||||
disabled={
|
||||
selectedBankTransactions.length === 0 &&
|
||||
selectedLedgerTransactions.length === 0
|
||||
}
|
||||
>
|
||||
Nulstil valg
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={pendingMatches.length === 0}
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<DemoDataDisclaimer message="Bankafstemning er delvist implementeret. Gem-funktionen er under udvikling." />
|
||||
|
||||
{/* Filters */}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
|
|
@ -314,7 +313,7 @@ export default function Bankafstemning() {
|
|||
<Col span={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Bogforing (uafstemt)"
|
||||
title="Bogføring (uafstemt)"
|
||||
value={ledgerTotal}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
|
|
@ -374,7 +373,7 @@ export default function Bankafstemning() {
|
|||
disabled={!canMatch}
|
||||
>
|
||||
Match valgte ({selectedBankTransactions.length} bank,{' '}
|
||||
{selectedLedgerTransactions.length} bogforing)
|
||||
{selectedLedgerTransactions.length} bogføring)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,7 +495,7 @@ export default function Bankafstemning() {
|
|||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Bogforingsposter</Text>
|
||||
<Text strong>Bogføringsposter</Text>
|
||||
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
|
||||
</Space>
|
||||
}
|
||||
|
|
@ -505,7 +504,7 @@ export default function Bankafstemning() {
|
|||
>
|
||||
{ledgerEntries.length === 0 ? (
|
||||
<Empty
|
||||
description="Ingen uafstemte bogforingsposter (API ikke implementeret endnu)"
|
||||
description="Ingen uafstemte bogføringsposter (API ikke implementeret endnu)"
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -674,22 +673,22 @@ export default function Bankafstemning() {
|
|||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Vaelg konto"
|
||||
placeholder="Vælg konto"
|
||||
options={[
|
||||
{ value: '6100', label: '6100 - Husleje' },
|
||||
{ value: '6800', label: '6800 - Kontorartikler' },
|
||||
{ value: '5000', label: '5000 - Varekob' },
|
||||
{ value: '5000', label: '5000 - Varekøb' },
|
||||
{ value: '4000', label: '4000 - Salg' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
placeholder="Vaelg momskode"
|
||||
placeholder="Vælg momskode"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'K25', label: 'K25 - Indgaaende moms 25%' },
|
||||
{ value: 'S25', label: 'S25 - Udgaaende moms 25%' },
|
||||
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
|
||||
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
|
||||
{ value: 'NONE', label: 'Ingen moms' },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
|
||||
import {
|
||||
BankOutlined,
|
||||
RiseOutlined,
|
||||
FallOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
|
|
@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries';
|
|||
import { useVatReport } from '@/api/queries/vatQueries';
|
||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// Types for chart data
|
||||
interface CashFlowDataPoint {
|
||||
|
|
@ -47,9 +47,13 @@ export default function Dashboard() {
|
|||
const { activeCompany } = useCompanyStore();
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
|
||||
// Define date interval
|
||||
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
|
||||
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
|
||||
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
|
||||
const periodStart = currentFiscalYear?.startDate
|
||||
? dayjs(currentFiscalYear.startDate).format('YYYY-MM-DD')
|
||||
: dayjs().startOf('year').format('YYYY-MM-DD');
|
||||
const periodEnd = currentFiscalYear?.endDate
|
||||
? dayjs(currentFiscalYear.endDate).format('YYYY-MM-DD')
|
||||
: dayjs().endOf('year').format('YYYY-MM-DD');
|
||||
|
||||
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
|
||||
activeCompany?.id,
|
||||
|
|
@ -207,7 +211,7 @@ export default function Dashboard() {
|
|||
|
||||
const revenueExpenseConfig = {
|
||||
data: cashFlowData.flatMap((d) => [
|
||||
{ month: d.month, type: 'Indtaegter', value: d.inflow },
|
||||
{ month: d.month, type: 'Indtægter', value: d.inflow },
|
||||
{ month: d.month, type: 'Udgifter', value: d.outflow },
|
||||
]),
|
||||
isGroup: true,
|
||||
|
|
@ -225,15 +229,13 @@ export default function Dashboard() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')}
|
||||
</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle={company?.name ? `${company.name} - ${formatDate(new Date().toISOString(), 'MMMM YYYY')}` : undefined}
|
||||
breadcrumbs={[{ title: 'Dashboard' }]}
|
||||
/>
|
||||
|
||||
<DemoDataDisclaimer message="Dashboard viser beregnede data fra kontoplanen. Pengestrøms- og udgiftsgrafer er endnu ikke tilgængelige." />
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]}>
|
||||
|
|
@ -249,13 +251,9 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag
|
||||
color={metrics.cashChange >= 0 ? 'green' : 'red'}
|
||||
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||
>
|
||||
{metrics.cashChange >= 0 ? '+' : ''}
|
||||
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Baseret på kontosaldi i regnskabsåret
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -296,10 +294,9 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
|
||||
{metrics.apChange >= 0 ? '+' : ''}
|
||||
{(metrics.apChange * 100).toFixed(1)}% denne maaned
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Baseret på kontosaldi i regnskabsåret
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -315,32 +312,33 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">Naeste frist: 1. marts</Tag>
|
||||
<a href="/momsindberetning">Se momsindberetning</a>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{/* Charts Row */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
{/* Cash Flow Chart */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Pengestroemme" size="small">
|
||||
<Card title="Pengestrømme" size="small">
|
||||
{cashFlowData.length > 0 ? (
|
||||
<Line {...cashFlowConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
|
||||
<Empty description="Ingen pengestrømsdata tilgængelig endnu" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Revenue vs Expenses */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Indtaegter vs. Udgifter" size="small">
|
||||
<Card title="Indtægter vs. Udgifter" size="small">
|
||||
{cashFlowData.length > 0 ? (
|
||||
<Column {...revenueExpenseConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
|
||||
<Empty description="Ingen historiske data tilgængelig endnu" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -354,7 +352,7 @@ export default function Dashboard() {
|
|||
{expenseBreakdown.length > 0 ? (
|
||||
<Pie {...expenseConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
|
||||
<Empty description="Ingen udgiftsdata tilgængelig" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -440,7 +438,9 @@ export default function Dashboard() {
|
|||
<Col>
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
||||
<Text>Momsindberetning forfalder om 14 dage</Text>
|
||||
<a href="/momsindberetning">
|
||||
<Text>Se momsindberetning</Text>
|
||||
</a>
|
||||
</Space>
|
||||
</Col>
|
||||
{metrics.overdueInvoices > 0 && (
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { AmountText } from '@/components/shared/AmountText';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
|
|
@ -457,25 +458,16 @@ export default function Fakturaer() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Fakturaer
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
||||
Ny fakturakladde
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Fakturaer"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Fakturaer' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
||||
Ny fakturakladde
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore';
|
|||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { validateDoubleEntry } from '@/lib/accounting';
|
||||
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
|
||||
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Display type for journal entry drafts
|
||||
|
|
@ -62,6 +65,13 @@ export default function Kassekladde() {
|
|||
{ debit: 0, credit: 0 },
|
||||
]);
|
||||
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
|
||||
// Mutation hooks
|
||||
const createDraftMutation = useCreateJournalEntryDraft();
|
||||
const updateDraftMutation = useUpdateJournalEntryDraft();
|
||||
const discardDraftMutation = useDiscardJournalEntryDraft();
|
||||
|
||||
// Fetch accounts and drafts from API
|
||||
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
|
||||
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id);
|
||||
|
|
@ -124,7 +134,7 @@ export default function Kassekladde() {
|
|||
return <Tag color="red">Annulleret</Tag>;
|
||||
}
|
||||
return value ? (
|
||||
<Tag color="green">Bogfort</Tag>
|
||||
<Tag color="green">Bogført</Tag>
|
||||
) : (
|
||||
<Tag color="orange">Kladde</Tag>
|
||||
);
|
||||
|
|
@ -189,17 +199,56 @@ export default function Kassekladde() {
|
|||
setIsModalOpen(true);
|
||||
break;
|
||||
case 'copy':
|
||||
message.success(`Bilag ${record.transactionNumber} kopieret`);
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
break;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const draft = await createDraftMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: `Kopi af ${record.description}`,
|
||||
description: record.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
});
|
||||
// Copy lines to the new draft
|
||||
if (record.lines && record.lines.length > 0) {
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: draft.id,
|
||||
lines: record.lines.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId,
|
||||
debitAmount: l.debitAmount || 0,
|
||||
creditAmount: l.creditAmount || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
message.success(`Bilag ${record.transactionNumber} kopieret`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved kopiering: ${error.message}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
break;
|
||||
case 'void':
|
||||
Modal.confirm({
|
||||
title: 'Annuller bilag',
|
||||
content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`,
|
||||
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
|
||||
okText: 'Annuller bilag',
|
||||
okType: 'danger',
|
||||
cancelText: 'Fortryd',
|
||||
onOk: () => {
|
||||
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
||||
onOk: async () => {
|
||||
try {
|
||||
await discardDraftMutation.mutateAsync(record.id);
|
||||
message.success(`Bilag ${record.transactionNumber} annulleret`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved annullering: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
|
@ -238,18 +287,72 @@ export default function Kassekladde() {
|
|||
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
||||
if (!validation.valid) {
|
||||
message.error(
|
||||
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting:', { ...values, lines });
|
||||
message.success('Bilag oprettet');
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingDraft) {
|
||||
// Update existing draft
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: editingDraft.id,
|
||||
name: values.description,
|
||||
documentDate: values.date?.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
lines: lines
|
||||
.filter(l => l.accountId)
|
||||
.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId!,
|
||||
debitAmount: l.debit || 0,
|
||||
creditAmount: l.credit || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
message.success('Bilag opdateret');
|
||||
} else {
|
||||
// Create new draft
|
||||
const draft = await createDraftMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
name: values.description,
|
||||
documentDate: values.date?.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
fiscalYearId: currentFiscalYear?.id,
|
||||
});
|
||||
|
||||
// Update the draft with lines
|
||||
if (lines.some(l => l.accountId)) {
|
||||
await updateDraftMutation.mutateAsync({
|
||||
id: draft.id,
|
||||
lines: lines
|
||||
.filter(l => l.accountId)
|
||||
.map((l, idx) => ({
|
||||
lineNumber: idx + 1,
|
||||
accountId: l.accountId!,
|
||||
debitAmount: l.debit || 0,
|
||||
creditAmount: l.credit || 0,
|
||||
description: l.description,
|
||||
vatCode: l.vatCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
message.success('Bilag oprettet');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -258,21 +361,11 @@ export default function Kassekladde() {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Kassekladde
|
||||
</Title>
|
||||
<Text type="secondary">{activeCompany?.name}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -280,32 +373,23 @@ export default function Kassekladde() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Kassekladde
|
||||
</Title>
|
||||
<Text type="secondary">{activeCompany?.name}</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingDraft(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Nyt bilag
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingDraft(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Nyt bilag
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
|
|
@ -329,7 +413,7 @@ export default function Kassekladde() {
|
|||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'posted', label: 'Bogfort' },
|
||||
{ value: 'posted', label: 'Bogført' },
|
||||
{ value: 'draft', label: 'Kladde' },
|
||||
{ value: 'discarded', label: 'Annulleret' },
|
||||
]}
|
||||
|
|
@ -373,7 +457,7 @@ export default function Kassekladde() {
|
|||
<Form.Item
|
||||
name="date"
|
||||
label="Dato"
|
||||
rules={[{ required: true, message: 'Vaelg dato' }]}
|
||||
rules={[{ required: true, message: 'Vælg dato' }]}
|
||||
initialValue={dayjs()}
|
||||
>
|
||||
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
||||
|
|
@ -409,7 +493,7 @@ export default function Kassekladde() {
|
|||
<td style={{ padding: 4 }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Vaelg konto"
|
||||
placeholder="Vælg konto"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={line.accountId}
|
||||
|
|
@ -478,7 +562,7 @@ export default function Kassekladde() {
|
|||
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<Button type="dashed" size="small" onClick={handleAddLine}>
|
||||
+ Tilfoej linje
|
||||
+ Tilføj linje
|
||||
</Button>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -1,46 +1,45 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tree,
|
||||
Table,
|
||||
Space,
|
||||
Tag,
|
||||
Modal,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tabs,
|
||||
Statistic,
|
||||
message,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Empty,
|
||||
Switch,
|
||||
Divider,
|
||||
Descriptions,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
FileOutlined,
|
||||
SearchOutlined,
|
||||
MoreOutlined,
|
||||
HistoryOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
|
||||
import { getAccountTypeName } from '@/lib/accounting';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { spacing } from '@/styles/designTokens';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import type { Account, AccountType } from '@/types/accounting';
|
||||
import { useCreateAccount } from '@/api/mutations/accountMutations';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const accountTypes: AccountType[] = [
|
||||
'asset',
|
||||
|
|
@ -54,17 +53,23 @@ const accountTypes: AccountType[] = [
|
|||
'extraordinary',
|
||||
];
|
||||
|
||||
interface AccountWithBalance extends Account {
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export default function Kontooversigt() {
|
||||
const navigate = useNavigate();
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const { currentFiscalYear } = usePeriodStore();
|
||||
const screens = useBreakpoint();
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
const [selectedAccount, setSelectedAccount] = useState<AccountWithBalance | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const isMobile = !screens.md;
|
||||
// Mutation hooks
|
||||
const createAccountMutation = useCreateAccount();
|
||||
|
||||
// Fetch accounts and balances from API
|
||||
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
|
||||
|
|
@ -78,161 +83,176 @@ export default function Kontooversigt() {
|
|||
|
||||
const isLoading = accountsLoading || balancesLoading;
|
||||
|
||||
// Combine accounts with balances
|
||||
const accountsWithBalances = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
|
||||
// Build tree data from accounts
|
||||
const buildTreeData = (): DataNode[] => {
|
||||
return accountTypes.map((type) => {
|
||||
const range = getAccountNumberRange(type);
|
||||
const typeAccounts = accountsWithBalances.filter((acc) => acc.type === type);
|
||||
const typeBalance = typeAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||
|
||||
return {
|
||||
key: type,
|
||||
title: (
|
||||
<Space>
|
||||
<Text strong>{getAccountTypeName(type)}</Text>
|
||||
<Text type="secondary">({range.min}-{range.max})</Text>
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color: typeBalance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(typeBalance))}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
icon: <FolderOutlined />,
|
||||
children: typeAccounts
|
||||
.filter((acc) =>
|
||||
searchText === '' ||
|
||||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
acc.accountNumber.includes(searchText)
|
||||
)
|
||||
.map((acc) => ({
|
||||
key: acc.id,
|
||||
title: (
|
||||
<Space>
|
||||
<Text code>{acc.accountNumber}</Text>
|
||||
<Text>{acc.name}</Text>
|
||||
{!acc.isActive && <Tag color="red">Inaktiv</Tag>}
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color: acc.balance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(acc.balance))}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
icon: <FileOutlined />,
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
// Combine accounts with balances and filter
|
||||
const tableData = useMemo(() => {
|
||||
const combined = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAccount = (selectedKeys: React.Key[]) => {
|
||||
const key = selectedKeys[0];
|
||||
if (key && !accountTypes.includes(key as AccountType)) {
|
||||
const account = accountsWithBalances.find((acc) => acc.id === key);
|
||||
setSelectedAccount(account || null);
|
||||
}
|
||||
return combined
|
||||
.filter(acc => showInactive || acc.isActive)
|
||||
.filter(acc =>
|
||||
searchText === '' ||
|
||||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
acc.accountNumber.includes(searchText)
|
||||
)
|
||||
.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
|
||||
}, [accounts, balances, searchText, showInactive]);
|
||||
|
||||
// Calculate totals for KPI cards
|
||||
const kpiData = useMemo(() => {
|
||||
const data = accounts.map(acc => {
|
||||
const balance = balances.find(b => b.id === acc.id);
|
||||
return { ...acc, balance: balance?.netChange ?? 0 };
|
||||
});
|
||||
|
||||
return {
|
||||
assets: data.filter(a => a.type === 'asset').reduce((sum, a) => sum + a.balance, 0),
|
||||
liabilities: data.filter(a => ['liability', 'equity'].includes(a.type)).reduce((sum, a) => sum + Math.abs(a.balance), 0),
|
||||
revenue: data.filter(a => a.type === 'revenue').reduce((sum, a) => sum + Math.abs(a.balance), 0),
|
||||
expenses: data.filter(a => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type)).reduce((sum, a) => sum + a.balance, 0),
|
||||
};
|
||||
}, [accounts, balances]);
|
||||
|
||||
const handleRowClick = (record: AccountWithBalance) => {
|
||||
setSelectedAccount(record);
|
||||
setIsEditMode(false);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateAccount = () => {
|
||||
setEditingAccount(null);
|
||||
setSelectedAccount(null);
|
||||
form.resetFields();
|
||||
setIsModalOpen(true);
|
||||
setIsEditMode(true);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleEditAccount = (account: Account) => {
|
||||
setEditingAccount(account);
|
||||
form.setFieldsValue(account);
|
||||
setIsModalOpen(true);
|
||||
const handleEditAccount = () => {
|
||||
if (selectedAccount) {
|
||||
form.setFieldsValue(selectedAccount);
|
||||
setIsEditMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setIsDrawerOpen(false);
|
||||
setIsEditMode(false);
|
||||
setSelectedAccount(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Submitting account:', values);
|
||||
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!activeCompany) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAccount) {
|
||||
// TODO: Backend does not yet have an updateAccount mutation.
|
||||
// For now, show a message indicating this is not yet supported.
|
||||
message.warning('Redigering af konti er endnu ikke understottet i backend');
|
||||
} else {
|
||||
// Create new account
|
||||
await createAccountMutation.mutateAsync({
|
||||
companyId: activeCompany.id,
|
||||
accountNumber: values.accountNumber,
|
||||
name: values.name,
|
||||
accountType: values.type,
|
||||
description: values.description,
|
||||
vatCodeId: values.vatCode,
|
||||
});
|
||||
message.success('Konto oprettet');
|
||||
}
|
||||
|
||||
handleCloseDrawer();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals from actual data
|
||||
const totalAssets = accountsWithBalances
|
||||
.filter((a) => a.type === 'asset')
|
||||
.reduce((sum, a) => sum + a.balance, 0);
|
||||
const totalLiabilities = accountsWithBalances
|
||||
.filter((a) => ['liability', 'equity'].includes(a.type))
|
||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
||||
const totalRevenue = accountsWithBalances
|
||||
.filter((a) => a.type === 'revenue')
|
||||
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
|
||||
const totalExpenses = accountsWithBalances
|
||||
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
|
||||
.reduce((sum, a) => sum + a.balance, 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
const columns: ColumnsType<AccountWithBalance> = [
|
||||
{
|
||||
title: 'Nr.',
|
||||
dataIndex: 'accountNumber',
|
||||
key: 'accountNumber',
|
||||
width: 100,
|
||||
render: (text) => <Text code>{text}</Text>,
|
||||
sorter: (a, b) => a.accountNumber.localeCompare(b.accountNumber),
|
||||
},
|
||||
{
|
||||
title: 'Navn',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
{!record.isActive && <Tag color="default" bordered={false}>Inaktiv</Tag>}
|
||||
</Space>
|
||||
),
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 150,
|
||||
filters: accountTypes.map(type => ({ text: getAccountTypeName(type), value: type })),
|
||||
onFilter: (value, record) => record.type === value,
|
||||
render: (type) => <Tag>{getAccountTypeName(type)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Moms',
|
||||
dataIndex: 'vatCode',
|
||||
key: 'vatCode',
|
||||
width: 100,
|
||||
render: (code) => code ? <Tag color="blue">{code}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Saldo',
|
||||
dataIndex: 'balance',
|
||||
key: 'balance',
|
||||
align: 'right',
|
||||
width: 150,
|
||||
render: (value) => (
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: value >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(value)}
|
||||
</Text>
|
||||
),
|
||||
sorter: (a, b) => a.balance - b.balance,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(record);
|
||||
}}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateAccount}
|
||||
aria-label="Opret ny konto"
|
||||
>
|
||||
Ny konto
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Empty description="Ingen konti fundet. Opret en ny konto for at komme i gang." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogforing', path: '/bogforing' },
|
||||
{ title: 'Bogføring', path: '/bogforing' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
extra={
|
||||
|
|
@ -240,280 +260,262 @@ export default function Kontooversigt() {
|
|||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateAccount}
|
||||
aria-label="Opret ny konto"
|
||||
>
|
||||
Ny konto
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Aktiver total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
|
||||
<Statistic
|
||||
title="Aktiver"
|
||||
value={totalAssets}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.assets}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Passiver total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
|
||||
<Statistic
|
||||
title="Passiver"
|
||||
value={totalLiabilities}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.liabilities}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.debit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Omsaetning total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
|
||||
<Statistic
|
||||
title="Omsaetning"
|
||||
value={totalRevenue}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
title="Omsætning"
|
||||
value={kpiData.revenue}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" role="region" aria-label="Omkostninger total">
|
||||
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
|
||||
<Statistic
|
||||
title="Omkostninger"
|
||||
value={totalExpenses}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
value={kpiData.expenses}
|
||||
precision={0}
|
||||
valueStyle={{ color: accountingColors.debit }}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
formatter={(val) => formatCurrency(val as number)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Main Content */}
|
||||
<Row gutter={spacing.lg}>
|
||||
{/* Account Tree */}
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title="Kontoplan" size="small">
|
||||
{/* Search moved outside extra for better mobile UX */}
|
||||
<Input
|
||||
placeholder="Sog efter konto eller kontonummer..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ marginBottom: spacing.md }}
|
||||
allowClear
|
||||
aria-label="Sog i kontoplan"
|
||||
/>
|
||||
<Tree
|
||||
showIcon
|
||||
defaultExpandAll
|
||||
treeData={buildTreeData()}
|
||||
onSelect={handleSelectAccount}
|
||||
selectedKeys={selectedAccount ? [selectedAccount.id] : []}
|
||||
style={{
|
||||
maxHeight: isMobile ? 300 : 450,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
aria-label="Kontoplan hierarki"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Account Details */}
|
||||
<Col xs={24} lg={14}>
|
||||
{selectedAccount ? (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Text code>{selectedAccount.accountNumber}</Text>
|
||||
<Text strong>{selectedAccount.name}</Text>
|
||||
{!selectedAccount.isActive && (
|
||||
<Tag color="red">Inaktiv</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditAccount(selectedAccount)}
|
||||
aria-label={`Rediger konto ${selectedAccount.accountNumber}`}
|
||||
>
|
||||
Rediger
|
||||
</Button>
|
||||
}
|
||||
role="region"
|
||||
aria-label={`Detaljer for konto ${selectedAccount.accountNumber}`}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'transactions',
|
||||
label: 'Bevaegelser',
|
||||
children: (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Statistic
|
||||
title="Saldo"
|
||||
value={selectedAccount.balance}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
valueStyle={{
|
||||
color:
|
||||
selectedAccount.balance >= 0
|
||||
? accountingColors.credit
|
||||
: accountingColors.debit,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Empty description="Ingen bevaegelser" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: 'Kontooplysninger',
|
||||
children: (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Kontonummer</Text>
|
||||
<div>
|
||||
<Text strong>{selectedAccount.accountNumber}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Kontotype</Text>
|
||||
<div>
|
||||
<Tag>{getAccountTypeName(selectedAccount.type)}</Tag>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Status</Text>
|
||||
<div>
|
||||
{selectedAccount.isActive ? (
|
||||
<Tag color="green">Aktiv</Tag>
|
||||
) : (
|
||||
<Tag color="red">Inaktiv</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">Momskode</Text>
|
||||
<div>
|
||||
<Text>{selectedAccount.vatCode || 'Ingen'}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<EmptyState
|
||||
variant="accounts"
|
||||
icon={<FileOutlined style={{ fontSize: 48 }} />}
|
||||
title="Ingen konto valgt"
|
||||
description="Vaelg en konto i kontoplanen til venstre for at se detaljer og bevaegelser."
|
||||
compact
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create/Edit Account Modal */}
|
||||
<Modal
|
||||
title={editingAccount ? 'Rediger konto' : 'Opret konto'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
okText="Gem"
|
||||
cancelText="Annuller"
|
||||
<Card
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
title={
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
placeholder="Søg på navn eller nummer..."
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="accountNumber"
|
||||
label="Kontonummer"
|
||||
rules={[
|
||||
{ required: true, message: 'Indtast kontonummer' },
|
||||
{
|
||||
pattern: /^\d{4}$/,
|
||||
message: 'Kontonummer skal vaere 4 cifre',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="F.eks. 1000" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{ pageSize: 50, showSizeChanger: true }}
|
||||
size="middle"
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Kontonavn"
|
||||
rules={[{ required: true, message: 'Indtast kontonavn' }]}
|
||||
>
|
||||
<Input placeholder="F.eks. Bankkonto" />
|
||||
</Form.Item>
|
||||
{/* Details/Edit Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
isEditMode
|
||||
? (selectedAccount ? 'Rediger konto' : 'Ny konto')
|
||||
: (
|
||||
<Space>
|
||||
{selectedAccount?.name}
|
||||
<Tag>{selectedAccount?.accountNumber}</Tag>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
width={500}
|
||||
open={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
extra={
|
||||
!isEditMode && selectedAccount && (
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={handleEditAccount}>
|
||||
Rediger
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
isEditMode && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => isEditMode && selectedAccount ? setIsEditMode(false) : handleCloseDrawer()}>
|
||||
Annuller
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
Gem konto
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="accountNumber"
|
||||
label="Kontonummer"
|
||||
rules={[
|
||||
{ required: true, message: 'Påkrævet' },
|
||||
{ pattern: /^\d{4}$/, message: 'Skal være 4 cifre' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={4} placeholder="1234" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="Kontotype"
|
||||
rules={[{ required: true, message: 'Påkrævet' }]}
|
||||
>
|
||||
<Select options={accountTypes.map(t => ({ label: getAccountTypeName(t), value: t }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="Kontotype"
|
||||
rules={[{ required: true, message: 'Vaelg kontotype' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Vaelg type"
|
||||
options={accountTypes.map((type) => ({
|
||||
value: type,
|
||||
label: getAccountTypeName(type),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Kontonavn"
|
||||
rules={[{ required: true, message: 'Påkrævet' }]}
|
||||
>
|
||||
<Input placeholder="F.eks. Salg af varer" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
placeholder="Vaelg momskode"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'S25', label: 'S25 - Udgaende moms 25%' },
|
||||
{ value: 'K25', label: 'K25 - Indgaende moms 25%' },
|
||||
{ value: 'E0', label: 'E0 - EU-varekob 0%' },
|
||||
{ value: 'U0', label: 'U0 - Eksport 0%' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="Vælg..."
|
||||
options={[
|
||||
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
|
||||
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
|
||||
{ value: 'E0', label: 'E0 - EU-salg' },
|
||||
{ value: 'U0', label: 'U0 - Eksport' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isActive" label="Status" valuePropName="checked" initialValue={true}>
|
||||
<Switch checkedChildren="Aktiv" unCheckedChildren="Inaktiv" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="description" label="Beskrivelse">
|
||||
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Beskrivelse">
|
||||
<Input.TextArea rows={4} placeholder="Interne noter til denne konto..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : selectedAccount ? (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Balance Summary */}
|
||||
<Card size="small" bordered={false} style={{ background: '#f5f5f5' }}>
|
||||
<Statistic
|
||||
title="Nuværende Saldo"
|
||||
value={selectedAccount.balance}
|
||||
precision={2}
|
||||
suffix="kr."
|
||||
valueStyle={{
|
||||
color: selectedAccount.balance >= 0 ? accountingColors.credit : accountingColors.debit,
|
||||
fontSize: 24,
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Beregnet for indeværende regnskabsår
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="isActive" label="Status" initialValue={true}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: 'Aktiv' },
|
||||
{ value: false, label: 'Inaktiv' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Kontonummer">{selectedAccount.accountNumber}</Descriptions.Item>
|
||||
<Descriptions.Item label="Kontotype">{getAccountTypeName(selectedAccount.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Moms">{selectedAccount.vatCode || <Text type="secondary">Ingen</Text>}</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">
|
||||
{selectedAccount.isActive ? <Tag color="success">Aktiv</Tag> : <Tag color="default">Inaktiv</Tag>}
|
||||
</Descriptions.Item>
|
||||
{selectedAccount.description && (
|
||||
<Descriptions.Item label="Beskrivelse">
|
||||
{selectedAccount.description}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left" plain><HistoryOutlined /> Seneste Bevægelser</Divider>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Text type="secondary">Ingen posteringer i den valgte periode.</Text>
|
||||
<br />
|
||||
<Button type="link" size="small" onClick={() => navigate('/kassekladde')}>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>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,9 +124,9 @@ export default function Kreditnotaer() {
|
|||
// Fetch customers for dropdown
|
||||
const { data: customers = [] } = useActiveCustomers(company?.id);
|
||||
|
||||
// Fetch invoices for applying credit notes (only when modal is open)
|
||||
// Fetch invoices for applying credit notes and for original invoice selector
|
||||
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
|
||||
enabled: !!company?.id && isApplyModalOpen,
|
||||
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
|
||||
});
|
||||
|
||||
const openInvoices: Invoice[] = allInvoices.filter(
|
||||
|
|
@ -623,6 +623,15 @@ export default function Kreditnotaer() {
|
|||
allowClear
|
||||
placeholder="Vælg faktura der krediteres"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={allInvoices
|
||||
.filter((i: Invoice) => !i.isCreditNote && i.status !== 'voided')
|
||||
.map((i: Invoice) => ({
|
||||
value: i.id,
|
||||
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountTotal)})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="Årsag">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Spin,
|
||||
Alert,
|
||||
Drawer,
|
||||
|
|
@ -48,6 +49,7 @@ import { formatDate, validateCVRModulus11 } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
|
@ -310,27 +312,18 @@ export default function Kunder() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Kunder
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<ShortcutTooltip shortcutId="newCustomer">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Ny kunde
|
||||
</Button>
|
||||
</ShortcutTooltip>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Kunder"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Kunder' }]}
|
||||
extra={
|
||||
<ShortcutTooltip shortcutId="newCustomer">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Ny kunde
|
||||
</Button>
|
||||
</ShortcutTooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
|
|
@ -391,15 +384,10 @@ export default function Kunder() {
|
|||
style={{ width: 250 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
value={showInactive ? 'all' : 'active'}
|
||||
onChange={(value) => setShowInactive(value === 'all')}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'active', label: 'Kun aktive' },
|
||||
{ value: 'all', label: 'Alle kunder' },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
|
|
@ -15,24 +15,26 @@ import {
|
|||
Alert,
|
||||
Modal,
|
||||
Descriptions,
|
||||
message,
|
||||
Empty,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
SendOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Pie } from '@ant-design/charts';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { formatCurrency, formatDate, formatPeriod } from '@/lib/formatters';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { useVatReport } from '@/api/queries/vatQueries';
|
||||
import { formatCurrency, formatPeriod } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// Danish VAT boxes (Rubrikker)
|
||||
// Danish VAT boxes (Rubrikker) - mapped from backend VatReport
|
||||
interface VATBox {
|
||||
boxNumber: number;
|
||||
nameDanish: string;
|
||||
|
|
@ -42,134 +44,90 @@ interface VATBox {
|
|||
basis?: number;
|
||||
}
|
||||
|
||||
const mockVATReport: VATBox[] = [
|
||||
{
|
||||
boxNumber: 1,
|
||||
nameDanish: 'Salgsmoms',
|
||||
nameEnglish: 'Output VAT',
|
||||
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
|
||||
amount: 62500,
|
||||
basis: 250000,
|
||||
},
|
||||
{
|
||||
boxNumber: 2,
|
||||
nameDanish: 'Moms af varekøb i udlandet (EU)',
|
||||
nameEnglish: 'VAT on goods from EU',
|
||||
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
|
||||
amount: 5000,
|
||||
basis: 20000,
|
||||
},
|
||||
{
|
||||
boxNumber: 3,
|
||||
nameDanish: 'Moms af ydelseskøb i udlandet',
|
||||
nameEnglish: 'VAT on services from abroad',
|
||||
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||
amount: 2500,
|
||||
basis: 10000,
|
||||
},
|
||||
{
|
||||
boxNumber: 4,
|
||||
nameDanish: 'Købsmoms',
|
||||
nameEnglish: 'Input VAT',
|
||||
description: 'Fradragsberettiget moms af køb',
|
||||
amount: 35000,
|
||||
basis: 140000,
|
||||
},
|
||||
{
|
||||
boxNumber: 5,
|
||||
nameDanish: 'Olie- og flaskegasafgift',
|
||||
nameEnglish: 'Oil and gas duty',
|
||||
description: 'Godtgørelse af olie- og flaskegasafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 6,
|
||||
nameDanish: 'Elafgift',
|
||||
nameEnglish: 'Electricity duty',
|
||||
description: 'Godtgørelse af elafgift',
|
||||
amount: 1200,
|
||||
},
|
||||
{
|
||||
boxNumber: 7,
|
||||
nameDanish: 'Naturgas- og bygasafgift',
|
||||
nameEnglish: 'Natural gas duty',
|
||||
description: 'Godtgørelse af naturgas- og bygasafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 8,
|
||||
nameDanish: 'Kulafgift',
|
||||
nameEnglish: 'Coal duty',
|
||||
description: 'Godtgørelse af kulafgift',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
boxNumber: 9,
|
||||
nameDanish: 'CO2-afgift',
|
||||
nameEnglish: 'CO2 duty',
|
||||
description: 'Godtgørelse af CO2-afgift',
|
||||
amount: 300,
|
||||
},
|
||||
];
|
||||
|
||||
// Historical submissions
|
||||
const mockSubmissions = [
|
||||
{
|
||||
id: '1',
|
||||
period: '2024-10',
|
||||
submittedAt: '2024-11-28',
|
||||
status: 'accepted',
|
||||
netVAT: 28500,
|
||||
referenceNumber: 'SKAT-2024-123456',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
period: '2024-07',
|
||||
submittedAt: '2024-08-30',
|
||||
status: 'accepted',
|
||||
netVAT: 32100,
|
||||
referenceNumber: 'SKAT-2024-789012',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
period: '2024-04',
|
||||
submittedAt: '2024-05-29',
|
||||
status: 'accepted',
|
||||
netVAT: -5600,
|
||||
referenceNumber: 'SKAT-2024-345678',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Momsindberetning() {
|
||||
const { company } = useCompany();
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
||||
dayjs().subtract(1, 'month').startOf('month')
|
||||
);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||
|
||||
// Calculate totals
|
||||
const outputVAT = mockVATReport
|
||||
.filter((box) => [1, 2, 3].includes(box.boxNumber))
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
// Calculate period dates based on selection
|
||||
const periodStart = useMemo(() => {
|
||||
if (periodType === 'quarterly') {
|
||||
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
|
||||
}
|
||||
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
|
||||
}, [selectedPeriod, periodType]);
|
||||
|
||||
const inputVAT = mockVATReport
|
||||
.filter((box) => box.boxNumber === 4)
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
const periodEnd = useMemo(() => {
|
||||
if (periodType === 'quarterly') {
|
||||
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
|
||||
}
|
||||
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
|
||||
}, [selectedPeriod, periodType]);
|
||||
|
||||
const energyDuties = mockVATReport
|
||||
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
|
||||
.reduce((sum, box) => sum + box.amount, 0);
|
||||
// Fetch VAT report from backend
|
||||
const { data: vatReport, isLoading, error } = useVatReport(
|
||||
activeCompany?.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
const netVAT = outputVAT - inputVAT - energyDuties;
|
||||
// Map backend VatReport to UI's rubrik display
|
||||
const vatBoxes: VATBox[] = useMemo(() => {
|
||||
if (!vatReport) return [];
|
||||
return [
|
||||
{
|
||||
boxNumber: 1,
|
||||
nameDanish: 'Salgsmoms',
|
||||
nameEnglish: 'Output VAT',
|
||||
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
|
||||
amount: vatReport.boxA,
|
||||
basis: vatReport.basis1,
|
||||
},
|
||||
{
|
||||
boxNumber: 2,
|
||||
nameDanish: 'Moms af varekob i udlandet (EU)',
|
||||
nameEnglish: 'VAT on goods from EU',
|
||||
description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande',
|
||||
amount: vatReport.boxC,
|
||||
basis: vatReport.basis3,
|
||||
},
|
||||
{
|
||||
boxNumber: 3,
|
||||
nameDanish: 'Moms af ydelseskob i udlandet',
|
||||
nameEnglish: 'VAT on services from abroad',
|
||||
description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt',
|
||||
amount: vatReport.boxD,
|
||||
basis: vatReport.basis4,
|
||||
},
|
||||
{
|
||||
boxNumber: 4,
|
||||
nameDanish: 'Kobsmoms',
|
||||
nameEnglish: 'Input VAT',
|
||||
description: 'Fradragsberettiget moms af kob',
|
||||
amount: vatReport.boxB,
|
||||
basis: undefined, // Backend doesn't provide a specific basis for input VAT
|
||||
},
|
||||
];
|
||||
}, [vatReport]);
|
||||
|
||||
// Calculate totals from real data
|
||||
const outputVAT = vatReport?.totalOutputVat ?? 0;
|
||||
const inputVAT = vatReport?.totalInputVat ?? 0;
|
||||
const netVAT = vatReport?.netVat ?? 0;
|
||||
|
||||
// Pie chart config
|
||||
const pieData = [
|
||||
{ type: 'Salgsmoms', value: mockVATReport[0].amount },
|
||||
{ type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount },
|
||||
{ type: 'Købsmoms (fradrag)', value: inputVAT },
|
||||
{ type: 'Energiafgifter (fradrag)', value: energyDuties },
|
||||
];
|
||||
const pieData = useMemo(() => {
|
||||
if (!vatReport) return [];
|
||||
return [
|
||||
{ type: 'Salgsmoms', value: vatReport.boxA },
|
||||
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
|
||||
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
|
||||
].filter(d => d.value > 0);
|
||||
}, [vatReport, inputVAT]);
|
||||
|
||||
const pieConfig = {
|
||||
data: pieData,
|
||||
|
|
@ -243,96 +201,49 @@ export default function Momsindberetning() {
|
|||
},
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
Modal.confirm({
|
||||
title: 'Indsend momsangivelse',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>Du er ved at indsende momsangivelse for:</p>
|
||||
<p>
|
||||
<Text strong>Periode:</Text> {formatPeriod(selectedPeriod.toDate())}
|
||||
</p>
|
||||
<p>
|
||||
<Text strong>Moms til betaling:</Text>{' '}
|
||||
<Text
|
||||
style={{
|
||||
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
{netVAT < 0 ? ' (tilgode)' : ''}
|
||||
</Text>
|
||||
</p>
|
||||
<Alert
|
||||
message="Denne handling kan ikke fortrydes"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: 'Indsend til SKAT',
|
||||
cancelText: 'Annuller',
|
||||
onOk: () => {
|
||||
message.success('Momsangivelse indsendt til SKAT');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return (
|
||||
<Tag color="green" icon={<CheckCircleOutlined />}>
|
||||
Godkendt
|
||||
</Tag>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Tag color="blue" icon={<ClockCircleOutlined />}>
|
||||
Afventer
|
||||
</Tag>
|
||||
);
|
||||
case 'rejected':
|
||||
return (
|
||||
<Tag color="red" icon={<ExclamationCircleOutlined />}>
|
||||
Afvist
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Momsindberetning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Momsindberetning
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
>
|
||||
Forhåndsvis
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Momsindberetning"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
disabled={!vatReport}
|
||||
>
|
||||
Forhåndsvis
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* SKAT submission notice */}
|
||||
<Alert
|
||||
type="warning"
|
||||
message="Indberetning skal ske manuelt på skat.dk"
|
||||
description="Automatisk indberetning til SKAT er endnu ikke implementeret. Brug disse tal til at udfylde momsangivelsen på skat.dk manuelt."
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{/* Period Selection */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
|
|
@ -343,7 +254,7 @@ export default function Momsindberetning() {
|
|||
onChange={setPeriodType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Månedlig' },
|
||||
{ value: 'monthly', label: 'Maanedlig' },
|
||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -356,12 +267,26 @@ export default function Momsindberetning() {
|
|||
<Tag color="blue">
|
||||
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
|
||||
</Tag>
|
||||
{vatReport && (
|
||||
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Fejl ved indlaesning af momsdata"
|
||||
description={error.message}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Udgående moms"
|
||||
|
|
@ -372,7 +297,7 @@ export default function Momsindberetning() {
|
|||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Indgående moms (fradrag)"
|
||||
|
|
@ -383,18 +308,7 @@ export default function Momsindberetning() {
|
|||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Energiafgifter (fradrag)"
|
||||
value={energyDuties}
|
||||
precision={2}
|
||||
formatter={(value) => formatCurrency(value as number)}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
|
||||
|
|
@ -413,87 +327,57 @@ export default function Momsindberetning() {
|
|||
<Row gutter={16}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="Momsangivelse - Rubrikker" size="small">
|
||||
<Table
|
||||
dataSource={mockVATReport}
|
||||
columns={columns}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={3}>
|
||||
<Text strong>Moms til betaling / tilgode</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text
|
||||
strong
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{netVAT >= 0 ? '' : '-'}
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
{vatBoxes.length > 0 ? (
|
||||
<Table
|
||||
dataSource={vatBoxes}
|
||||
columns={columns}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={3}>
|
||||
<Text strong>Moms til betaling / tilgode</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text
|
||||
strong
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{netVAT >= 0 ? '' : '-'}
|
||||
{formatCurrency(Math.abs(netVAT))}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="Ingen momsdata for den valgte periode" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
|
||||
<Pie {...pieConfig} />
|
||||
{pieData.length > 0 ? (
|
||||
<Pie {...pieConfig} />
|
||||
) : (
|
||||
<Empty description="Ingen data at vise" style={{ height: 200 }} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Tidligere indberetninger" size="small">
|
||||
{mockSubmissions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>
|
||||
{dayjs(sub.period, 'YYYY-MM').format('MMMM YYYY')}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Indsendt {formatDate(sub.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{getStatusTag(sub.status)}
|
||||
<br />
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
sub.netVAT >= 0
|
||||
? accountingColors.debit
|
||||
: accountingColors.credit,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(sub.netVAT)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
||||
<Text type="secondary">
|
||||
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -512,18 +396,25 @@ export default function Momsindberetning() {
|
|||
Download PDF
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
key="skat-link"
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => {
|
||||
window.open('https://skat.dk', '_blank');
|
||||
setIsPreviewOpen(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
Indsend til SKAT
|
||||
Ga til skat.dk
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
message="Brug disse tal til at udfylde momsangivelsen på skat.dk"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
|
||||
|
|
@ -537,22 +428,24 @@ export default function Momsindberetning() {
|
|||
|
||||
<Divider />
|
||||
|
||||
<Table
|
||||
dataSource={mockVATReport}
|
||||
columns={[
|
||||
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: 'Beløb',
|
||||
align: 'right',
|
||||
render: (v) => formatCurrency(v),
|
||||
},
|
||||
]}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
{vatBoxes.length > 0 && (
|
||||
<Table
|
||||
dataSource={vatBoxes}
|
||||
columns={[
|
||||
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
|
||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: 'Belob',
|
||||
align: 'right',
|
||||
render: (v: number) => formatCurrency(v),
|
||||
},
|
||||
]}
|
||||
rowKey="boxNumber"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens';
|
|||
import { accountingColors } from '@/styles/theme';
|
||||
import { AmountText } from '@/components/shared/AmountText';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Order, OrderLine, OrderStatus } from '@/types/order';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
|
||||
|
|
@ -140,7 +141,7 @@ export default function Ordrer() {
|
|||
|
||||
const handleSubmitCreate = async () => {
|
||||
if (!company || !currentFiscalYear) {
|
||||
showError('Virksomhed eller regnskabsaar ikke valgt');
|
||||
showError('Virksomhed eller regnskabsår ikke valgt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -226,12 +227,12 @@ export default function Ordrer() {
|
|||
const handleConfirmOrder = async () => {
|
||||
if (!selectedOrder) return;
|
||||
if (selectedOrder.lines.length === 0) {
|
||||
showWarning('Tilfoej mindst en linje foer bekraeftelse');
|
||||
showWarning('Tilføj mindst en linje før bekræftelse');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirmOrderMutation.mutateAsync(selectedOrder.id);
|
||||
showSuccess('Ordre bekraeftet');
|
||||
showSuccess('Ordre bekræftet');
|
||||
// Refresh would happen via query invalidation
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
|
|
@ -276,7 +277,7 @@ export default function Ordrer() {
|
|||
|
||||
const handleSubmitConvert = async () => {
|
||||
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
|
||||
showWarning('Vaelg mindst en linje at fakturere');
|
||||
showWarning('Vælg mindst en linje at fakturere');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -346,7 +347,7 @@ export default function Ordrer() {
|
|||
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Beloeb',
|
||||
title: 'Beløb',
|
||||
dataIndex: 'amountTotal',
|
||||
key: 'amountTotal',
|
||||
width: 120,
|
||||
|
|
@ -371,7 +372,7 @@ export default function Ordrer() {
|
|||
align: 'center',
|
||||
filters: [
|
||||
{ text: 'Kladde', value: 'draft' },
|
||||
{ text: 'Bekraeftet', value: 'confirmed' },
|
||||
{ text: 'Bekræftet', value: 'confirmed' },
|
||||
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
|
||||
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
|
||||
{ text: 'Annulleret', value: 'cancelled' },
|
||||
|
|
@ -399,37 +400,28 @@ export default function Ordrer() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Ordrer
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
|
||||
Ny ordre
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Ordrer"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Ordrer' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
|
||||
Ny ordre
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Alert
|
||||
message="Fejl ved indlaesning af ordrer"
|
||||
message="Fejl ved indlæsning af ordrer"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Proev igen
|
||||
Prøv igen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -454,7 +446,7 @@ export default function Ordrer() {
|
|||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Bekraeftede"
|
||||
title="Bekræftede"
|
||||
value={stats.confirmed}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
/>
|
||||
|
|
@ -463,7 +455,7 @@ export default function Ordrer() {
|
|||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Samlet vaerdi"
|
||||
title="Samlet værdi"
|
||||
value={stats.totalValue}
|
||||
precision={2}
|
||||
valueStyle={{ color: accountingColors.credit }}
|
||||
|
|
@ -477,7 +469,7 @@ export default function Ordrer() {
|
|||
<Card size="small" style={{ marginBottom: spacing.lg }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="Soeg ordre..."
|
||||
placeholder="Søg ordre..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
|
|
@ -491,7 +483,7 @@ export default function Ordrer() {
|
|||
options={[
|
||||
{ value: 'all', label: 'Alle status' },
|
||||
{ value: 'draft', label: 'Kladde' },
|
||||
{ value: 'confirmed', label: 'Bekraeftet' },
|
||||
{ value: 'confirmed', label: 'Bekræftet' },
|
||||
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
|
||||
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
|
||||
{ value: 'cancelled', label: 'Annulleret' },
|
||||
|
|
@ -503,7 +495,7 @@ export default function Ordrer() {
|
|||
{/* Order Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin tip="Indlaeser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
) : filteredOrders.length > 0 ? (
|
||||
|
|
@ -518,7 +510,7 @@ export default function Ordrer() {
|
|||
<EmptyState
|
||||
variant="default"
|
||||
title="Ingen ordrer"
|
||||
description={searchText ? 'Ingen ordrer matcher din soegning' : 'Opret din foerste ordre'}
|
||||
description={searchText ? 'Ingen ordrer matcher din søgning' : 'Opret din første ordre'}
|
||||
primaryAction={
|
||||
!searchText
|
||||
? {
|
||||
|
|
@ -546,11 +538,11 @@ export default function Ordrer() {
|
|||
<Form.Item
|
||||
name="customerId"
|
||||
label="Kunde"
|
||||
rules={[{ required: true, message: 'Vaelg kunde' }]}
|
||||
rules={[{ required: true, message: 'Vælg kunde' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Vaelg kunde"
|
||||
placeholder="Vælg kunde"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
|
|
@ -576,7 +568,7 @@ export default function Ordrer() {
|
|||
<Form.Item name="reference" label="Reference">
|
||||
<Input placeholder="Projektnavn, tilbudsnr., etc." />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="Bemaerkninger">
|
||||
<Form.Item name="notes" label="Bemærkninger">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
|
@ -612,7 +604,7 @@ export default function Ordrer() {
|
|||
onClick={handleOpenAddLineModal}
|
||||
loading={addOrderLineMutation.isPending}
|
||||
>
|
||||
Tilfoej linje
|
||||
Tilføj linje
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
|
|
@ -621,13 +613,13 @@ export default function Ordrer() {
|
|||
loading={confirmOrderMutation.isPending}
|
||||
disabled={selectedOrder.lines.length === 0}
|
||||
>
|
||||
Bekraeft
|
||||
Bekræft
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canShowConvertToInvoice(selectedOrder) && (
|
||||
<Tooltip
|
||||
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
|
||||
title={selectedOrder.status === 'draft' ? 'Bekræft ordren først' : undefined}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
|
|
@ -720,7 +712,7 @@ export default function Ordrer() {
|
|||
) : (
|
||||
<Alert
|
||||
message="Ingen linjer endnu"
|
||||
description="Tilfoej linjer for at kunne bekraefte ordren."
|
||||
description="Tilføj linjer for at kunne bekræfte ordren."
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
|
|
@ -732,13 +724,13 @@ export default function Ordrer() {
|
|||
<Col span={12}>
|
||||
{selectedOrder.notes && (
|
||||
<>
|
||||
<Text type="secondary">Bemaerkninger:</Text>
|
||||
<Text type="secondary">Bemærkninger:</Text>
|
||||
<p>{selectedOrder.notes}</p>
|
||||
</>
|
||||
)}
|
||||
{selectedOrder.cancelledReason && (
|
||||
<>
|
||||
<Text type="secondary">Annulleringsaarsag:</Text>
|
||||
<Text type="secondary">Annulleringsårsag:</Text>
|
||||
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -746,7 +738,7 @@ export default function Ordrer() {
|
|||
<Col span={12}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary">Beloeb ex. moms: </Text>
|
||||
<Text type="secondary">Beløb ex. moms: </Text>
|
||||
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
|
|
@ -794,7 +786,7 @@ export default function Ordrer() {
|
|||
>
|
||||
<Alert
|
||||
message="Advarsel"
|
||||
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uaendrede."
|
||||
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uændrede."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
|
|
@ -802,8 +794,8 @@ export default function Ordrer() {
|
|||
<Form form={cancelForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="Aarsag til annullering"
|
||||
rules={[{ required: true, message: 'Angiv aarsag' }]}
|
||||
label="Årsag til annullering"
|
||||
rules={[{ required: true, message: 'Angiv årsag' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
|
||||
</Form.Item>
|
||||
|
|
@ -812,14 +804,14 @@ export default function Ordrer() {
|
|||
|
||||
{/* Add Line Modal */}
|
||||
<Modal
|
||||
title="Tilfoej linje"
|
||||
title="Tilføj linje"
|
||||
open={isAddLineModalOpen}
|
||||
onCancel={() => {
|
||||
setIsAddLineModalOpen(false);
|
||||
setSelectedProductId(null);
|
||||
}}
|
||||
onOk={handleSubmitAddLine}
|
||||
okText="Tilfoej"
|
||||
okText="Tilføj"
|
||||
cancelText="Annuller"
|
||||
confirmLoading={addOrderLineMutation.isPending}
|
||||
width={550}
|
||||
|
|
@ -840,7 +832,7 @@ export default function Ordrer() {
|
|||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="product">Vaelg produkt</Radio.Button>
|
||||
<Radio.Button value="product">Vælg produkt</Radio.Button>
|
||||
<Radio.Button value="freetext">Fritekst</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
|
@ -850,11 +842,11 @@ export default function Ordrer() {
|
|||
label="Produkt"
|
||||
required
|
||||
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
|
||||
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
|
||||
help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Soeg efter produkt..."
|
||||
placeholder="Søg efter produkt..."
|
||||
optionFilterProp="children"
|
||||
value={selectedProductId}
|
||||
onChange={handleProductSelect}
|
||||
|
|
@ -922,7 +914,7 @@ export default function Ordrer() {
|
|||
<Form.Item
|
||||
name="vatCode"
|
||||
label="Momskode"
|
||||
rules={[{ required: true, message: 'Vaelg momskode' }]}
|
||||
rules={[{ required: true, message: 'Vælg momskode' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={addLineMode === 'product' && !!selectedProductId}
|
||||
|
|
@ -952,8 +944,8 @@ export default function Ordrer() {
|
|||
width={600}
|
||||
>
|
||||
<Alert
|
||||
message="Vaelg linjer til fakturering"
|
||||
description="Vaelg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
|
||||
message="Vælg linjer til fakturering"
|
||||
description="Vælg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: spacing.lg }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
InputNumber,
|
||||
Select,
|
||||
AutoComplete,
|
||||
Spin,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -20,6 +19,8 @@ import {
|
|||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Switch,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||
import {
|
||||
|
|
@ -45,9 +46,10 @@ import { formatDate, formatCurrency } from '@/lib/formatters';
|
|||
import { spacing } from '@/styles/designTokens';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// VAT code options
|
||||
const vatCodeOptions = [
|
||||
|
|
@ -310,33 +312,57 @@ export default function Produkter() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: spacing.xl }}>
|
||||
<Spin size="large" />
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
/>
|
||||
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
|
||||
</Row>
|
||||
<Card><Skeleton active paragraph={{ rows: 8 }} /></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
message="Fejl ved indlæsning af produkter"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
/>
|
||||
<Alert
|
||||
message="Fejl ved indlæsning af produkter"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" onClick={() => window.location.reload()}>
|
||||
Prøv igen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: spacing.lg, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Produkter
|
||||
</Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Opret produkt
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Produkter"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Opret produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Statistics */}
|
||||
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
|
||||
|
|
@ -373,12 +399,10 @@ export default function Produkter() {
|
|||
allowClear
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button
|
||||
type={showInactive ? 'primary' : 'default'}
|
||||
onClick={() => setShowInactive(!showInactive)}
|
||||
>
|
||||
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
|
||||
</Button>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
|
||||
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Divider,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
|
|
@ -22,6 +22,8 @@ import {
|
|||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -29,24 +31,45 @@ export default function Settings() {
|
|||
const { company } = useCompany();
|
||||
const [companyForm] = Form.useForm();
|
||||
const [preferencesForm] = Form.useForm();
|
||||
const updateCompanyMutation = useUpdateCompany();
|
||||
|
||||
const handleSaveCompany = async () => {
|
||||
try {
|
||||
const values = await companyForm.validateFields();
|
||||
console.log('Saving company:', values);
|
||||
|
||||
if (!company?.id) {
|
||||
message.error('Ingen virksomhed valgt');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCompanyMutation.mutateAsync({
|
||||
id: company.id,
|
||||
input: {
|
||||
name: values.name,
|
||||
cvr: values.cvr,
|
||||
address: values.address,
|
||||
city: values.city,
|
||||
postalCode: values.postalCode,
|
||||
},
|
||||
});
|
||||
message.success('Virksomhedsoplysninger gemt');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved gemning: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePreferences = async () => {
|
||||
try {
|
||||
const values = await preferencesForm.validateFields();
|
||||
console.log('Saving preferences:', values);
|
||||
message.success('Præferencer gemt');
|
||||
await preferencesForm.validateFields();
|
||||
// TODO: Backend does not yet have a preferences mutation.
|
||||
// Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint.
|
||||
message.info('Præferencer er endnu ikke forbundet til backend');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(`Fejl ved gemning: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -282,56 +305,19 @@ export default function Settings() {
|
|||
<Title level={5} style={{ margin: 0 }}>
|
||||
Tilknyttede bankkonti
|
||||
</Title>
|
||||
<Button type="primary">Tilføj bankkonto</Button>
|
||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Mock bank accounts */}
|
||||
{[
|
||||
{
|
||||
id: '1',
|
||||
bankName: 'Danske Bank',
|
||||
accountName: 'Erhvervskonto',
|
||||
accountNumber: '1234-5678901234',
|
||||
ledgerAccount: '1000 - Bank',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
bankName: 'Nordea',
|
||||
accountName: 'Opsparingskonto',
|
||||
accountNumber: '9876-5432109876',
|
||||
ledgerAccount: '1010 - Bank opsparing',
|
||||
isActive: true,
|
||||
},
|
||||
].map((account) => (
|
||||
<Card key={account.id} size="small">
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<Text strong>{account.bankName}</Text>
|
||||
<Tag color="blue">{account.accountName}</Tag>
|
||||
{account.isActive && <Tag color="green">Aktiv</Tag>}
|
||||
</Space>
|
||||
<Text type="secondary">{account.accountNumber}</Text>
|
||||
<Text type="secondary">
|
||||
Bogføringskonto: {account.ledgerAccount}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small">Rediger</Button>
|
||||
<Button size="small" danger>
|
||||
Fjern
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="Ingen bankkonti tilknyttet endnu"
|
||||
>
|
||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
|
||||
Tilføj bankkonto
|
||||
</Button>
|
||||
</Empty>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
|
|
@ -345,6 +331,7 @@ export default function Settings() {
|
|||
),
|
||||
children: (
|
||||
<Card>
|
||||
<DemoDataDisclaimer message="Brugerstyring er under udvikling" />
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -356,50 +343,15 @@ export default function Settings() {
|
|||
<Title level={5} style={{ margin: 0 }}>
|
||||
Brugere med adgang
|
||||
</Title>
|
||||
<Button type="primary">Inviter bruger</Button>
|
||||
<Button type="primary" disabled>Inviter bruger</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Mock users */}
|
||||
{[
|
||||
{
|
||||
id: '1',
|
||||
name: 'Admin Bruger',
|
||||
email: 'admin@example.com',
|
||||
role: 'Administrator',
|
||||
lastLogin: '2025-01-17',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Bogholder',
|
||||
email: 'bogholder@example.com',
|
||||
role: 'Bogholder',
|
||||
lastLogin: '2025-01-16',
|
||||
},
|
||||
].map((user) => (
|
||||
<Card key={user.id} size="small">
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{user.name}</Text>
|
||||
<Text type="secondary">{user.email}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Tag color={user.role === 'Administrator' ? 'gold' : 'blue'}>
|
||||
{user.role}
|
||||
</Tag>
|
||||
<Text type="secondary">
|
||||
Sidste login: {user.lastLogin}
|
||||
</Text>
|
||||
<Button size="small">Rediger</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Text type="secondary">
|
||||
Brugere med adgang til denne virksomhed vil blive vist her,
|
||||
når funktionen er implementeret.
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Company, CompanyRole } from '@/types/accounting';
|
||||
import type { CompanyRole, CompanyWithRole } from '@/types/accounting';
|
||||
|
||||
interface CompanyState {
|
||||
// Current active company
|
||||
activeCompany: Company | null;
|
||||
// List of available companies
|
||||
companies: Company[];
|
||||
// Current active company (includes role from myCompanies query)
|
||||
activeCompany: CompanyWithRole | null;
|
||||
// List of available companies (includes role from myCompanies query)
|
||||
companies: CompanyWithRole[];
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setActiveCompany: (company: Company) => void;
|
||||
setCompanies: (companies: Company[]) => void;
|
||||
setActiveCompany: (company: CompanyWithRole) => void;
|
||||
setCompanies: (companies: CompanyWithRole[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
clearActiveCompany: () => void;
|
||||
}
|
||||
|
|
@ -53,11 +53,11 @@ export const useCompanies = () =>
|
|||
useCompanyStore((state) => state.companies);
|
||||
|
||||
// Get the current user's role for the active company
|
||||
// Returns 'owner' as default for now - in production this would come from the server
|
||||
// Returns the role from the myCompanies query data stored on the active company
|
||||
export const useActiveCompanyRole = (): CompanyRole => {
|
||||
// Placeholder: In a real implementation, this would check the user's role
|
||||
// for the currently active company from the server/auth context
|
||||
return 'owner';
|
||||
const activeCompany = useCompanyStore((state) => state.activeCompany);
|
||||
// Return the actual role from the CompanyWithRole data, default to 'viewer' if not set
|
||||
return activeCompany?.role ?? 'viewer';
|
||||
};
|
||||
|
||||
// Helper functions for user roles
|
||||
|
|
@ -88,9 +88,8 @@ export function getRoleColor(role: CompanyRole): string {
|
|||
}
|
||||
|
||||
// Hook to check if current user can administer the company
|
||||
// This is a placeholder - in a real app, this would check the user's role
|
||||
// Checks if the user has Owner role for the active company
|
||||
export function useCanAdmin(): boolean {
|
||||
// For now, return true to allow all users to manage access
|
||||
// In production, this should check the current user's role
|
||||
return true;
|
||||
const role = useActiveCompanyRole();
|
||||
return role === 'owner';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface Account {
|
|||
type: AccountType;
|
||||
parentId?: string;
|
||||
isActive: boolean;
|
||||
isSystemAccount?: boolean;
|
||||
description?: string;
|
||||
vatCode?: string;
|
||||
balance: number;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ export type VATCode =
|
|||
* VAT code type classification
|
||||
*/
|
||||
export type VATCodeType =
|
||||
| 'output' // Udgaaende moms (salg)
|
||||
| 'input' // Indgaaende moms (koeb)
|
||||
| 'output' // Udgående moms (salg)
|
||||
| 'input' // Indgående moms (køb)
|
||||
| 'reverse_charge' // Omvendt betalingspligt
|
||||
| 'exempt' // Momsfritaget
|
||||
| 'none'; // Ingen moms
|
||||
|
|
|
|||
|
|
@ -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