Audit v2: fix security, data integrity, compliance, bugs, encoding, UX
Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1c2af6027
commit
709d0a4739
50 changed files with 676 additions and 372 deletions
|
|
@ -1,9 +1,11 @@
|
||||||
|
{"id":"books-0ea","title":"Phase 1+2: Backend security, data integrity, legal compliance","description":"Fix negative debit/credit validation, mandatory document date, event sourcing timestamps, BankingController auth, attachment access check, CVR validation, API key salt, VAT code system, VAT accounts, SAF-T fixes, invoice CVR validation","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:55.640389+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.394948+01:00"}
|
||||||
{"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:24:40.198621+01:00","closed_at":"2026-01-30T22:24:40.198621+01:00","close_reason":"Closed"}
|
{"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:24:40.198621+01:00","closed_at":"2026-01-30T22:24:40.198621+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-0xk","title":"Phase 2: Wire broken features to backend APIs","description":"Connect all console.log-only handlers to real GraphQL mutations: Kassekladde submit, Settings save, Bankafstemning save, Kontooversigt account CRUD, FiscalYear creation, CloseFiscalYearWizard, Void/Copy actions.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.249535+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.305957+01:00","closed_at":"2026-02-05T21:35:30.305957+01:00","close_reason":"Closed"}
|
{"id":"books-0xk","title":"Phase 2: Wire broken features to backend APIs","description":"Connect all console.log-only handlers to real GraphQL mutations: Kassekladde submit, Settings save, Bankafstemning save, Kontooversigt account CRUD, FiscalYear creation, CloseFiscalYearWizard, Void/Copy actions.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.249535+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.305957+01:00","closed_at":"2026-02-05T21:35:30.305957+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
|
{"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
|
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
|
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-8lo","title":"revisit the laytoug and desig nfor kontooversigten.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:06.620288+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.365315+01:00","closed_at":"2026-01-30T14:47:52.365315+01:00","close_reason":"Closed"}
|
{"id":"books-8lo","title":"revisit the laytoug and desig nfor kontooversigten.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:25:06.620288+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.365315+01:00","closed_at":"2026-01-30T14:47:52.365315+01:00","close_reason":"Closed"}
|
||||||
|
{"id":"books-9ig","title":"Phase 3: Critical frontend bugs","description":"Fix closing wizard entries, create-next-year checkbox, Dashboard a-href, Kassekladde filters, edit draft form population, Vis detaljer action, Flere filtre button, duplicate migration","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:57.507387+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.478081+01:00"}
|
||||||
{"id":"books-bj6","title":"Test automatisk pickup","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:04:40.572496+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:05:44.401903+01:00","closed_at":"2026-01-30T14:05:44.401903+01:00","close_reason":"completed"}
|
{"id":"books-bj6","title":"Test automatisk pickup","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:04:40.572496+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:05:44.401903+01:00","closed_at":"2026-01-30T14:05:44.401903+01:00","close_reason":"completed"}
|
||||||
{"id":"books-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"}
|
{"id":"books-byl","title":"opret giv et shortlink på frontenden til backend på /hangfire","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:34.946139+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.62014+01:00","closed_at":"2026-01-30T14:40:44.62014+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
|
{"id":"books-cdf","title":"opret","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:23:39.411558+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T17:45:05.363658+01:00","closed_at":"2026-01-30T17:45:05.363658+01:00","close_reason":"Skipped - task description too vague"}
|
||||||
|
|
@ -14,6 +16,8 @@
|
||||||
{"id":"books-k95","title":"Phase 4: UX consistency \u0026 bug fixes","description":"Danish character encoding, DemoDataDisclaimer deployment, PageHeader adoption, mobile responsiveness, mock data removal, dead buttons.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.471301+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.433843+01:00","closed_at":"2026-02-05T21:35:30.433843+01:00","close_reason":"Closed"}
|
{"id":"books-k95","title":"Phase 4: UX consistency \u0026 bug fixes","description":"Danish character encoding, DemoDataDisclaimer deployment, PageHeader adoption, mobile responsiveness, mock data removal, dead buttons.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.471301+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.433843+01:00","closed_at":"2026-02-05T21:35:30.433843+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-ley","title":"Phase 1: GraphQL Authentication \u0026 Authorization","description":"Add authentication to GraphQL endpoint and authorization checks to all resolvers. Fix: S-01 through S-06, RBAC always returning owner, admin hardcoded email check.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.131213+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.243779+01:00","closed_at":"2026-02-05T21:35:30.243779+01:00","close_reason":"Closed"}
|
{"id":"books-ley","title":"Phase 1: GraphQL Authentication \u0026 Authorization","description":"Add authentication to GraphQL endpoint and authorization checks to all resolvers. Fix: S-01 through S-06, RBAC always returning owner, admin hardcoded email check.","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T21:12:09.131213+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T21:35:30.243779+01:00","closed_at":"2026-02-05T21:35:30.243779+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-ljg","title":"Fjern mock data og kobl frontend til backend GraphQL","description":"Frontend bruger ~2000 linjer hardcoded mock data i stedet for at bruge de eksisterende GraphQL hooks.\n\n## Problem\n- Backend GraphQL API er klar med queries og mutations\n- Frontend har hooks skrevet (useAccounts, useFiscalYears, etc.)\n- Men pages bruger hardcoded mock data i stedet for at kalde hooks\n\n## Filer der skal opdateres\n1. Dashboard.tsx - mock metrics, charts, transactions\n2. Kassekladde.tsx - mock accounts og posteringer \n3. Kontooversigt.tsx - mock kontoplan og balancer\n4. Bankafstemning.tsx - mock bank accounts og transaktioner\n5. FiscalYearSelector.tsx - mock fiscal years\n6. CompanySwitcher.tsx - mock companies\n7. Stores (companyStore, periodStore) - skal initialiseres fra API\n\n## Acceptkriterier\n- Al mock data fjernet fra frontend\n- Alle pages bruger GraphQL hooks til at hente data\n- Stores initialiseres korrekt ved app start\n- Data vises fra backend i UI","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:27:49.225279+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:42:04.17437+01:00","closed_at":"2026-01-30T22:42:04.17437+01:00","close_reason":"Closed"}
|
{"id":"books-ljg","title":"Fjern mock data og kobl frontend til backend GraphQL","description":"Frontend bruger ~2000 linjer hardcoded mock data i stedet for at bruge de eksisterende GraphQL hooks.\n\n## Problem\n- Backend GraphQL API er klar med queries og mutations\n- Frontend har hooks skrevet (useAccounts, useFiscalYears, etc.)\n- Men pages bruger hardcoded mock data i stedet for at kalde hooks\n\n## Filer der skal opdateres\n1. Dashboard.tsx - mock metrics, charts, transactions\n2. Kassekladde.tsx - mock accounts og posteringer \n3. Kontooversigt.tsx - mock kontoplan og balancer\n4. Bankafstemning.tsx - mock bank accounts og transaktioner\n5. FiscalYearSelector.tsx - mock fiscal years\n6. CompanySwitcher.tsx - mock companies\n7. Stores (companyStore, periodStore) - skal initialiseres fra API\n\n## Acceptkriterier\n- Al mock data fjernet fra frontend\n- Alle pages bruger GraphQL hooks til at hente data\n- Stores initialiseres korrekt ved app start\n- Data vises fra backend i UI","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:27:49.225279+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:42:04.17437+01:00","closed_at":"2026-01-30T22:42:04.17437+01:00","close_reason":"Closed"}
|
||||||
|
{"id":"books-m5a","title":"Phase 6+7: Consistency, quality, UX, accessibility","description":"Remove console.logs, adopt PageHeader on 6 pages, standardize loading states, date formats, fix deprecated props, colors, sidebar width, mobile responsiveness, aria-labels, a11y","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:17:00.630867+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.639147+01:00"}
|
||||||
|
{"id":"books-pos","title":"Phase 4+5: Danish encoding + dead buttons","description":"Fix ~45 Danish character encoding issues across 10 files. Fix dead buttons: Download PDF, Eksporter, Administrer fiscal year, bank connections, help link, notification bell","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-02-05T22:16:59.078667+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-02-05T22:17:04.558186+01:00"}
|
||||||
{"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
|
{"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
||||||
{"id":"books-wzq","title":"tilføj en lille disclaimer på alle områder, hvor der er statisk data. brug gerne planning mode","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:22:53.728536+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.557962+01:00","closed_at":"2026-01-30T14:40:44.557962+01:00","close_reason":"Closed"}
|
{"id":"books-wzq","title":"tilføj en lille disclaimer på alle områder, hvor der er statisk data. brug gerne planning mode","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:22:53.728536+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:40:44.557962+01:00","closed_at":"2026-01-30T14:40:44.557962+01:00","close_reason":"Closed"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
using Books.Api.Domain.Invoices;
|
using Books.Api.Domain.Invoices;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using Books.Api.Invoicing.Services;
|
using Books.Api.Invoicing.Services;
|
||||||
using EventFlow.Commands;
|
using EventFlow.Commands;
|
||||||
|
|
||||||
|
|
@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Command handler for creating invoices.
|
/// Command handler for creating invoices.
|
||||||
/// Auto-assigns a sequential invoice number if one is not provided.
|
/// Auto-assigns a sequential invoice number if one is not provided.
|
||||||
|
/// Validates the company has a CVR number (required for invoicing).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CreateInvoiceCommandHandler(
|
public class CreateInvoiceCommandHandler(
|
||||||
IInvoiceNumberService invoiceNumberService)
|
IInvoiceNumberService invoiceNumberService,
|
||||||
|
ICompanyRepository companyRepository)
|
||||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||||
{
|
{
|
||||||
public override async Task ExecuteAsync(
|
public override async Task ExecuteAsync(
|
||||||
|
|
@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler(
|
||||||
CreateInvoiceCommand command,
|
CreateInvoiceCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Validate company has a CVR number (required for invoicing per Danish law)
|
||||||
|
var company = await companyRepository.GetByIdAsync(command.CompanyId, cancellationToken);
|
||||||
|
if (company == null)
|
||||||
|
{
|
||||||
|
throw new DomainException(
|
||||||
|
"COMPANY_NOT_FOUND",
|
||||||
|
$"Company '{command.CompanyId}' not found",
|
||||||
|
$"Virksomheden '{command.CompanyId}' blev ikke fundet");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(company.Cvr))
|
||||||
|
{
|
||||||
|
throw new DomainException(
|
||||||
|
"CVR_REQUIRED_FOR_INVOICE",
|
||||||
|
"Company must have a CVR number to create invoices. Please update company settings.",
|
||||||
|
"Virksomheden skal have et CVR-nummer for at oprette fakturaer. Opdater venligst virksomhedsindstillinger.");
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-assign invoice number if not provided
|
// Auto-assign invoice number if not provided
|
||||||
var invoiceNumber = command.InvoiceNumber;
|
var invoiceNumber = command.InvoiceNumber;
|
||||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler(
|
||||||
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate document date falls within fiscal year range (if document date is set)
|
// Validate document date is set (required for posting per Bogføringsloven)
|
||||||
if (draft?.DocumentDate != null)
|
if (draft?.DocumentDate == null)
|
||||||
|
{
|
||||||
|
throw new DomainException(
|
||||||
|
"DOCUMENT_DATE_REQUIRED",
|
||||||
|
"Document date (bilagsdato) is required for posting a journal entry",
|
||||||
|
"Bilagsdato er påkrævet for bogføring af en postering");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate document date falls within fiscal year range
|
||||||
{
|
{
|
||||||
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
|
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
|
||||||
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,20 @@ public class AttachmentController(
|
||||||
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up the attachment to verify company access
|
||||||
|
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
|
||||||
|
if (attachment == null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user has access to the company that owns this attachment
|
||||||
|
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
|
||||||
|
if (access == null)
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
|
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
|
||||||
|
|
||||||
if (file == null)
|
if (file == null)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections;
|
||||||
using Books.Api.Domain.BankConnections;
|
using Books.Api.Domain.BankConnections;
|
||||||
using EventFlow;
|
using EventFlow;
|
||||||
using EventFlow.Aggregates.ExecutionResults;
|
using EventFlow.Aggregates.ExecutionResults;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Books.Api.Controllers;
|
namespace Books.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/banking")]
|
[Route("api/banking")]
|
||||||
|
[Authorize]
|
||||||
public class BankingController : ControllerBase
|
public class BankingController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICommandBus _commandBus;
|
private readonly ICommandBus _commandBus;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Books.Api.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("[controller]")]
|
|
||||||
public class WeatherForecastController : ControllerBase
|
|
||||||
{
|
|
||||||
private static readonly string[] Summaries =
|
|
||||||
[
|
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
||||||
];
|
|
||||||
|
|
||||||
[HttpGet(Name = "GetWeatherForecast")]
|
|
||||||
public IEnumerable<WeatherForecast> Get()
|
|
||||||
{
|
|
||||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
|
||||||
{
|
|
||||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
||||||
TemperatureC = Random.Shared.Next(-20, 55),
|
|
||||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
|
||||||
})
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,7 @@ public class AttachmentAggregate(AttachmentId id)
|
||||||
_isCreated = true;
|
_isCreated = true;
|
||||||
_companyId = e.CompanyId;
|
_companyId = e.CompanyId;
|
||||||
_transactionId = e.TransactionId;
|
_transactionId = e.TransactionId;
|
||||||
_uploadedAt = DateTimeOffset.UtcNow;
|
_uploadedAt = e.UploadedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Apply(AttachmentLinkedToTransactionEvent e)
|
public void Apply(AttachmentLinkedToTransactionEvent e)
|
||||||
|
|
@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id)
|
||||||
fileSize,
|
fileSize,
|
||||||
storagePath.Trim(),
|
storagePath.Trim(),
|
||||||
uploadedBy,
|
uploadedBy,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
draftId?.Trim(),
|
draftId?.Trim(),
|
||||||
transactionId?.Trim()));
|
transactionId?.Trim()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ public class AttachmentUploadedEvent(
|
||||||
long fileSize,
|
long fileSize,
|
||||||
string storagePath,
|
string storagePath,
|
||||||
string uploadedBy,
|
string uploadedBy,
|
||||||
|
DateTimeOffset uploadedAt,
|
||||||
string? draftId = null,
|
string? draftId = null,
|
||||||
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
|
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
|
||||||
{
|
{
|
||||||
|
|
@ -46,6 +47,11 @@ public class AttachmentUploadedEvent(
|
||||||
|
|
||||||
public string UploadedBy { get; } = uploadedBy;
|
public string UploadedBy { get; } = uploadedBy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp when the attachment was uploaded.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset UploadedAt { get; } = uploadedAt;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional reference to journal entry draft.
|
/// Optional reference to journal entry draft.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
||||||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||||
|
|
||||||
|
// Validate CVR number if provided
|
||||||
|
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
|
||||||
|
throw new DomainException(
|
||||||
|
"INVALID_CVR",
|
||||||
|
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
|
||||||
|
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
|
||||||
|
|
||||||
Emit(new CompanyCreatedEvent(
|
Emit(new CompanyCreatedEvent(
|
||||||
name.Trim(),
|
name.Trim(),
|
||||||
cvr?.Trim(),
|
cvr?.Trim(),
|
||||||
|
|
@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
||||||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||||
|
|
||||||
|
// Validate CVR number if provided
|
||||||
|
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
|
||||||
|
throw new DomainException(
|
||||||
|
"INVALID_CVR",
|
||||||
|
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
|
||||||
|
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
|
||||||
|
|
||||||
Emit(new CompanyUpdatedEvent(
|
Emit(new CompanyUpdatedEvent(
|
||||||
name.Trim(),
|
name.Trim(),
|
||||||
cvr?.Trim(),
|
cvr?.Trim(),
|
||||||
|
|
|
||||||
|
|
@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a single draft line.
|
/// Validates a single draft line.
|
||||||
|
/// Amounts must be non-negative.
|
||||||
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
||||||
/// At least one of DebitAmount or CreditAmount must be > 0.
|
/// At least one of DebitAmount or CreditAmount must be > 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ValidateDraftLine(DraftLine line)
|
private static void ValidateDraftLine(DraftLine line)
|
||||||
{
|
{
|
||||||
|
if (line.DebitAmount < 0)
|
||||||
|
{
|
||||||
|
throw new DomainException(
|
||||||
|
"NEGATIVE_DEBIT_AMOUNT",
|
||||||
|
$"Line {line.LineNumber} has a negative debit amount. Amounts must be non-negative.",
|
||||||
|
$"Linje {line.LineNumber} har et negativt debetbeløb. Beløb skal være ikke-negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.CreditAmount < 0)
|
||||||
|
{
|
||||||
|
throw new DomainException(
|
||||||
|
"NEGATIVE_CREDIT_AMOUNT",
|
||||||
|
$"Line {line.LineNumber} has a negative credit amount. Amounts must be non-negative.",
|
||||||
|
$"Linje {line.LineNumber} har et negativt kreditbeløb. Beløb skal være ikke-negative.");
|
||||||
|
}
|
||||||
|
|
||||||
if (line.DebitAmount > 0 && line.CreditAmount > 0)
|
if (line.DebitAmount > 0 && line.CreditAmount > 0)
|
||||||
{
|
{
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
|
|
|
||||||
|
|
@ -168,24 +168,11 @@ public class VatCalculationService : IVatCalculationService
|
||||||
// - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT)
|
// - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT)
|
||||||
// - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT)
|
// - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT)
|
||||||
// The key insight: VAT follows the same direction as the base transaction
|
// The key insight: VAT follows the same direction as the base transaction
|
||||||
if (isInputVat)
|
vatLine = vatLine with
|
||||||
{
|
{
|
||||||
// Purchases: VAT follows the expense direction (typically debit)
|
DebitAmount = isDebit ? vatAmount : 0,
|
||||||
vatLine = vatLine with
|
CreditAmount = !isDebit ? vatAmount : 0
|
||||||
{
|
};
|
||||||
DebitAmount = isDebit ? vatAmount : 0,
|
|
||||||
CreditAmount = !isDebit ? vatAmount : 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Sales: VAT follows the revenue direction (typically credit)
|
|
||||||
vatLine = vatLine with
|
|
||||||
{
|
|
||||||
DebitAmount = isDebit ? vatAmount : 0,
|
|
||||||
CreditAmount = !isDebit ? vatAmount : 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
vatLines.Add(vatLine);
|
vatLines.Add(vatLine);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent(
|
||||||
string userId,
|
string userId,
|
||||||
string companyId,
|
string companyId,
|
||||||
CompanyRole role,
|
CompanyRole role,
|
||||||
string grantedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
string grantedBy,
|
||||||
|
DateTimeOffset grantedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||||
{
|
{
|
||||||
public string UserId { get; } = userId;
|
public string UserId { get; } = userId;
|
||||||
public string CompanyId { get; } = companyId;
|
public string CompanyId { get; } = companyId;
|
||||||
public CompanyRole Role { get; } = role;
|
public CompanyRole Role { get; } = role;
|
||||||
public string GrantedBy { get; } = grantedBy;
|
public string GrantedBy { get; } = grantedBy;
|
||||||
|
public DateTimeOffset GrantedAt { get; } = grantedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent(
|
||||||
/// Emitted when a user's access to a company is revoked.
|
/// Emitted when a user's access to a company is revoked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserCompanyAccessRevokedEvent(
|
public class UserCompanyAccessRevokedEvent(
|
||||||
string revokedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
string revokedBy,
|
||||||
|
DateTimeOffset revokedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||||
{
|
{
|
||||||
public string RevokedBy { get; } = revokedBy;
|
public string RevokedBy { get; } = revokedBy;
|
||||||
|
public DateTimeOffset RevokedAt { get; } = revokedAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
||||||
}
|
}
|
||||||
|
|
||||||
// If previously revoked, we're re-granting
|
// If previously revoked, we're re-granting
|
||||||
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy));
|
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy, DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
||||||
"Adgang er allerede tilbagekaldt");
|
"Adgang er allerede tilbagekaldt");
|
||||||
}
|
}
|
||||||
|
|
||||||
Emit(new UserCompanyAccessRevokedEvent(revokedBy));
|
Emit(new UserCompanyAccessRevokedEvent(revokedBy, DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Apply(UserCompanyAccessGrantedEvent e)
|
public void Apply(UserCompanyAccessGrantedEvent e)
|
||||||
|
|
@ -82,7 +82,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
||||||
CompanyId = e.CompanyId;
|
CompanyId = e.CompanyId;
|
||||||
Role = e.Role;
|
Role = e.Role;
|
||||||
GrantedBy = e.GrantedBy;
|
GrantedBy = e.GrantedBy;
|
||||||
GrantedAt = DateTimeOffset.UtcNow;
|
GrantedAt = e.GrantedAt;
|
||||||
IsActive = true;
|
IsActive = true;
|
||||||
RevokedAt = null;
|
RevokedAt = null;
|
||||||
RevokedBy = null;
|
RevokedBy = null;
|
||||||
|
|
@ -96,7 +96,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
||||||
public void Apply(UserCompanyAccessRevokedEvent e)
|
public void Apply(UserCompanyAccessRevokedEvent e)
|
||||||
{
|
{
|
||||||
IsActive = false;
|
IsActive = false;
|
||||||
RevokedAt = DateTimeOffset.UtcNow;
|
RevokedAt = e.RevokedAt;
|
||||||
RevokedBy = e.RevokedBy;
|
RevokedBy = e.RevokedBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,19 @@ public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepo
|
||||||
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id });
|
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT {SelectColumns}
|
||||||
|
FROM attachment_read_models
|
||||||
|
WHERE storage_path = @StoragePath AND is_deleted = FALSE
|
||||||
|
""";
|
||||||
|
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { StoragePath = storagePath });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories;
|
||||||
public interface IAttachmentRepository
|
public interface IAttachmentRepository
|
||||||
{
|
{
|
||||||
Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
||||||
|
Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default);
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,15 @@ public static class StandardDanishAccounts
|
||||||
yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110");
|
yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110");
|
||||||
yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110");
|
yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110");
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// MOMSKONTI (VAT Accounts) - 56xx
|
||||||
|
// Standard: 5610 = Købsmoms, 5611 = Salgsmoms, 5620 = EU-erhvervelsesmoms
|
||||||
|
// Required for VAT calculation and reporting
|
||||||
|
// =========================================
|
||||||
|
yield return new("5610", "Købsmoms", AccountType.Liability, "Indgående moms (Input VAT)", null, "5610", true);
|
||||||
|
yield return new("5611", "Salgsmoms", AccountType.Liability, "Udgående moms (Output VAT)", null, "5611", true);
|
||||||
|
yield return new("5620", "EU-erhvervelsesmoms", AccountType.Liability, "EU acquisition VAT", null, "5620", true);
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx
|
// PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx
|
||||||
// Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat
|
// Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Books.Api.Domain;
|
||||||
using Books.Api.EventFlow.ReadModels;
|
using Books.Api.EventFlow.ReadModels;
|
||||||
using Books.Api.EventFlow.Repositories;
|
using Books.Api.EventFlow.Repositories;
|
||||||
using Books.Api.Saft.Models;
|
using Books.Api.Saft.Models;
|
||||||
|
|
@ -294,6 +295,9 @@ public class SaftExportService(
|
||||||
if (isDebit) totalDebit += entry.Amount;
|
if (isDebit) totalDebit += entry.Amount;
|
||||||
else totalCredit += entry.Amount;
|
else totalCredit += entry.Amount;
|
||||||
|
|
||||||
|
// Try to extract VAT information from the entry description
|
||||||
|
var taxInfo = ExtractTaxInformation(entry.Description, entry.Amount);
|
||||||
|
|
||||||
return new SaftTransactionLine(
|
return new SaftTransactionLine(
|
||||||
(idx + 1).ToString(),
|
(idx + 1).ToString(),
|
||||||
accountNumber ?? entry.AccountId.ToString(),
|
accountNumber ?? entry.AccountId.ToString(),
|
||||||
|
|
@ -302,7 +306,7 @@ public class SaftExportService(
|
||||||
creditAmount,
|
creditAmount,
|
||||||
null, // CustomerID - could parse from reference
|
null, // CustomerID - could parse from reference
|
||||||
null, // SupplierID
|
null, // SupplierID
|
||||||
null); // TaxInfo - would need VAT code tracking
|
taxInfo);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
transactions.Add(new SaftTransaction(
|
transactions.Add(new SaftTransaction(
|
||||||
|
|
@ -385,6 +389,37 @@ public class SaftExportService(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts VAT/tax information from a ledger entry description.
|
||||||
|
/// VAT lines generated by the system contain patterns like "Moms U25 (25%)" or "Moms I25 (25%)".
|
||||||
|
/// </summary>
|
||||||
|
private static SaftTaxInformation? ExtractTaxInformation(string? description, decimal amount)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(description))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check for known VAT codes in the description
|
||||||
|
foreach (var vatCodeInfo in VatCodes.All)
|
||||||
|
{
|
||||||
|
if (vatCodeInfo.Code == VatCodes.INGEN)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (description.Contains(vatCodeInfo.Code, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var rate = VatCodes.GetRate(vatCodeInfo.Code);
|
||||||
|
var taxBase = rate > 0 ? Math.Round(amount / rate, 2, MidpointRounding.AwayFromZero) : 0m;
|
||||||
|
|
||||||
|
return new SaftTaxInformation(
|
||||||
|
vatCodeInfo.Code,
|
||||||
|
rate * 100, // TaxPercentage as whole number (25 not 0.25)
|
||||||
|
taxBase,
|
||||||
|
amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a Danish CVR number.
|
/// Validates a Danish CVR number.
|
||||||
/// A valid CVR is exactly 8 digits.
|
/// A valid CVR is exactly 8 digits.
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ public class SaftXmlBuilder
|
||||||
writer.WriteStartElement("Header");
|
writer.WriteStartElement("Header");
|
||||||
|
|
||||||
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
|
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
|
||||||
|
writer.WriteElementString("AuditFileCountry", "DK");
|
||||||
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
|
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
|
||||||
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
|
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
|
||||||
writer.WriteElementString("SoftwareID", header.SoftwareID);
|
writer.WriteElementString("SoftwareID", header.SoftwareID);
|
||||||
|
|
@ -72,6 +73,12 @@ public class SaftXmlBuilder
|
||||||
WriteContact(writer, company.Contact);
|
WriteContact(writer, company.Contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF-T DK requires TaxRegistrationNumber with "DK" prefix + CVR
|
||||||
|
if (!string.IsNullOrEmpty(company.RegistrationNumber))
|
||||||
|
{
|
||||||
|
writer.WriteElementString("TaxRegistrationNumber", "DK" + company.RegistrationNumber);
|
||||||
|
}
|
||||||
|
|
||||||
writer.WriteEndElement(); // Company
|
writer.WriteEndElement(); // Company
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
namespace Books.Api;
|
|
||||||
|
|
||||||
public class WeatherForecast
|
|
||||||
{
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureC { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -105,7 +105,7 @@ export function DocumentUploadModal({
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
try {
|
try {
|
||||||
await postDraftMutation.mutateAsync(result.draftId);
|
await postDraftMutation.mutateAsync(result.draftId);
|
||||||
message.success('Bogfoert!');
|
message.success('Bogført!');
|
||||||
onConfirm();
|
onConfirm();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error('Kunne ikke bogføre. Prøv igen.');
|
message.error('Kunne ikke bogføre. Prøv igen.');
|
||||||
|
|
@ -154,7 +154,7 @@ export function DocumentUploadModal({
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
AI-tjenesten udtraekker information fra dokumentet
|
AI-tjenesten udtrækker information fra dokumentet
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -206,7 +206,7 @@ export function DocumentUploadModal({
|
||||||
Luk
|
Luk
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="view" type="primary" onClick={onConfirm}>
|
<Button key="view" type="primary" onClick={onConfirm}>
|
||||||
Gaa til kladde
|
Gå til kladde
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
|
|
@ -279,7 +279,7 @@ export function DocumentUploadModal({
|
||||||
loading={isPosting}
|
loading={isPosting}
|
||||||
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
|
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
|
||||||
>
|
>
|
||||||
Godkend og bogfoer
|
Godkend og bogfør
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
|
@ -297,7 +297,7 @@ export function DocumentUploadModal({
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
|
||||||
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
|
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
|
||||||
Foreslaaet bogfoering
|
Foreslået bogføring
|
||||||
</Title>
|
</Title>
|
||||||
{journalLines.length > 0 && (
|
{journalLines.length > 0 && (
|
||||||
isBalanced ? (
|
isBalanced ? (
|
||||||
|
|
@ -341,8 +341,8 @@ export function DocumentUploadModal({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
message="Ingen kontobogfoering foreslaaet"
|
message="Ingen kontobogføring foreslået"
|
||||||
description="AI kunne ikke foreslaa konti til dette dokument. Du kan tilfoeje dokumentet til kladden og bogfoere manuelt."
|
description="AI kunne ikke foreslå konti til dette dokument. Du kan tilføje dokumentet til kladden og bogføre manuelt."
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
icon={<InfoCircleOutlined />}
|
icon={<InfoCircleOutlined />}
|
||||||
|
|
@ -394,7 +394,7 @@ export function DocumentUploadModal({
|
||||||
<Divider style={{ margin: `${spacing.md}px 0` }} />
|
<Divider style={{ margin: `${spacing.md}px 0` }} />
|
||||||
<Alert
|
<Alert
|
||||||
message="Ingen matchende banktransaktion fundet"
|
message="Ingen matchende banktransaktion fundet"
|
||||||
description={`Der blev ikke fundet en pending banktransaktion paa ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`}
|
description={`Der blev ikke fundet en pending banktransaktion på ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`}
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
icon={<InfoCircleOutlined />}
|
icon={<InfoCircleOutlined />}
|
||||||
|
|
@ -406,7 +406,7 @@ export function DocumentUploadModal({
|
||||||
{result.accountSuggestion && (
|
{result.accountSuggestion && (
|
||||||
<div style={{ marginTop: spacing.sm }}>
|
<div style={{ marginTop: spacing.sm }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
Kontoforslag baseret paa AI-analyse (
|
Kontoforslag baseret på AI-analyse (
|
||||||
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
|
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -474,7 +474,7 @@ function ExtractedInfoSection({
|
||||||
style={{ marginBottom: hasLineItems ? 12 : 0 }}
|
style={{ marginBottom: hasLineItems ? 12 : 0 }}
|
||||||
>
|
>
|
||||||
{extraction.vendor && (
|
{extraction.vendor && (
|
||||||
<Descriptions.Item label="Leverandoer" span={extraction.vendorCvr ? 1 : 2}>
|
<Descriptions.Item label="Leverandør" span={extraction.vendorCvr ? 1 : 2}>
|
||||||
<Text strong>{extraction.vendor}</Text>
|
<Text strong>{extraction.vendor}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Select, Space, Typography, Tag, Skeleton } from 'antd';
|
import { Select, Space, Typography, Tag } from 'antd';
|
||||||
import { ShopOutlined } from '@ant-design/icons';
|
import { ShopOutlined } from '@ant-design/icons';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
import { formatCVR } from '@/lib/formatters';
|
import { formatCVR } from '@/lib/formatters';
|
||||||
|
|
@ -22,7 +22,7 @@ export default function CompanySwitcher({ compact = false }: CompanySwitcherProp
|
||||||
};
|
};
|
||||||
|
|
||||||
if (companies.length === 0) {
|
if (companies.length === 0) {
|
||||||
return <Skeleton.Input style={{ width: 200 }} active />;
|
return <Text type="secondary">Ingen virksomheder</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
MinusCircleOutlined,
|
MinusCircleOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { usePeriodStore } from '@/stores/periodStore';
|
import { usePeriodStore } from '@/stores/periodStore';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
|
import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
|
||||||
|
|
@ -50,6 +51,7 @@ interface FiscalYearSelectorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
|
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { activeCompany } = useCompanyStore();
|
const { activeCompany } = useCompanyStore();
|
||||||
const {
|
const {
|
||||||
fiscalYears,
|
fiscalYears,
|
||||||
|
|
@ -126,8 +128,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
||||||
if (onManage) {
|
if (onManage) {
|
||||||
onManage();
|
onManage();
|
||||||
} else {
|
} else {
|
||||||
// Navigate to settings page
|
navigate('/indstillinger');
|
||||||
console.log('Navigate to fiscal year settings');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Layout, Space, Button, Dropdown, Avatar, Divider, Tooltip, Badge } from 'antd';
|
import { Layout, Space, Button, Dropdown, Avatar, Divider, Tooltip, Badge, Popover, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
|
@ -129,22 +129,28 @@ export default function Header({ isMobile = false }: HeaderProps) {
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<QuestionCircleOutlined />}
|
icon={<QuestionCircleOutlined />}
|
||||||
onClick={() => window.open('https://help.books.dk', '_blank')}
|
onClick={() => message.info('Hjælp er under udvikling')}
|
||||||
aria-label="Hjælp"
|
aria-label="Hjælp"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Tooltip title="Notifikationer">
|
<Popover
|
||||||
<Badge count={0} size="small">
|
content="Ingen nye notifikationer"
|
||||||
<Button
|
title="Notifikationer"
|
||||||
type="text"
|
trigger="click"
|
||||||
icon={<BellOutlined />}
|
placement="bottomRight"
|
||||||
onClick={() => navigate('/indstillinger')}
|
>
|
||||||
aria-label="Notifikationer"
|
<Tooltip title="Notifikationer">
|
||||||
/>
|
<Badge count={0} size="small">
|
||||||
</Badge>
|
<Button
|
||||||
</Tooltip>
|
type="text"
|
||||||
|
icon={<BellOutlined />}
|
||||||
|
aria-label="Notifikationer"
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ export default function Sidebar() {
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onCollapse={toggleSidebar}
|
onCollapse={toggleSidebar}
|
||||||
width={220}
|
width={220}
|
||||||
|
aria-label="Hovednavigation"
|
||||||
style={{
|
style={{
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ import {
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
import type { FiscalYear } from '@/types/periods';
|
import type { FiscalYear } from '@/types/periods';
|
||||||
import type { Account, Transaction } from '@/types/accounting';
|
import type { Account, Transaction } from '@/types/accounting';
|
||||||
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
import { useCloseFiscalYear, useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||||
|
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const { Text, Title, Paragraph } = Typography;
|
const { Text, Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
@ -85,6 +87,9 @@ export default function CloseFiscalYearWizard({
|
||||||
} = usePeriodStore();
|
} = usePeriodStore();
|
||||||
|
|
||||||
const closeFiscalYearMutation = useCloseFiscalYear();
|
const closeFiscalYearMutation = useCloseFiscalYear();
|
||||||
|
const createFiscalYearMutation = useCreateFiscalYear();
|
||||||
|
const createDraftMutation = useCreateJournalEntryDraft();
|
||||||
|
const updateDraftMutation = useUpdateJournalEntryDraft();
|
||||||
|
|
||||||
// Reset wizard when opened
|
// Reset wizard when opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -164,34 +169,70 @@ export default function CloseFiscalYearWizard({
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
|
// 1. Post closing entries as journal entry drafts
|
||||||
// in generateClosingEntries() but never actually posted to the ledger.
|
for (const entry of closingEntries) {
|
||||||
// Before closing the fiscal year, these closing entries MUST be posted:
|
// Create a draft for each closing entry
|
||||||
// 1. Revenue accounts should be zeroed out to the result account
|
const draft = await createDraftMutation.mutateAsync({
|
||||||
// 2. Expense accounts should be zeroed out to the result account
|
companyId: fiscalYear.companyId,
|
||||||
// 3. The net result should be transferred to the equity account (resultAccountId)
|
name: entry.descriptionDanish,
|
||||||
// Without posting these entries, the opening balances for the next year will be incorrect.
|
documentDate: fiscalYear.endDate,
|
||||||
|
description: entry.descriptionDanish,
|
||||||
|
fiscalYearId: fiscalYear.id,
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Close open periods if requested (local store)
|
// Add lines to the draft
|
||||||
|
if (entry.lines.length > 0) {
|
||||||
|
await updateDraftMutation.mutateAsync({
|
||||||
|
id: draft.id,
|
||||||
|
lines: entry.lines.map((line, idx) => ({
|
||||||
|
lineNumber: idx + 1,
|
||||||
|
accountId: line.accountId,
|
||||||
|
debitAmount: line.debit,
|
||||||
|
creditAmount: line.credit,
|
||||||
|
description: entry.descriptionDanish,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Close open periods if requested (local store)
|
||||||
if (closeOpenPeriods) {
|
if (closeOpenPeriods) {
|
||||||
for (const period of openPeriodsInYear) {
|
for (const period of openPeriodsInYear) {
|
||||||
closePeriod(period.id, 'system');
|
closePeriod(period.id, 'system');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lock all periods in the year (local store)
|
// 3. Lock all periods in the year (local store)
|
||||||
for (const period of yearPeriods) {
|
for (const period of yearPeriods) {
|
||||||
lockPeriod(period.id, 'system');
|
lockPeriod(period.id, 'system');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Call backend mutation to close the fiscal year
|
// 4. Call backend mutation to close the fiscal year
|
||||||
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
|
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
|
||||||
|
|
||||||
// 4. Also update local store
|
// 5. Also update local store
|
||||||
closeFiscalYear(fiscalYear.id, 'system');
|
closeFiscalYear(fiscalYear.id, 'system');
|
||||||
lockFiscalYear(fiscalYear.id, 'system');
|
lockFiscalYear(fiscalYear.id, 'system');
|
||||||
|
|
||||||
// 5. Move to complete step
|
// 6. Create next fiscal year if requested
|
||||||
|
if (createNextYear) {
|
||||||
|
const nextStartDate = dayjs(fiscalYear.endDate).add(1, 'day');
|
||||||
|
const nextEndDate = nextStartDate.add(12, 'month').subtract(1, 'day');
|
||||||
|
const nextStartYear = nextStartDate.year();
|
||||||
|
const nextEndYear = nextEndDate.year();
|
||||||
|
const nextName = nextStartYear === nextEndYear
|
||||||
|
? `${nextStartYear}`
|
||||||
|
: `${nextStartYear}/${nextEndYear}`;
|
||||||
|
|
||||||
|
await createFiscalYearMutation.mutateAsync({
|
||||||
|
companyId: fiscalYear.companyId,
|
||||||
|
name: nextName,
|
||||||
|
startDate: nextStartDate.format('YYYY-MM-DD'),
|
||||||
|
endDate: nextEndDate.format('YYYY-MM-DD'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Move to complete step
|
||||||
setCurrentStep('complete');
|
setCurrentStep('complete');
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
|
||||||
|
|
@ -206,8 +206,6 @@ export default function BankConnectionsTab({ companyId }: BankConnectionsTabProp
|
||||||
// Use backend callback URL - backend will handle OAuth and redirect back to frontend
|
// Use backend callback URL - backend will handle OAuth and redirect back to frontend
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001';
|
const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://localhost:5001';
|
||||||
const redirectUrl = `${apiBaseUrl}/api/banking/callback`;
|
const redirectUrl = `${apiBaseUrl}/api/banking/callback`;
|
||||||
console.log('Enable Banking redirect URL:', redirectUrl);
|
|
||||||
|
|
||||||
const result = await startConnection.mutateAsync({
|
const result = await startConnection.mutateAsync({
|
||||||
companyId,
|
companyId,
|
||||||
aspspName: selectedBank,
|
aspspName: selectedBank,
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,10 @@ export function AmountText({
|
||||||
|
|
||||||
const formatAmount = (): string => {
|
const formatAmount = (): string => {
|
||||||
const formatted = formatCurrency(Math.abs(amount));
|
const formatted = formatCurrency(Math.abs(amount));
|
||||||
const sign = showSign && amount !== 0 ? (amount > 0 ? '+' : '-') : amount < 0 ? '-' : '';
|
// Always show +/- prefix for non-zero amounts (accessibility: not color-only)
|
||||||
|
// When showSign is explicitly true, same behavior; kept for API compatibility
|
||||||
|
const alwaysSign = true || showSign;
|
||||||
|
const sign = alwaysSign && amount !== 0 ? (amount > 0 ? '+' : '-') : amount < 0 ? '-' : '';
|
||||||
const suffix = showCurrency ? ` ${currencySuffix}` : '';
|
const suffix = showCurrency ? ` ${currencySuffix}` : '';
|
||||||
|
|
||||||
return `${sign}${formatted}${suffix}`;
|
return `${sign}${formatted}${suffix}`;
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
||||||
lines.push({
|
lines.push({
|
||||||
accountId: `vat-output-${vatCode}`,
|
accountId: `vat-output-${vatCode}`,
|
||||||
accountNumber: outputVatAccount,
|
accountNumber: outputVatAccount,
|
||||||
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
|
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: vatAmount,
|
credit: vatAmount,
|
||||||
|
|
@ -392,7 +392,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
||||||
lines: [],
|
lines: [],
|
||||||
bankTransactionId: bankTransaction.id,
|
bankTransactionId: bankTransaction.id,
|
||||||
isValid: false,
|
isValid: false,
|
||||||
validationMessage: `Split-beloeb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`,
|
validationMessage: `Split-beløb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +444,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
||||||
generatedLines.push({
|
generatedLines.push({
|
||||||
accountId: `vat-output-${splitLine.vatCode}`,
|
accountId: `vat-output-${splitLine.vatCode}`,
|
||||||
accountNumber: outputVatAccount,
|
accountNumber: outputVatAccount,
|
||||||
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
|
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: lineVat,
|
credit: lineVat,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function formatNumber(
|
||||||
*/
|
*/
|
||||||
export function formatDate(
|
export function formatDate(
|
||||||
date: string | Date,
|
date: string | Date,
|
||||||
format: string = 'DD/MM/YYYY'
|
format: string = 'DD-MM-YYYY'
|
||||||
): string {
|
): string {
|
||||||
return dayjs(date).format(format);
|
return dayjs(date).format(format);
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export function formatDate(
|
||||||
*/
|
*/
|
||||||
export function formatDateTime(
|
export function formatDateTime(
|
||||||
date: string | Date,
|
date: string | Date,
|
||||||
format: string = 'DD/MM/YYYY HH:mm'
|
format: string = 'DD-MM-YYYY HH:mm'
|
||||||
): string {
|
): string {
|
||||||
return dayjs(date).format(format);
|
return dayjs(date).format(format);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ export function canPostToDate(
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Period is locked',
|
reason: 'Period is locked',
|
||||||
reasonDanish: 'Perioden er laast',
|
reasonDanish: 'Perioden er låst',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,7 +386,7 @@ export function canPostToDate(
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Cannot post to future periods',
|
reason: 'Cannot post to future periods',
|
||||||
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder',
|
reasonDanish: 'Kan ikke bogføre i fremtidige perioder',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,7 +411,7 @@ export function validatePeriodClose(
|
||||||
errors.push({
|
errors.push({
|
||||||
code: 'PERIOD_LOCKED',
|
code: 'PERIOD_LOCKED',
|
||||||
message: 'Period is already locked and cannot be modified',
|
message: 'Period is already locked and cannot be modified',
|
||||||
messageDanish: 'Perioden er allerede laast og kan ikke aendres',
|
messageDanish: 'Perioden er allerede låst og kan ikke ændres',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
},
|
},
|
||||||
reverseCharge: false,
|
reverseCharge: false,
|
||||||
deductible: false,
|
deductible: false,
|
||||||
description: 'Moms paa salg af varer og ydelser i Danmark',
|
description: 'Moms på salg af varer og ydelser i Danmark',
|
||||||
},
|
},
|
||||||
K25: {
|
K25: {
|
||||||
code: 'K25',
|
code: 'K25',
|
||||||
|
|
@ -39,11 +39,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
},
|
},
|
||||||
reverseCharge: false,
|
reverseCharge: false,
|
||||||
deductible: true,
|
deductible: true,
|
||||||
description: 'Fradragsberettiget moms paa koeb',
|
description: 'Fradragsberettiget moms på køb',
|
||||||
},
|
},
|
||||||
EU_VARE: {
|
EU_VARE: {
|
||||||
code: 'EU_VARE',
|
code: 'EU_VARE',
|
||||||
nameDanish: 'EU-varekoeb (erhvervelsesmoms)',
|
nameDanish: 'EU-varekøb (erhvervelsesmoms)',
|
||||||
nameEnglish: 'EU goods purchase (acquisition VAT)',
|
nameEnglish: 'EU goods purchase (acquisition VAT)',
|
||||||
rate: 0.25,
|
rate: 0.25,
|
||||||
type: 'reverse_charge',
|
type: 'reverse_charge',
|
||||||
|
|
@ -53,11 +53,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
},
|
},
|
||||||
reverseCharge: true,
|
reverseCharge: true,
|
||||||
deductible: true, // Both output and input VAT
|
deductible: true, // Both output and input VAT
|
||||||
description: 'Koeb af varer fra andre EU-lande med omvendt betalingspligt',
|
description: 'Køb af varer fra andre EU-lande med omvendt betalingspligt',
|
||||||
},
|
},
|
||||||
EU_YDELSE: {
|
EU_YDELSE: {
|
||||||
code: 'EU_YDELSE',
|
code: 'EU_YDELSE',
|
||||||
nameDanish: 'EU-ydelseskoeb (omvendt betalingspligt)',
|
nameDanish: 'EU-ydelseskøb (omvendt betalingspligt)',
|
||||||
nameEnglish: 'EU services purchase (reverse charge)',
|
nameEnglish: 'EU services purchase (reverse charge)',
|
||||||
rate: 0.25,
|
rate: 0.25,
|
||||||
type: 'reverse_charge',
|
type: 'reverse_charge',
|
||||||
|
|
@ -67,7 +67,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
},
|
},
|
||||||
reverseCharge: true,
|
reverseCharge: true,
|
||||||
deductible: true,
|
deductible: true,
|
||||||
description: 'Koeb af ydelser fra udlandet med omvendt betalingspligt',
|
description: 'Køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||||
},
|
},
|
||||||
MOMSFRI: {
|
MOMSFRI: {
|
||||||
code: 'MOMSFRI',
|
code: 'MOMSFRI',
|
||||||
|
|
@ -114,7 +114,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
||||||
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
|
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
|
||||||
monthly: {
|
monthly: {
|
||||||
type: 'monthly',
|
type: 'monthly',
|
||||||
nameDanish: 'Maanedlig',
|
nameDanish: 'Månedlig',
|
||||||
nameEnglish: 'Monthly',
|
nameEnglish: 'Monthly',
|
||||||
deadlineDaysAfterPeriod: 25, // 25th of following month
|
deadlineDaysAfterPeriod: 25, // 25th of following month
|
||||||
periodsPerYear: 12,
|
periodsPerYear: 12,
|
||||||
|
|
@ -130,7 +130,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
||||||
},
|
},
|
||||||
'half-yearly': {
|
'half-yearly': {
|
||||||
type: 'half-yearly',
|
type: 'half-yearly',
|
||||||
nameDanish: 'Halvaarslig',
|
nameDanish: 'Halvårslig',
|
||||||
nameEnglish: 'Half-yearly',
|
nameEnglish: 'Half-yearly',
|
||||||
deadlineDaysAfterPeriod: 60, // ~2 months after period
|
deadlineDaysAfterPeriod: 60, // ~2 months after period
|
||||||
periodsPerYear: 2,
|
periodsPerYear: 2,
|
||||||
|
|
@ -138,7 +138,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
||||||
},
|
},
|
||||||
yearly: {
|
yearly: {
|
||||||
type: 'yearly',
|
type: 'yearly',
|
||||||
nameDanish: 'Aarslig',
|
nameDanish: 'Årslig',
|
||||||
nameEnglish: 'Yearly',
|
nameEnglish: 'Yearly',
|
||||||
deadlineDaysAfterPeriod: 90, // March 1st for calendar year
|
deadlineDaysAfterPeriod: 90, // March 1st for calendar year
|
||||||
periodsPerYear: 1,
|
periodsPerYear: 1,
|
||||||
|
|
@ -150,7 +150,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
||||||
* SKAT VAT box definitions
|
* SKAT VAT box definitions
|
||||||
*/
|
*/
|
||||||
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
||||||
// VAT amounts (Momsbeloeb)
|
// VAT amounts (Momsbeløb)
|
||||||
A: {
|
A: {
|
||||||
id: 'A',
|
id: 'A',
|
||||||
type: 'vat',
|
type: 'vat',
|
||||||
|
|
@ -163,37 +163,37 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
||||||
B: {
|
B: {
|
||||||
id: 'B',
|
id: 'B',
|
||||||
type: 'vat',
|
type: 'vat',
|
||||||
nameDanish: 'Koebsmoms',
|
nameDanish: 'Købsmoms',
|
||||||
nameEnglish: 'Input VAT (purchases)',
|
nameEnglish: 'Input VAT (purchases)',
|
||||||
description: 'Fradragsberettiget moms af koeb',
|
description: 'Fradragsberettiget moms af køb',
|
||||||
skippable: false,
|
skippable: false,
|
||||||
isDeductible: true,
|
isDeductible: true,
|
||||||
},
|
},
|
||||||
C: {
|
C: {
|
||||||
id: 'C',
|
id: 'C',
|
||||||
type: 'vat',
|
type: 'vat',
|
||||||
nameDanish: 'Moms af EU-varekoeb',
|
nameDanish: 'Moms af EU-varekøb',
|
||||||
nameEnglish: 'VAT on EU goods purchases',
|
nameEnglish: 'VAT on EU goods purchases',
|
||||||
description: 'Erhvervelsesmoms ved koeb af varer fra andre EU-lande',
|
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
|
||||||
skippable: true,
|
skippable: true,
|
||||||
isDeductible: false, // Listed as output, but can be deducted via B
|
isDeductible: false, // Listed as output, but can be deducted via B
|
||||||
},
|
},
|
||||||
D: {
|
D: {
|
||||||
id: 'D',
|
id: 'D',
|
||||||
type: 'vat',
|
type: 'vat',
|
||||||
nameDanish: 'Moms af ydelseskoeb fra udland',
|
nameDanish: 'Moms af ydelseskøb fra udland',
|
||||||
nameEnglish: 'VAT on foreign services',
|
nameEnglish: 'VAT on foreign services',
|
||||||
description: 'Moms ved koeb af ydelser fra udlandet med omvendt betalingspligt',
|
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||||
skippable: true,
|
skippable: true,
|
||||||
isDeductible: false,
|
isDeductible: false,
|
||||||
},
|
},
|
||||||
// Basis/turnover amounts (Omsaetning)
|
// Basis/turnover amounts (Omsætning)
|
||||||
'1': {
|
'1': {
|
||||||
id: '1',
|
id: '1',
|
||||||
type: 'basis',
|
type: 'basis',
|
||||||
nameDanish: 'Salg med moms',
|
nameDanish: 'Salg med moms',
|
||||||
nameEnglish: 'Sales with VAT',
|
nameEnglish: 'Sales with VAT',
|
||||||
description: 'Vaerdi af varer og ydelser solgt med dansk moms (momsgrundlag)',
|
description: 'Værdi af varer og ydelser solgt med dansk moms (momsgrundlag)',
|
||||||
skippable: false,
|
skippable: false,
|
||||||
isDeductible: false,
|
isDeductible: false,
|
||||||
},
|
},
|
||||||
|
|
@ -209,18 +209,18 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
||||||
'3': {
|
'3': {
|
||||||
id: '3',
|
id: '3',
|
||||||
type: 'basis',
|
type: 'basis',
|
||||||
nameDanish: 'EU-varekoeb',
|
nameDanish: 'EU-varekøb',
|
||||||
nameEnglish: 'EU goods purchases',
|
nameEnglish: 'EU goods purchases',
|
||||||
description: 'Vaerdi af varer koebt fra andre EU-lande',
|
description: 'Værdi af varer købt fra andre EU-lande',
|
||||||
skippable: true,
|
skippable: true,
|
||||||
isDeductible: false,
|
isDeductible: false,
|
||||||
},
|
},
|
||||||
'4': {
|
'4': {
|
||||||
id: '4',
|
id: '4',
|
||||||
type: 'basis',
|
type: 'basis',
|
||||||
nameDanish: 'Ydelseskoeb fra udland',
|
nameDanish: 'Ydelseskøb fra udland',
|
||||||
nameEnglish: 'Foreign services purchases',
|
nameEnglish: 'Foreign services purchases',
|
||||||
description: 'Vaerdi af ydelser koebt fra udlandet',
|
description: 'Værdi af ydelser købt fra udlandet',
|
||||||
skippable: true,
|
skippable: true,
|
||||||
isDeductible: false,
|
isDeductible: false,
|
||||||
},
|
},
|
||||||
|
|
@ -310,9 +310,9 @@ export function getPeriodicitetOptions(): Array<{ value: VATPeriodicitet; label:
|
||||||
value: config.type,
|
value: config.type,
|
||||||
label: config.nameDanish,
|
label: config.nameDanish,
|
||||||
description: config.threshold?.min
|
description: config.threshold?.min
|
||||||
? `Omsaetning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK`
|
? `Omsætning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK`
|
||||||
: config.threshold?.max
|
: config.threshold?.max
|
||||||
? `Omsaetning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
|
? `Omsætning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
|
||||||
: 'Standard',
|
: 'Standard',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||||
import {
|
import {
|
||||||
ToolOutlined,
|
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
|
|
@ -24,6 +23,7 @@ import { useCanAdmin } from '@/stores/companyStore';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { graphqlClient } from '@/api/client';
|
import { graphqlClient } from '@/api/client';
|
||||||
import { gql } from 'graphql-request';
|
import { gql } from 'graphql-request';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|
@ -129,13 +129,11 @@ export default function Admin() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div style={{ marginBottom: 16 }}>
|
title="Administration"
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
subtitle="Systemværktøjer til fejlfinding og vedligeholdelse"
|
||||||
<ToolOutlined /> Administration
|
breadcrumbs={[{ title: 'Administration' }]}
|
||||||
</Title>
|
/>
|
||||||
<Text type="secondary">Systemværktøjer til fejlfinding og vedligeholdelse</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
message="Advarsel"
|
message="Advarsel"
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { useReconciliationStore } from '@/stores/reconciliationStore';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
|
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
|
||||||
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
|
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
|
||||||
|
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import type { BankTransaction } from '@/types/accounting';
|
import type { BankTransaction } from '@/types/accounting';
|
||||||
|
|
@ -71,6 +72,7 @@ export default function Bankafstemning() {
|
||||||
// Fetch data from API
|
// Fetch data from API
|
||||||
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
|
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
|
||||||
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
|
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
|
||||||
|
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id);
|
||||||
|
|
||||||
const isLoading = connectionsLoading || transactionsLoading;
|
const isLoading = connectionsLoading || transactionsLoading;
|
||||||
|
|
||||||
|
|
@ -193,7 +195,7 @@ export default function Bankafstemning() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success('Postering tilfojet til afventende matches');
|
message.success('Postering tilføjet til afventende matches');
|
||||||
setIsCreateModalOpen(false);
|
setIsCreateModalOpen(false);
|
||||||
setSelectedBankTx(null);
|
setSelectedBankTx(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -203,17 +205,6 @@ export default function Bankafstemning() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAll = () => {
|
|
||||||
if (pendingMatches.length === 0) {
|
|
||||||
message.warning('Ingen matches at gemme');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
|
|
||||||
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
|
|
||||||
// mark them as reconciled, and create journal entries for new transactions.
|
|
||||||
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
|
||||||
addPendingMatch({
|
addPendingMatch({
|
||||||
bankTransactionId: suggestion.bankTransactionId,
|
bankTransactionId: suggestion.bankTransactionId,
|
||||||
|
|
@ -263,14 +254,15 @@ export default function Bankafstemning() {
|
||||||
>
|
>
|
||||||
Nulstil valg
|
Nulstil valg
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Tooltip title="Gem-funktionen er under udvikling">
|
||||||
type="primary"
|
<Button
|
||||||
icon={<CheckOutlined />}
|
type="primary"
|
||||||
onClick={handleSaveAll}
|
icon={<CheckOutlined />}
|
||||||
disabled={pendingMatches.length === 0}
|
disabled
|
||||||
>
|
>
|
||||||
Gem afstemninger ({pendingMatches.length})
|
Gem afstemninger ({pendingMatches.length})
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -389,7 +381,7 @@ export default function Bankafstemning() {
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
|
styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
|
||||||
>
|
>
|
||||||
{bankTransactions.length === 0 ? (
|
{bankTransactions.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
|
|
@ -500,7 +492,7 @@ export default function Bankafstemning() {
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
|
styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
|
||||||
>
|
>
|
||||||
{ledgerEntries.length === 0 ? (
|
{ledgerEntries.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
|
|
@ -626,7 +618,7 @@ export default function Bankafstemning() {
|
||||||
|
|
||||||
{/* Create Entry Modal */}
|
{/* Create Entry Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Opret bogforingspost"
|
title="Opret bogføringspost"
|
||||||
open={isCreateModalOpen}
|
open={isCreateModalOpen}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsCreateModalOpen(false);
|
setIsCreateModalOpen(false);
|
||||||
|
|
@ -674,12 +666,12 @@ export default function Bankafstemning() {
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vælg konto"
|
placeholder="Vælg konto"
|
||||||
options={[
|
showSearch
|
||||||
{ value: '6100', label: '6100 - Husleje' },
|
optionFilterProp="label"
|
||||||
{ value: '6800', label: '6800 - Kontorartikler' },
|
options={activeAccounts.map((acc) => ({
|
||||||
{ value: '5000', label: '5000 - Varekøb' },
|
value: acc.id,
|
||||||
{ value: '4000', label: '4000 - Salg' },
|
label: `${acc.accountNumber} - ${acc.name}`,
|
||||||
]}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="vatCode" label="Momskode">
|
<Form.Item name="vatCode" label="Momskode">
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,6 @@ export default function CompanySetupWizard() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('Creating company with values:', values);
|
|
||||||
|
|
||||||
const company = await createCompany.mutateAsync({
|
const company = await createCompany.mutateAsync({
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
cvr: values.cvr?.trim() || undefined,
|
cvr: values.cvr?.trim() || undefined,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Line, Pie, Column } from '@ant-design/charts';
|
import { Line, Pie, Column } from '@ant-design/charts';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
import { useCompanyStore } from '@/stores/companyStore';
|
||||||
|
|
@ -312,7 +313,7 @@ export default function Dashboard() {
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<a href="/momsindberetning">Se momsindberetning</a>
|
<Link to="/momsindberetning">Se momsindberetning</Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -384,7 +385,7 @@ export default function Dashboard() {
|
||||||
<Card
|
<Card
|
||||||
title="Seneste transaktioner"
|
title="Seneste transaktioner"
|
||||||
size="small"
|
size="small"
|
||||||
bodyStyle={{ padding: 0 }}
|
styles={{ body: { padding: 0 } }}
|
||||||
>
|
>
|
||||||
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
||||||
{recentTransactions.length > 0 ? (
|
{recentTransactions.length > 0 ? (
|
||||||
|
|
@ -438,9 +439,9 @@ export default function Dashboard() {
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
||||||
<a href="/momsindberetning">
|
<Link to="/momsindberetning">
|
||||||
<Text>Se momsindberetning</Text>
|
<Text>Se momsindberetning</Text>
|
||||||
</a>
|
</Link>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
{metrics.overdueInvoices > 0 && (
|
{metrics.overdueInvoices > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Tag,
|
Tag,
|
||||||
Alert,
|
Alert,
|
||||||
Spin,
|
Skeleton,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||||
import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
|
|
@ -18,8 +18,9 @@ import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
|
||||||
import { useExportSaft, downloadSaftFile } from '@/api/mutations/saftMutations';
|
import { useExportSaft, downloadSaftFile } from '@/api/mutations/saftMutations';
|
||||||
import { formatDate } from '@/lib/formatters';
|
import { formatDate } from '@/lib/formatters';
|
||||||
import { spacing } from '@/styles/designTokens';
|
import { spacing } from '@/styles/designTokens';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
export default function Eksport() {
|
export default function Eksport() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
|
|
@ -53,8 +54,20 @@ export default function Eksport() {
|
||||||
|
|
||||||
if (fiscalYearsLoading) {
|
if (fiscalYearsLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: spacing.xl }}>
|
<div>
|
||||||
<Spin size="large" />
|
<PageHeader
|
||||||
|
title="Eksporter data"
|
||||||
|
subtitle="Eksporter regnskabsdata til forskellige formater for compliance og rapportering."
|
||||||
|
breadcrumbs={[{ title: 'Rapporter' }, { title: 'Eksport' }]}
|
||||||
|
/>
|
||||||
|
<Row gutter={[spacing.lg, spacing.lg]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card><Skeleton active paragraph={{ rows: 6 }} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card><Skeleton active paragraph={{ rows: 6 }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -63,10 +76,11 @@ export default function Eksport() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title level={4}>Eksporter data</Title>
|
<PageHeader
|
||||||
<Paragraph type="secondary">
|
title="Eksporter data"
|
||||||
Eksporter regnskabsdata til forskellige formater for compliance og rapportering.
|
subtitle="Eksporter regnskabsdata til forskellige formater for compliance og rapportering."
|
||||||
</Paragraph>
|
breadcrumbs={[{ title: 'Rapporter' }, { title: 'Eksport' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Row gutter={[spacing.lg, spacing.lg]}>
|
<Row gutter={[spacing.lg, spacing.lg]}>
|
||||||
{/* SAF-T Export Card */}
|
{/* SAF-T Export Card */}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Spin,
|
Skeleton,
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
|
@ -42,6 +42,7 @@ import { useCurrentFiscalYear } from '@/stores/periodStore';
|
||||||
import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries';
|
import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries';
|
||||||
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
|
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
|
||||||
import { useActiveProducts, type Product } from '@/api/queries/productQueries';
|
import { useActiveProducts, type Product } from '@/api/queries/productQueries';
|
||||||
|
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||||
import {
|
import {
|
||||||
useCreateInvoice,
|
useCreateInvoice,
|
||||||
useAddInvoiceLine,
|
useAddInvoiceLine,
|
||||||
|
|
@ -124,6 +125,13 @@ export default function Fakturaer() {
|
||||||
// Fetch products for line form
|
// Fetch products for line form
|
||||||
const { data: products = [] } = useActiveProducts(company?.id);
|
const { data: products = [] } = useActiveProducts(company?.id);
|
||||||
|
|
||||||
|
// Fetch accounts for payment modal (filter for bank-type accounts: asset accounts starting with 1)
|
||||||
|
const { data: allAccounts = [] } = useActiveAccounts(company?.id);
|
||||||
|
const bankAccounts = useMemo(
|
||||||
|
() => allAccounts.filter((acc) => acc.type === 'asset' && parseInt(acc.accountNumber, 10) >= 1000 && parseInt(acc.accountNumber, 10) < 2000),
|
||||||
|
[allAccounts]
|
||||||
|
);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createInvoiceMutation = useCreateInvoice();
|
const createInvoiceMutation = useCreateInvoice();
|
||||||
const addInvoiceLineMutation = useAddInvoiceLine();
|
const addInvoiceLineMutation = useAddInvoiceLine();
|
||||||
|
|
@ -560,9 +568,7 @@ export default function Fakturaer() {
|
||||||
{/* Invoice Table */}
|
{/* Invoice Table */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin tip="Indlæser fakturaer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
<div style={{ minHeight: 200 }} />
|
|
||||||
</Spin>
|
|
||||||
) : filteredInvoices.length > 0 ? (
|
) : filteredInvoices.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredInvoices}
|
dataSource={filteredInvoices}
|
||||||
|
|
@ -968,9 +974,12 @@ export default function Fakturaer() {
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vælg bankkonto"
|
placeholder="Vælg bankkonto"
|
||||||
options={[
|
showSearch
|
||||||
{ value: 'bank-hovedkonto', label: '5600 - Bankkonto' },
|
optionFilterProp="label"
|
||||||
]}
|
options={bankAccounts.map((acc) => ({
|
||||||
|
value: acc.id,
|
||||||
|
label: `${acc.accountNumber} - ${acc.name}`,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="paymentDate" label="Betalingsdato">
|
<Form.Item name="paymentDate" label="Betalingsdato">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Empty,
|
Empty,
|
||||||
|
Descriptions,
|
||||||
|
Table,
|
||||||
|
Drawer,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
|
@ -34,7 +37,7 @@ import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
import { PageHeader } from '@/components/shared/PageHeader';
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import { validateDoubleEntry } from '@/lib/accounting';
|
import { validateDoubleEntry } from '@/lib/accounting';
|
||||||
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
|
import type { TransactionLine, JournalEntryDraft, JournalEntryDraftStatus } from '@/types/accounting';
|
||||||
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
|
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||||
import { usePeriodStore } from '@/stores/periodStore';
|
import { usePeriodStore } from '@/stores/periodStore';
|
||||||
|
|
||||||
|
|
@ -51,7 +54,10 @@ interface DraftDisplay {
|
||||||
totalCredit: number;
|
totalCredit: number;
|
||||||
isReconciled: boolean;
|
isReconciled: boolean;
|
||||||
isVoided: boolean;
|
isVoided: boolean;
|
||||||
|
status: JournalEntryDraftStatus;
|
||||||
lines: JournalEntryDraft['lines'];
|
lines: JournalEntryDraft['lines'];
|
||||||
|
postedAt?: string;
|
||||||
|
postedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Kassekladde() {
|
export default function Kassekladde() {
|
||||||
|
|
@ -59,6 +65,10 @@ export default function Kassekladde() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingDraft, setEditingDraft] = useState<DraftDisplay | null>(null);
|
const [editingDraft, setEditingDraft] = useState<DraftDisplay | null>(null);
|
||||||
const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||||
|
const [accountFilter, setAccountFilter] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||||
|
const [detailDraft, setDetailDraft] = useState<DraftDisplay | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [lines, setLines] = useState<Partial<TransactionLine>[]>([
|
const [lines, setLines] = useState<Partial<TransactionLine>[]>([
|
||||||
{ debit: 0, credit: 0 },
|
{ debit: 0, credit: 0 },
|
||||||
|
|
@ -79,7 +89,7 @@ export default function Kassekladde() {
|
||||||
const isLoading = accountsLoading || draftsLoading;
|
const isLoading = accountsLoading || draftsLoading;
|
||||||
|
|
||||||
// Convert drafts to display format
|
// Convert drafts to display format
|
||||||
const displayData: DraftDisplay[] = drafts.map(draft => ({
|
const displayData: DraftDisplay[] = useMemo(() => drafts.map(draft => ({
|
||||||
id: draft.id,
|
id: draft.id,
|
||||||
transactionNumber: draft.voucherNumber || draft.name,
|
transactionNumber: draft.voucherNumber || draft.name,
|
||||||
date: draft.documentDate || draft.createdAt,
|
date: draft.documentDate || draft.createdAt,
|
||||||
|
|
@ -89,7 +99,62 @@ export default function Kassekladde() {
|
||||||
totalCredit: draft.lines?.reduce((sum, l) => sum + (l.creditAmount || 0), 0) ?? 0,
|
totalCredit: draft.lines?.reduce((sum, l) => sum + (l.creditAmount || 0), 0) ?? 0,
|
||||||
isReconciled: draft.status === 'posted',
|
isReconciled: draft.status === 'posted',
|
||||||
isVoided: draft.status === 'discarded',
|
isVoided: draft.status === 'discarded',
|
||||||
}));
|
status: draft.status,
|
||||||
|
postedAt: draft.status === 'posted' ? draft.updatedAt : undefined,
|
||||||
|
postedBy: draft.createdBy,
|
||||||
|
})), [drafts]);
|
||||||
|
|
||||||
|
// Apply filters to display data
|
||||||
|
const filteredData: DraftDisplay[] = useMemo(() => {
|
||||||
|
let data = displayData;
|
||||||
|
|
||||||
|
// Date filter
|
||||||
|
if (dateFilter && dateFilter[0] && dateFilter[1]) {
|
||||||
|
const startDate = dateFilter[0].startOf('day');
|
||||||
|
const endDate = dateFilter[1].endOf('day');
|
||||||
|
data = data.filter(d => {
|
||||||
|
const dDate = dayjs(d.date);
|
||||||
|
return (dDate.isAfter(startDate) || dDate.isSame(startDate, 'day')) &&
|
||||||
|
(dDate.isBefore(endDate) || dDate.isSame(endDate, 'day'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account filter - filter drafts where any line references the selected account
|
||||||
|
if (accountFilter) {
|
||||||
|
data = data.filter(d =>
|
||||||
|
d.lines.some(l => l.accountId === accountFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter) {
|
||||||
|
data = data.filter(d => d.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, [displayData, dateFilter, accountFilter, statusFilter]);
|
||||||
|
|
||||||
|
// Pre-populate form when editing a draft
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingDraft && isModalOpen) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
date: editingDraft.date ? dayjs(editingDraft.date) : dayjs(),
|
||||||
|
description: editingDraft.description,
|
||||||
|
});
|
||||||
|
// Populate lines from the draft
|
||||||
|
if (editingDraft.lines && editingDraft.lines.length > 0) {
|
||||||
|
setLines(editingDraft.lines.map(l => ({
|
||||||
|
accountId: l.accountId,
|
||||||
|
debit: l.debitAmount || 0,
|
||||||
|
credit: l.creditAmount || 0,
|
||||||
|
description: l.description,
|
||||||
|
vatCode: l.vatCode,
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editingDraft, isModalOpen, form]);
|
||||||
|
|
||||||
const columns: DataTableColumn<DraftDisplay>[] = [
|
const columns: DataTableColumn<DraftDisplay>[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -134,7 +199,7 @@ export default function Kassekladde() {
|
||||||
return <Tag color="red">Annulleret</Tag>;
|
return <Tag color="red">Annulleret</Tag>;
|
||||||
}
|
}
|
||||||
return value ? (
|
return value ? (
|
||||||
<Tag color="green">Bogført</Tag>
|
<Tag color="green">Bogfort</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="orange">Kladde</Tag>
|
<Tag color="orange">Kladde</Tag>
|
||||||
);
|
);
|
||||||
|
|
@ -192,7 +257,7 @@ export default function Kassekladde() {
|
||||||
const handleAction = (action: string, record: DraftDisplay) => {
|
const handleAction = (action: string, record: DraftDisplay) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'view':
|
case 'view':
|
||||||
message.info(`Vis detaljer for bilag ${record.transactionNumber}`);
|
setDetailDraft(record);
|
||||||
break;
|
break;
|
||||||
case 'edit':
|
case 'edit':
|
||||||
setEditingDraft(record);
|
setEditingDraft(record);
|
||||||
|
|
@ -236,7 +301,7 @@ export default function Kassekladde() {
|
||||||
case 'void':
|
case 'void':
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Annuller bilag',
|
title: 'Annuller bilag',
|
||||||
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
|
content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`,
|
||||||
okText: 'Annuller bilag',
|
okText: 'Annuller bilag',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: 'Fortryd',
|
cancelText: 'Fortryd',
|
||||||
|
|
@ -287,7 +352,7 @@ export default function Kassekladde() {
|
||||||
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
message.error(
|
message.error(
|
||||||
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
|
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +412,7 @@ export default function Kassekladde() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
|
setEditingDraft(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -356,6 +422,23 @@ export default function Kassekladde() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to look up account name by ID
|
||||||
|
const getAccountName = (accountId: string): string => {
|
||||||
|
const acc = accounts.find(a => a.id === accountId);
|
||||||
|
return acc ? `${acc.accountNumber} - ${acc.name}` : accountId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: JournalEntryDraftStatus): { label: string; color: string } => {
|
||||||
|
switch (status) {
|
||||||
|
case 'posted': return { label: 'Bogfort', color: 'green' };
|
||||||
|
case 'discarded': return { label: 'Annulleret', color: 'red' };
|
||||||
|
case 'draft': return { label: 'Kladde', color: 'orange' };
|
||||||
|
case 'pending_review': return { label: 'Afventer gennemgang', color: 'blue' };
|
||||||
|
case 'approved': return { label: 'Godkendt', color: 'cyan' };
|
||||||
|
default: return { label: status, color: 'default' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const balance = validateDoubleEntry(lines as TransactionLine[]);
|
const balance = validateDoubleEntry(lines as TransactionLine[]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -364,7 +447,7 @@ export default function Kassekladde() {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Kassekladde"
|
title="Kassekladde"
|
||||||
subtitle={activeCompany?.name}
|
subtitle={activeCompany?.name}
|
||||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||||
/>
|
/>
|
||||||
<Skeleton active paragraph={{ rows: 10 }} />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -376,13 +459,15 @@ export default function Kassekladde() {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Kassekladde"
|
title="Kassekladde"
|
||||||
subtitle={activeCompany?.name}
|
subtitle={activeCompany?.name}
|
||||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingDraft(null);
|
setEditingDraft(null);
|
||||||
|
form.resetFields();
|
||||||
|
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -397,36 +482,58 @@ export default function Kassekladde() {
|
||||||
placeholder={['Fra dato', 'Til dato']}
|
placeholder={['Fra dato', 'Til dato']}
|
||||||
value={dateFilter}
|
value={dateFilter}
|
||||||
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
|
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
|
||||||
format="DD/MM/YYYY"
|
format="DD-MM-YYYY"
|
||||||
/>
|
/>
|
||||||
<Select
|
{showAdvancedFilters && (
|
||||||
placeholder="Konto"
|
<>
|
||||||
style={{ width: 200 }}
|
<Select
|
||||||
allowClear
|
placeholder="Konto"
|
||||||
options={accounts.map((acc) => ({
|
style={{ width: 200 }}
|
||||||
value: acc.id,
|
allowClear
|
||||||
label: `${acc.accountNumber} - ${acc.name}`,
|
value={accountFilter}
|
||||||
}))}
|
onChange={(value) => setAccountFilter(value ?? null)}
|
||||||
/>
|
options={accounts.map((acc) => ({
|
||||||
<Select
|
value: acc.id,
|
||||||
placeholder="Status"
|
label: `${acc.accountNumber} - ${acc.name}`,
|
||||||
style={{ width: 120 }}
|
}))}
|
||||||
allowClear
|
/>
|
||||||
options={[
|
<Select
|
||||||
{ value: 'posted', label: 'Bogført' },
|
placeholder="Status"
|
||||||
{ value: 'draft', label: 'Kladde' },
|
style={{ width: 120 }}
|
||||||
{ value: 'discarded', label: 'Annulleret' },
|
allowClear
|
||||||
]}
|
value={statusFilter}
|
||||||
/>
|
onChange={(value) => setStatusFilter(value ?? null)}
|
||||||
<Button icon={<FilterOutlined />}>Flere filtre</Button>
|
options={[
|
||||||
|
{ value: 'posted', label: 'Bogfort' },
|
||||||
|
{ value: 'draft', label: 'Kladde' },
|
||||||
|
{ value: 'discarded', label: 'Annulleret' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<FilterOutlined />}
|
||||||
|
type={showAdvancedFilters ? 'primary' : 'default'}
|
||||||
|
ghost={showAdvancedFilters}
|
||||||
|
onClick={() => {
|
||||||
|
setShowAdvancedFilters(!showAdvancedFilters);
|
||||||
|
if (showAdvancedFilters) {
|
||||||
|
// Clear advanced filters when hiding
|
||||||
|
setAccountFilter(null);
|
||||||
|
setStatusFilter(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAdvancedFilters ? 'Skjul filtre' : 'Flere filtre'}
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
{displayData.length === 0 ? (
|
{filteredData.length === 0 ? (
|
||||||
<Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." />
|
<Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." />
|
||||||
) : (
|
) : (
|
||||||
<DataTable<DraftDisplay>
|
<DataTable<DraftDisplay>
|
||||||
data={displayData}
|
data={filteredData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
exportFilename="kassekladde"
|
exportFilename="kassekladde"
|
||||||
rowSelection="multiple"
|
rowSelection="multiple"
|
||||||
|
|
@ -437,12 +544,95 @@ export default function Kassekladde() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Detail Drawer */}
|
||||||
|
<Drawer
|
||||||
|
title={`Bilag #${detailDraft?.transactionNumber ?? ''}`}
|
||||||
|
open={!!detailDraft}
|
||||||
|
onClose={() => setDetailDraft(null)}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{detailDraft && (
|
||||||
|
<>
|
||||||
|
<Descriptions bordered column={1} size="small">
|
||||||
|
<Descriptions.Item label="Bilagsnr.">
|
||||||
|
#{detailDraft.transactionNumber}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Dato">
|
||||||
|
{detailDraft.date ? dayjs(detailDraft.date).format('DD-MM-YYYY') : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Beskrivelse">
|
||||||
|
{detailDraft.description}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Status">
|
||||||
|
<Tag color={getStatusLabel(detailDraft.status).color}>
|
||||||
|
{getStatusLabel(detailDraft.status).label}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detailDraft.isReconciled && detailDraft.postedAt && (
|
||||||
|
<Descriptions.Item label="Bogfort">
|
||||||
|
{dayjs(detailDraft.postedAt).format('DD-MM-YYYY HH:mm')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{detailDraft.postedBy && (
|
||||||
|
<Descriptions.Item label="Bogfort af">
|
||||||
|
{detailDraft.postedBy}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="Total debet">
|
||||||
|
{formatCurrency(detailDraft.totalDebit)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Total kredit">
|
||||||
|
{formatCurrency(detailDraft.totalCredit)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||||
|
Posteringslinjer
|
||||||
|
</Typography.Title>
|
||||||
|
<Table
|
||||||
|
dataSource={detailDraft.lines.map((l, idx) => ({ ...l, key: idx }))}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Konto',
|
||||||
|
dataIndex: 'accountId',
|
||||||
|
key: 'account',
|
||||||
|
render: (accountId: string) => getAccountName(accountId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Debet',
|
||||||
|
dataIndex: 'debitAmount',
|
||||||
|
key: 'debit',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => v ? formatCurrency(v) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kredit',
|
||||||
|
dataIndex: 'creditAmount',
|
||||||
|
key: 'credit',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => v ? formatCurrency(v) : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tekst',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title={editingDraft ? 'Rediger bilag' : 'Nyt bilag'}
|
title={editingDraft ? 'Rediger bilag' : 'Nyt bilag'}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
|
setEditingDraft(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||||
}}
|
}}
|
||||||
|
|
@ -457,10 +647,10 @@ export default function Kassekladde() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="date"
|
name="date"
|
||||||
label="Dato"
|
label="Dato"
|
||||||
rules={[{ required: true, message: 'Vælg dato' }]}
|
rules={[{ required: true, message: 'Vaelg dato' }]}
|
||||||
initialValue={dayjs()}
|
initialValue={dayjs()}
|
||||||
>
|
>
|
||||||
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
<DatePicker format="DD-MM-YYYY" style={{ width: 150 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="description"
|
name="description"
|
||||||
|
|
@ -493,7 +683,7 @@ export default function Kassekladde() {
|
||||||
<td style={{ padding: 4 }}>
|
<td style={{ padding: 4 }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="Vælg konto"
|
placeholder="Vaelg konto"
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
value={line.accountId}
|
value={line.accountId}
|
||||||
|
|
@ -562,7 +752,7 @@ export default function Kassekladde() {
|
||||||
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
||||||
<td style={{ padding: 8 }}>
|
<td style={{ padding: 8 }}>
|
||||||
<Button type="dashed" size="small" onClick={handleAddLine}>
|
<Button type="dashed" size="small" onClick={handleAddLine}>
|
||||||
+ Tilføj linje
|
+ Tilføj linje
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ export default function Kontooversigt() {
|
||||||
if (selectedAccount) {
|
if (selectedAccount) {
|
||||||
// TODO: Backend does not yet have an updateAccount mutation.
|
// TODO: Backend does not yet have an updateAccount mutation.
|
||||||
// For now, show a message indicating this is not yet supported.
|
// For now, show a message indicating this is not yet supported.
|
||||||
message.warning('Redigering af konti er endnu ikke understottet i backend');
|
message.warning('Redigering af konti er endnu ikke understøttet i backend');
|
||||||
} else {
|
} else {
|
||||||
// Create new account
|
// Create new account
|
||||||
await createAccountMutation.mutateAsync({
|
await createAccountMutation.mutateAsync({
|
||||||
|
|
@ -252,7 +252,7 @@ export default function Kontooversigt() {
|
||||||
title="Kontooversigt"
|
title="Kontooversigt"
|
||||||
subtitle={activeCompany?.name}
|
subtitle={activeCompany?.name}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ title: 'Bogføring', path: '/bogforing' },
|
{ title: 'Bogføring' },
|
||||||
{ title: 'Kontooversigt' },
|
{ title: 'Kontooversigt' },
|
||||||
]}
|
]}
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -316,7 +316,7 @@ export default function Kontooversigt() {
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
bordered={false}
|
||||||
bodyStyle={{ padding: 0 }}
|
styles={{ body: { padding: 0 } }}
|
||||||
title={
|
title={
|
||||||
<Input
|
<Input
|
||||||
prefix={<SearchOutlined className="text-gray-400" />}
|
prefix={<SearchOutlined className="text-gray-400" />}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Spin,
|
Skeleton,
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
|
@ -65,6 +65,7 @@ import { spacing } from '@/styles/designTokens';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { AmountText } from '@/components/shared/AmountText';
|
import { AmountText } from '@/components/shared/AmountText';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -438,25 +439,16 @@ export default function Kreditnotaer() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Kreditnotaer"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Salg' }, { title: 'Kreditnotaer' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
|
||||||
marginBottom: spacing.lg,
|
Ny kreditnota
|
||||||
}}
|
</Button>
|
||||||
>
|
}
|
||||||
<div>
|
/>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Kreditnotaer
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
|
|
||||||
Ny kreditnota
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -549,12 +541,7 @@ export default function Kreditnotaer() {
|
||||||
{/* Credit Note Table */}
|
{/* Credit Note Table */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
tip="Indlæser kreditnotaer..."
|
|
||||||
style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}
|
|
||||||
>
|
|
||||||
<div style={{ minHeight: 200 }} />
|
|
||||||
</Spin>
|
|
||||||
) : filteredCreditNotes.length > 0 ? (
|
) : filteredCreditNotes.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredCreditNotes}
|
dataSource={filteredCreditNotes}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
Spin,
|
Skeleton,
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
|
@ -394,9 +394,7 @@ export default function Kunder() {
|
||||||
{/* Customer Table */}
|
{/* Customer Table */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin tip="Indlæser kunder..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
<div style={{ minHeight: 200 }} />
|
|
||||||
</Spin>
|
|
||||||
) : filteredCustomers.length > 0 ? (
|
) : filteredCustomers.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredCustomers}
|
dataSource={filteredCustomers}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ import { useCompany } from '@/hooks/useCompany';
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { DemoDataDisclaimer } from '@/components/shared';
|
import { DemoDataDisclaimer } from '@/components/shared';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
import type { Employee, PayrollEntry } from '@/types/accounting';
|
import type { Employee, PayrollEntry } from '@/types/accounting';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockEmployees: Employee[] = [
|
const mockEmployees: Employee[] = [
|
||||||
|
|
@ -273,25 +274,14 @@ export default function Loenforstaelse() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div
|
title="Lønforståelse"
|
||||||
style={{
|
subtitle={company?.name}
|
||||||
display: 'flex',
|
breadcrumbs={[{ title: 'Løn' }, { title: 'Lønforståelse' }]}
|
||||||
justifyContent: 'space-between',
|
extra={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
|
||||||
Lønforståelse
|
|
||||||
</Title>
|
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
<Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button>
|
<Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button>
|
||||||
</Space>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<DemoDataDisclaimer />
|
<DemoDataDisclaimer />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Empty,
|
Empty,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
|
|
@ -89,25 +90,25 @@ export default function Momsindberetning() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
boxNumber: 2,
|
boxNumber: 2,
|
||||||
nameDanish: 'Moms af varekob i udlandet (EU)',
|
nameDanish: 'Moms af varekøb i udlandet (EU)',
|
||||||
nameEnglish: 'VAT on goods from EU',
|
nameEnglish: 'VAT on goods from EU',
|
||||||
description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande',
|
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
|
||||||
amount: vatReport.boxC,
|
amount: vatReport.boxC,
|
||||||
basis: vatReport.basis3,
|
basis: vatReport.basis3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
boxNumber: 3,
|
boxNumber: 3,
|
||||||
nameDanish: 'Moms af ydelseskob i udlandet',
|
nameDanish: 'Moms af ydelseskøb i udlandet',
|
||||||
nameEnglish: 'VAT on services from abroad',
|
nameEnglish: 'VAT on services from abroad',
|
||||||
description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt',
|
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||||
amount: vatReport.boxD,
|
amount: vatReport.boxD,
|
||||||
basis: vatReport.basis4,
|
basis: vatReport.basis4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
boxNumber: 4,
|
boxNumber: 4,
|
||||||
nameDanish: 'Kobsmoms',
|
nameDanish: 'Købsmoms',
|
||||||
nameEnglish: 'Input VAT',
|
nameEnglish: 'Input VAT',
|
||||||
description: 'Fradragsberettiget moms af kob',
|
description: 'Fradragsberettiget moms af køb',
|
||||||
amount: vatReport.boxB,
|
amount: vatReport.boxB,
|
||||||
basis: undefined, // Backend doesn't provide a specific basis for input VAT
|
basis: undefined, // Backend doesn't provide a specific basis for input VAT
|
||||||
},
|
},
|
||||||
|
|
@ -125,7 +126,7 @@ export default function Momsindberetning() {
|
||||||
return [
|
return [
|
||||||
{ type: 'Salgsmoms', value: vatReport.boxA },
|
{ type: 'Salgsmoms', value: vatReport.boxA },
|
||||||
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
|
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
|
||||||
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
|
{ type: 'Købsmoms (fradrag)', value: inputVAT },
|
||||||
].filter(d => d.value > 0);
|
].filter(d => d.value > 0);
|
||||||
}, [vatReport, inputVAT]);
|
}, [vatReport, inputVAT]);
|
||||||
|
|
||||||
|
|
@ -223,7 +224,9 @@ export default function Momsindberetning() {
|
||||||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
<Tooltip title="Eksport er endnu ikke implementeret">
|
||||||
|
<Button icon={<DownloadOutlined />} disabled>Eksporter</Button>
|
||||||
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
|
|
@ -254,7 +257,7 @@ export default function Momsindberetning() {
|
||||||
onChange={setPeriodType}
|
onChange={setPeriodType}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'monthly', label: 'Maanedlig' },
|
{ value: 'monthly', label: 'Månedlig' },
|
||||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -277,7 +280,7 @@ export default function Momsindberetning() {
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
message="Fejl ved indlaesning af momsdata"
|
message="Fejl ved indlæsning af momsdata"
|
||||||
description={error.message}
|
description={error.message}
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
|
@ -376,7 +379,7 @@ export default function Momsindberetning() {
|
||||||
<Card title="Tidligere indberetninger" size="small">
|
<Card title="Tidligere indberetninger" size="small">
|
||||||
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
|
Tidligere indberetninger vil blive vist her når SKAT-integration er implementeret.
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -392,9 +395,11 @@ export default function Momsindberetning() {
|
||||||
<Button key="cancel" onClick={() => setIsPreviewOpen(false)}>
|
<Button key="cancel" onClick={() => setIsPreviewOpen(false)}>
|
||||||
Luk
|
Luk
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="export" icon={<DownloadOutlined />}>
|
<Tooltip title="PDF-download er endnu ikke implementeret" key="export-tooltip">
|
||||||
Download PDF
|
<Button key="export" icon={<DownloadOutlined />} disabled>
|
||||||
</Button>,
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</Tooltip>,
|
||||||
<Button
|
<Button
|
||||||
key="skat-link"
|
key="skat-link"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -404,7 +409,7 @@ export default function Momsindberetning() {
|
||||||
setIsPreviewOpen(false);
|
setIsPreviewOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ga til skat.dk
|
Gå til skat.dk
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|
@ -436,7 +441,7 @@ export default function Momsindberetning() {
|
||||||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||||
{
|
{
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
title: 'Belob',
|
title: 'Beløb',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: number) => formatCurrency(v),
|
render: (v: number) => formatCurrency(v),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Spin,
|
Skeleton,
|
||||||
Alert,
|
Alert,
|
||||||
Drawer,
|
Drawer,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
|
@ -495,9 +495,7 @@ export default function Ordrer() {
|
||||||
{/* Order Table */}
|
{/* Order Table */}
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
<div style={{ minHeight: 200 }} />
|
|
||||||
</Spin>
|
|
||||||
) : filteredOrders.length > 0 ? (
|
) : filteredOrders.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredOrders}
|
dataSource={filteredOrders}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
message,
|
message,
|
||||||
Space,
|
Space,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
|
|
@ -24,6 +23,8 @@ import {
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
||||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
import BankConnectionsTab from '@/components/settings/BankConnectionsTab';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -293,33 +294,7 @@ export default function Settings() {
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<Card>
|
<BankConnectionsTab companyId={company?.id} />
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
|
||||||
Tilknyttede bankkonti
|
|
||||||
</Title>
|
|
||||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description="Ingen bankkonti tilknyttet endnu"
|
|
||||||
>
|
|
||||||
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
|
|
||||||
Tilføj bankkonto
|
|
||||||
</Button>
|
|
||||||
</Empty>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -360,13 +335,11 @@ export default function Settings() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div style={{ marginBottom: 16 }}>
|
title="Indstillinger"
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
subtitle={company?.name}
|
||||||
Indstillinger
|
breadcrumbs={[{ title: 'Indstillinger' }]}
|
||||||
</Title>
|
/>
|
||||||
<Text type="secondary">{company?.name}</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs items={tabItems} />
|
<Tabs items={tabItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { spacing } from '@/styles/designTokens';
|
import { spacing } from '@/styles/designTokens';
|
||||||
|
import { PageHeader } from '@/components/shared/PageHeader';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -55,8 +56,7 @@ export default function UserSettings() {
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await profileForm.validateFields();
|
await profileForm.validateFields();
|
||||||
console.log('Saving profile:', values);
|
|
||||||
showSuccess('Profil opdateret');
|
showSuccess('Profil opdateret');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
console.error('Validation failed:', error);
|
||||||
|
|
@ -71,7 +71,6 @@ export default function UserSettings() {
|
||||||
showError('Adgangskoderne stemmer ikke overens');
|
showError('Adgangskoderne stemmer ikke overens');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('Changing password');
|
|
||||||
showSuccess('Adgangskode opdateret');
|
showSuccess('Adgangskode opdateret');
|
||||||
passwordForm.resetFields();
|
passwordForm.resetFields();
|
||||||
setIsChangingPassword(false);
|
setIsChangingPassword(false);
|
||||||
|
|
@ -83,8 +82,7 @@ export default function UserSettings() {
|
||||||
|
|
||||||
const handleSaveNotifications = async () => {
|
const handleSaveNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await notificationForm.validateFields();
|
await notificationForm.validateFields();
|
||||||
console.log('Saving notifications:', values);
|
|
||||||
showSuccess('Notifikationsindstillinger gemt');
|
showSuccess('Notifikationsindstillinger gemt');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
console.error('Validation failed:', error);
|
||||||
|
|
@ -124,8 +122,7 @@ export default function UserSettings() {
|
||||||
}
|
}
|
||||||
return false; // Prevent auto upload
|
return false; // Prevent auto upload
|
||||||
},
|
},
|
||||||
onChange: (info) => {
|
onChange: () => {
|
||||||
console.log('Upload:', info.file);
|
|
||||||
showSuccess('Profilbillede opdateret');
|
showSuccess('Profilbillede opdateret');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -458,13 +455,11 @@ export default function UserSettings() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div style={{ marginBottom: spacing.lg }}>
|
title="Min profil"
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
subtitle="Administrer dine personlige indstillinger"
|
||||||
Min profil
|
breadcrumbs={[{ title: 'Brugerindstillinger' }]}
|
||||||
</Title>
|
/>
|
||||||
<Text type="secondary">Administrer dine personlige indstillinger</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs items={tabItems} />
|
<Tabs items={tabItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ export const usePeriodStore = create<PeriodState>()(
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Period is locked',
|
reason: 'Period is locked',
|
||||||
reasonDanish: 'Perioden er laast',
|
reasonDanish: 'Perioden er låst',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,7 +344,7 @@ export const usePeriodStore = create<PeriodState>()(
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Cannot post to future periods',
|
reason: 'Cannot post to future periods',
|
||||||
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder',
|
reasonDanish: 'Kan ikke bogføre i fremtidige perioder',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ export const componentTokens = {
|
||||||
padding: spacing.xl,
|
padding: spacing.xl,
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
width: 200,
|
width: 220,
|
||||||
collapsedWidth: 80,
|
collapsedWidth: 80,
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,29 @@
|
||||||
* Period frequency - how often accounting periods are defined
|
* Period frequency - how often accounting periods are defined
|
||||||
*/
|
*/
|
||||||
export type PeriodFrequency =
|
export type PeriodFrequency =
|
||||||
| 'monthly' // Maanedlig
|
| 'monthly' // Månedlig
|
||||||
| 'quarterly' // Kvartalsvis
|
| 'quarterly' // Kvartalsvis
|
||||||
| 'half-yearly' // Halvaarlig
|
| 'half-yearly' // Halvårlig
|
||||||
| 'yearly'; // Aarlig
|
| 'yearly'; // Årlig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Period status according to Danish accounting requirements
|
* Period status according to Danish accounting requirements
|
||||||
*/
|
*/
|
||||||
export type PeriodStatus =
|
export type PeriodStatus =
|
||||||
| 'future' // Fremtidig - not yet started
|
| 'future' // Fremtidig - not yet started
|
||||||
| 'open' // Aaben - current working period
|
| 'open' // Åben - current working period
|
||||||
| 'closed' // Lukket - closed but can be reopened
|
| 'closed' // Lukket - closed but can be reopened
|
||||||
| 'locked'; // Laast - permanently locked (after arsafslutning)
|
| 'locked'; // Låst - permanently locked (after årsafslutning)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VAT Period frequency (can differ from accounting periods)
|
* VAT Period frequency (can differ from accounting periods)
|
||||||
* Based on SKAT requirements
|
* Based on SKAT requirements
|
||||||
*/
|
*/
|
||||||
export type VATPeriodicitet =
|
export type VATPeriodicitet =
|
||||||
| 'monthly' // Maanedlig (omsaetning > 50M DKK)
|
| 'monthly' // Månedlig (omsætning > 50M DKK)
|
||||||
| 'quarterly' // Kvartalsvis (default for most)
|
| 'quarterly' // Kvartalsvis (default for most)
|
||||||
| 'half-yearly' // Halvaarlig (omsaetning < 1M DKK, optional)
|
| 'half-yearly' // Halvårlig (omsætning < 1M DKK, optional)
|
||||||
| 'yearly'; // Aarlig (omsaetning < 300K DKK, optional)
|
| 'yearly'; // Årlig (omsætning < 300K DKK, optional)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiscal Year (Regnskabsaar)
|
* Fiscal Year (Regnskabsaar)
|
||||||
|
|
@ -230,10 +230,10 @@ export const DANISH_MONTHS_SHORT = [
|
||||||
* Period frequency display names
|
* Period frequency display names
|
||||||
*/
|
*/
|
||||||
export const PERIOD_FREQUENCY_NAMES: Record<PeriodFrequency, { danish: string; english: string }> = {
|
export const PERIOD_FREQUENCY_NAMES: Record<PeriodFrequency, { danish: string; english: string }> = {
|
||||||
'monthly': { danish: 'Maanedlig', english: 'Monthly' },
|
'monthly': { danish: 'Månedlig', english: 'Monthly' },
|
||||||
'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' },
|
'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' },
|
||||||
'half-yearly': { danish: 'Halvaarslig', english: 'Half-yearly' },
|
'half-yearly': { danish: 'Halvårslig', english: 'Half-yearly' },
|
||||||
'yearly': { danish: 'Aarlig', english: 'Yearly' },
|
'yearly': { danish: 'Årlig', english: 'Yearly' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -252,7 +252,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
|
||||||
icon: 'clock-circle'
|
icon: 'clock-circle'
|
||||||
},
|
},
|
||||||
'open': {
|
'open': {
|
||||||
danish: 'Aaben',
|
danish: 'Åben',
|
||||||
english: 'Open',
|
english: 'Open',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
icon: 'check-circle'
|
icon: 'check-circle'
|
||||||
|
|
@ -264,7 +264,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
|
||||||
icon: 'minus-circle'
|
icon: 'minus-circle'
|
||||||
},
|
},
|
||||||
'locked': {
|
'locked': {
|
||||||
danish: 'Laast',
|
danish: 'Låst',
|
||||||
english: 'Locked',
|
english: 'Locked',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
icon: 'lock'
|
icon: 'lock'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue