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-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-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-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-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"}
|
||||
|
|
@ -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-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-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-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"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.Invoices;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.Invoicing.Services;
|
||||
using EventFlow.Commands;
|
||||
|
||||
|
|
@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices;
|
|||
/// <summary>
|
||||
/// Command handler for creating invoices.
|
||||
/// Auto-assigns a sequential invoice number if one is not provided.
|
||||
/// Validates the company has a CVR number (required for invoicing).
|
||||
/// </summary>
|
||||
public class CreateInvoiceCommandHandler(
|
||||
IInvoiceNumberService invoiceNumberService)
|
||||
IInvoiceNumberService invoiceNumberService,
|
||||
ICompanyRepository companyRepository)
|
||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||
{
|
||||
public override async Task ExecuteAsync(
|
||||
|
|
@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler(
|
|||
CreateInvoiceCommand command,
|
||||
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
|
||||
var invoiceNumber = command.InvoiceNumber;
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
|
|
|
|||
|
|
@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler(
|
|||
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
||||
}
|
||||
|
||||
// Validate document date falls within fiscal year range (if document date is set)
|
||||
if (draft?.DocumentDate != null)
|
||||
// Validate document date is set (required for posting per Bogføringsloven)
|
||||
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 fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
||||
|
|
|
|||
|
|
@ -197,6 +197,20 @@ public class AttachmentController(
|
|||
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);
|
||||
|
||||
if (file == null)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections;
|
|||
using Books.Api.Domain.BankConnections;
|
||||
using EventFlow;
|
||||
using EventFlow.Aggregates.ExecutionResults;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Books.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/banking")]
|
||||
[Authorize]
|
||||
public class BankingController : ControllerBase
|
||||
{
|
||||
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;
|
||||
_companyId = e.CompanyId;
|
||||
_transactionId = e.TransactionId;
|
||||
_uploadedAt = DateTimeOffset.UtcNow;
|
||||
_uploadedAt = e.UploadedAt;
|
||||
}
|
||||
|
||||
public void Apply(AttachmentLinkedToTransactionEvent e)
|
||||
|
|
@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id)
|
|||
fileSize,
|
||||
storagePath.Trim(),
|
||||
uploadedBy,
|
||||
DateTimeOffset.UtcNow,
|
||||
draftId?.Trim(),
|
||||
transactionId?.Trim()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public class AttachmentUploadedEvent(
|
|||
long fileSize,
|
||||
string storagePath,
|
||||
string uploadedBy,
|
||||
DateTimeOffset uploadedAt,
|
||||
string? draftId = null,
|
||||
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
|
||||
{
|
||||
|
|
@ -46,6 +47,11 @@ public class AttachmentUploadedEvent(
|
|||
|
||||
public string UploadedBy { get; } = uploadedBy;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the attachment was uploaded.
|
||||
/// </summary>
|
||||
public DateTimeOffset UploadedAt { get; } = uploadedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to journal entry draft.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
|||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 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(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
|
|
@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
|||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 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(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
|
|
|
|||
|
|
@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
/// <summary>
|
||||
/// Validates a single draft line.
|
||||
/// Amounts must be non-negative.
|
||||
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
||||
/// At least one of DebitAmount or CreditAmount must be > 0.
|
||||
/// </summary>
|
||||
private static void ValidateDraftLine(DraftLine line)
|
||||
{
|
||||
if (line.DebitAmount < 0)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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 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
|
||||
if (isInputVat)
|
||||
vatLine = vatLine with
|
||||
{
|
||||
// Purchases: VAT follows the expense direction (typically debit)
|
||||
vatLine = vatLine with
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
DebitAmount = isDebit ? vatAmount : 0,
|
||||
CreditAmount = !isDebit ? vatAmount : 0
|
||||
};
|
||||
|
||||
vatLines.Add(vatLine);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent(
|
|||
string userId,
|
||||
string companyId,
|
||||
CompanyRole role,
|
||||
string grantedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
string grantedBy,
|
||||
DateTimeOffset grantedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
{
|
||||
public string UserId { get; } = userId;
|
||||
public string CompanyId { get; } = companyId;
|
||||
public CompanyRole Role { get; } = role;
|
||||
public string GrantedBy { get; } = grantedBy;
|
||||
public DateTimeOffset GrantedAt { get; } = grantedAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent(
|
|||
/// Emitted when a user's access to a company is revoked.
|
||||
/// </summary>
|
||||
public class UserCompanyAccessRevokedEvent(
|
||||
string revokedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
string revokedBy,
|
||||
DateTimeOffset revokedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
{
|
||||
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
|
||||
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy));
|
||||
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
"Adgang er allerede tilbagekaldt");
|
||||
}
|
||||
|
||||
Emit(new UserCompanyAccessRevokedEvent(revokedBy));
|
||||
Emit(new UserCompanyAccessRevokedEvent(revokedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public void Apply(UserCompanyAccessGrantedEvent e)
|
||||
|
|
@ -82,7 +82,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
CompanyId = e.CompanyId;
|
||||
Role = e.Role;
|
||||
GrantedBy = e.GrantedBy;
|
||||
GrantedAt = DateTimeOffset.UtcNow;
|
||||
GrantedAt = e.GrantedAt;
|
||||
IsActive = true;
|
||||
RevokedAt = null;
|
||||
RevokedBy = null;
|
||||
|
|
@ -96,7 +96,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
public void Apply(UserCompanyAccessRevokedEvent e)
|
||||
{
|
||||
IsActive = false;
|
||||
RevokedAt = DateTimeOffset.UtcNow;
|
||||
RevokedAt = e.RevokedAt;
|
||||
RevokedBy = e.RevokedBy;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepo
|
|||
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)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories;
|
|||
public interface IAttachmentRepository
|
||||
{
|
||||
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>> GetByDraftIdAsync(string draftId, 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("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
|
||||
// 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.Repositories;
|
||||
using Books.Api.Saft.Models;
|
||||
|
|
@ -294,6 +295,9 @@ public class SaftExportService(
|
|||
if (isDebit) totalDebit += 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(
|
||||
(idx + 1).ToString(),
|
||||
accountNumber ?? entry.AccountId.ToString(),
|
||||
|
|
@ -302,7 +306,7 @@ public class SaftExportService(
|
|||
creditAmount,
|
||||
null, // CustomerID - could parse from reference
|
||||
null, // SupplierID
|
||||
null); // TaxInfo - would need VAT code tracking
|
||||
taxInfo);
|
||||
}).ToList();
|
||||
|
||||
transactions.Add(new SaftTransaction(
|
||||
|
|
@ -385,6 +389,37 @@ public class SaftExportService(
|
|||
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>
|
||||
/// Validates a Danish CVR number.
|
||||
/// A valid CVR is exactly 8 digits.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public class SaftXmlBuilder
|
|||
writer.WriteStartElement("Header");
|
||||
|
||||
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
|
||||
writer.WriteElementString("AuditFileCountry", "DK");
|
||||
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
|
||||
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
|
||||
writer.WriteElementString("SoftwareID", header.SoftwareID);
|
||||
|
|
@ -72,6 +73,12 @@ public class SaftXmlBuilder
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
try {
|
||||
await postDraftMutation.mutateAsync(result.draftId);
|
||||
message.success('Bogfoert!');
|
||||
message.success('Bogført!');
|
||||
onConfirm();
|
||||
} catch (err) {
|
||||
message.error('Kunne ikke bogføre. Prøv igen.');
|
||||
|
|
@ -154,7 +154,7 @@ export function DocumentUploadModal({
|
|||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
AI-tjenesten udtraekker information fra dokumentet
|
||||
AI-tjenesten udtrækker information fra dokumentet
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -206,7 +206,7 @@ export function DocumentUploadModal({
|
|||
Luk
|
||||
</Button>,
|
||||
<Button key="view" type="primary" onClick={onConfirm}>
|
||||
Gaa til kladde
|
||||
Gå til kladde
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={onClose}
|
||||
|
|
@ -279,7 +279,7 @@ export function DocumentUploadModal({
|
|||
loading={isPosting}
|
||||
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
|
||||
>
|
||||
Godkend og bogfoer
|
||||
Godkend og bogfør
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={handleCancel}
|
||||
|
|
@ -297,7 +297,7 @@ export function DocumentUploadModal({
|
|||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
|
||||
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
|
||||
Foreslaaet bogfoering
|
||||
Foreslået bogføring
|
||||
</Title>
|
||||
{journalLines.length > 0 && (
|
||||
isBalanced ? (
|
||||
|
|
@ -341,8 +341,8 @@ export function DocumentUploadModal({
|
|||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message="Ingen kontobogfoering foreslaaet"
|
||||
description="AI kunne ikke foreslaa konti til dette dokument. Du kan tilfoeje dokumentet til kladden og bogfoere manuelt."
|
||||
message="Ingen kontobogføring foreslået"
|
||||
description="AI kunne ikke foreslå konti til dette dokument. Du kan tilføje dokumentet til kladden og bogføre manuelt."
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<InfoCircleOutlined />}
|
||||
|
|
@ -394,7 +394,7 @@ export function DocumentUploadModal({
|
|||
<Divider style={{ margin: `${spacing.md}px 0` }} />
|
||||
<Alert
|
||||
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"
|
||||
showIcon
|
||||
icon={<InfoCircleOutlined />}
|
||||
|
|
@ -406,7 +406,7 @@ export function DocumentUploadModal({
|
|||
{result.accountSuggestion && (
|
||||
<div style={{ marginTop: spacing.sm }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Kontoforslag baseret paa AI-analyse (
|
||||
Kontoforslag baseret på AI-analyse (
|
||||
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
|
||||
</Text>
|
||||
</div>
|
||||
|
|
@ -474,7 +474,7 @@ function ExtractedInfoSection({
|
|||
style={{ marginBottom: hasLineItems ? 12 : 0 }}
|
||||
>
|
||||
{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>
|
||||
</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 { useCompanyStore } from '@/stores/companyStore';
|
||||
import { formatCVR } from '@/lib/formatters';
|
||||
|
|
@ -22,7 +22,7 @@ export default function CompanySwitcher({ compact = false }: CompanySwitcherProp
|
|||
};
|
||||
|
||||
if (companies.length === 0) {
|
||||
return <Skeleton.Input style={{ width: 200 }} active />;
|
||||
return <Text type="secondary">Ingen virksomheder</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
MinusCircleOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { useFiscalYears } from '@/api/queries/fiscalYearQueries';
|
||||
|
|
@ -50,6 +51,7 @@ interface FiscalYearSelectorProps {
|
|||
}
|
||||
|
||||
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
|
||||
const navigate = useNavigate();
|
||||
const { activeCompany } = useCompanyStore();
|
||||
const {
|
||||
fiscalYears,
|
||||
|
|
@ -126,8 +128,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
|
|||
if (onManage) {
|
||||
onManage();
|
||||
} else {
|
||||
// Navigate to settings page
|
||||
console.log('Navigate to fiscal year settings');
|
||||
navigate('/indstillinger');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
|
|
@ -129,22 +129,28 @@ export default function Header({ isMobile = false }: HeaderProps) {
|
|||
<Button
|
||||
type="text"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
onClick={() => window.open('https://help.books.dk', '_blank')}
|
||||
onClick={() => message.info('Hjælp er under udvikling')}
|
||||
aria-label="Hjælp"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Notifications */}
|
||||
<Tooltip title="Notifikationer">
|
||||
<Badge count={0} size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
onClick={() => navigate('/indstillinger')}
|
||||
aria-label="Notifikationer"
|
||||
/>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
content="Ingen nye notifikationer"
|
||||
title="Notifikationer"
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Tooltip title="Notifikationer">
|
||||
<Badge count={0} size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label="Notifikationer"
|
||||
/>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ export default function Sidebar() {
|
|||
collapsed={sidebarCollapsed}
|
||||
onCollapse={toggleSidebar}
|
||||
width={220}
|
||||
aria-label="Hovednavigation"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ import {
|
|||
import { formatCurrency } from '@/lib/formatters';
|
||||
import type { FiscalYear } from '@/types/periods';
|
||||
import type { Account, Transaction } from '@/types/accounting';
|
||||
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { useCloseFiscalYear, useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
|
||||
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||
import { message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -85,6 +87,9 @@ export default function CloseFiscalYearWizard({
|
|||
} = usePeriodStore();
|
||||
|
||||
const closeFiscalYearMutation = useCloseFiscalYear();
|
||||
const createFiscalYearMutation = useCreateFiscalYear();
|
||||
const createDraftMutation = useCreateJournalEntryDraft();
|
||||
const updateDraftMutation = useUpdateJournalEntryDraft();
|
||||
|
||||
// Reset wizard when opened
|
||||
useEffect(() => {
|
||||
|
|
@ -164,34 +169,70 @@ export default function CloseFiscalYearWizard({
|
|||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
|
||||
// in generateClosingEntries() but never actually posted to the ledger.
|
||||
// Before closing the fiscal year, these closing entries MUST be posted:
|
||||
// 1. Revenue accounts should be zeroed out to the result account
|
||||
// 2. Expense accounts should be zeroed out to the result account
|
||||
// 3. The net result should be transferred to the equity account (resultAccountId)
|
||||
// Without posting these entries, the opening balances for the next year will be incorrect.
|
||||
// 1. Post closing entries as journal entry drafts
|
||||
for (const entry of closingEntries) {
|
||||
// Create a draft for each closing entry
|
||||
const draft = await createDraftMutation.mutateAsync({
|
||||
companyId: fiscalYear.companyId,
|
||||
name: entry.descriptionDanish,
|
||||
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) {
|
||||
for (const period of openPeriodsInYear) {
|
||||
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) {
|
||||
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);
|
||||
|
||||
// 4. Also update local store
|
||||
// 5. Also update local store
|
||||
closeFiscalYear(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');
|
||||
|
||||
onSuccess?.();
|
||||
|
|
|
|||
|
|
@ -206,8 +206,6 @@ export default function BankConnectionsTab({ companyId }: BankConnectionsTabProp
|
|||
// 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 redirectUrl = `${apiBaseUrl}/api/banking/callback`;
|
||||
console.log('Enable Banking redirect URL:', redirectUrl);
|
||||
|
||||
const result = await startConnection.mutateAsync({
|
||||
companyId,
|
||||
aspspName: selectedBank,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,10 @@ export function AmountText({
|
|||
|
||||
const formatAmount = (): string => {
|
||||
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}` : '';
|
||||
|
||||
return `${sign}${formatted}${suffix}`;
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
|||
lines.push({
|
||||
accountId: `vat-output-${vatCode}`,
|
||||
accountNumber: outputVatAccount,
|
||||
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
|
||||
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
||||
description: `Moms: ${description}`,
|
||||
debit: 0,
|
||||
credit: vatAmount,
|
||||
|
|
@ -392,7 +392,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
|||
lines: [],
|
||||
bankTransactionId: bankTransaction.id,
|
||||
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({
|
||||
accountId: `vat-output-${splitLine.vatCode}`,
|
||||
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}`,
|
||||
debit: 0,
|
||||
credit: lineVat,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function formatNumber(
|
|||
*/
|
||||
export function formatDate(
|
||||
date: string | Date,
|
||||
format: string = 'DD/MM/YYYY'
|
||||
format: string = 'DD-MM-YYYY'
|
||||
): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export function formatDate(
|
|||
*/
|
||||
export function formatDateTime(
|
||||
date: string | Date,
|
||||
format: string = 'DD/MM/YYYY HH:mm'
|
||||
format: string = 'DD-MM-YYYY HH:mm'
|
||||
): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ export function canPostToDate(
|
|||
return {
|
||||
allowed: false,
|
||||
reason: 'Period is locked',
|
||||
reasonDanish: 'Perioden er laast',
|
||||
reasonDanish: 'Perioden er låst',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +386,7 @@ export function canPostToDate(
|
|||
return {
|
||||
allowed: false,
|
||||
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({
|
||||
code: 'PERIOD_LOCKED',
|
||||
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,
|
||||
deductible: false,
|
||||
description: 'Moms paa salg af varer og ydelser i Danmark',
|
||||
description: 'Moms på salg af varer og ydelser i Danmark',
|
||||
},
|
||||
K25: {
|
||||
code: 'K25',
|
||||
|
|
@ -39,11 +39,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
},
|
||||
reverseCharge: false,
|
||||
deductible: true,
|
||||
description: 'Fradragsberettiget moms paa koeb',
|
||||
description: 'Fradragsberettiget moms på køb',
|
||||
},
|
||||
EU_VARE: {
|
||||
code: 'EU_VARE',
|
||||
nameDanish: 'EU-varekoeb (erhvervelsesmoms)',
|
||||
nameDanish: 'EU-varekøb (erhvervelsesmoms)',
|
||||
nameEnglish: 'EU goods purchase (acquisition VAT)',
|
||||
rate: 0.25,
|
||||
type: 'reverse_charge',
|
||||
|
|
@ -53,11 +53,11 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
},
|
||||
reverseCharge: true,
|
||||
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: {
|
||||
code: 'EU_YDELSE',
|
||||
nameDanish: 'EU-ydelseskoeb (omvendt betalingspligt)',
|
||||
nameDanish: 'EU-ydelseskøb (omvendt betalingspligt)',
|
||||
nameEnglish: 'EU services purchase (reverse charge)',
|
||||
rate: 0.25,
|
||||
type: 'reverse_charge',
|
||||
|
|
@ -67,7 +67,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
},
|
||||
reverseCharge: true,
|
||||
deductible: true,
|
||||
description: 'Koeb af ydelser fra udlandet med omvendt betalingspligt',
|
||||
description: 'Køb af ydelser fra udlandet med omvendt betalingspligt',
|
||||
},
|
||||
MOMSFRI: {
|
||||
code: 'MOMSFRI',
|
||||
|
|
@ -114,7 +114,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
|
|||
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
|
||||
monthly: {
|
||||
type: 'monthly',
|
||||
nameDanish: 'Maanedlig',
|
||||
nameDanish: 'Månedlig',
|
||||
nameEnglish: 'Monthly',
|
||||
deadlineDaysAfterPeriod: 25, // 25th of following month
|
||||
periodsPerYear: 12,
|
||||
|
|
@ -130,7 +130,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
|||
},
|
||||
'half-yearly': {
|
||||
type: 'half-yearly',
|
||||
nameDanish: 'Halvaarslig',
|
||||
nameDanish: 'Halvårslig',
|
||||
nameEnglish: 'Half-yearly',
|
||||
deadlineDaysAfterPeriod: 60, // ~2 months after period
|
||||
periodsPerYear: 2,
|
||||
|
|
@ -138,7 +138,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
|||
},
|
||||
yearly: {
|
||||
type: 'yearly',
|
||||
nameDanish: 'Aarslig',
|
||||
nameDanish: 'Årslig',
|
||||
nameEnglish: 'Yearly',
|
||||
deadlineDaysAfterPeriod: 90, // March 1st for calendar year
|
||||
periodsPerYear: 1,
|
||||
|
|
@ -150,7 +150,7 @@ export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetCon
|
|||
* SKAT VAT box definitions
|
||||
*/
|
||||
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
||||
// VAT amounts (Momsbeloeb)
|
||||
// VAT amounts (Momsbeløb)
|
||||
A: {
|
||||
id: 'A',
|
||||
type: 'vat',
|
||||
|
|
@ -163,37 +163,37 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
|||
B: {
|
||||
id: 'B',
|
||||
type: 'vat',
|
||||
nameDanish: 'Koebsmoms',
|
||||
nameDanish: 'Købsmoms',
|
||||
nameEnglish: 'Input VAT (purchases)',
|
||||
description: 'Fradragsberettiget moms af koeb',
|
||||
description: 'Fradragsberettiget moms af køb',
|
||||
skippable: false,
|
||||
isDeductible: true,
|
||||
},
|
||||
C: {
|
||||
id: 'C',
|
||||
type: 'vat',
|
||||
nameDanish: 'Moms af EU-varekoeb',
|
||||
nameDanish: 'Moms af EU-varekøb',
|
||||
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,
|
||||
isDeductible: false, // Listed as output, but can be deducted via B
|
||||
},
|
||||
D: {
|
||||
id: 'D',
|
||||
type: 'vat',
|
||||
nameDanish: 'Moms af ydelseskoeb fra udland',
|
||||
nameDanish: 'Moms af ydelseskøb fra udland',
|
||||
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,
|
||||
isDeductible: false,
|
||||
},
|
||||
// Basis/turnover amounts (Omsaetning)
|
||||
// Basis/turnover amounts (Omsætning)
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'basis',
|
||||
nameDanish: 'Salg med moms',
|
||||
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,
|
||||
isDeductible: false,
|
||||
},
|
||||
|
|
@ -209,18 +209,18 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
|
|||
'3': {
|
||||
id: '3',
|
||||
type: 'basis',
|
||||
nameDanish: 'EU-varekoeb',
|
||||
nameDanish: 'EU-varekøb',
|
||||
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,
|
||||
isDeductible: false,
|
||||
},
|
||||
'4': {
|
||||
id: '4',
|
||||
type: 'basis',
|
||||
nameDanish: 'Ydelseskoeb fra udland',
|
||||
nameDanish: 'Ydelseskøb fra udland',
|
||||
nameEnglish: 'Foreign services purchases',
|
||||
description: 'Vaerdi af ydelser koebt fra udlandet',
|
||||
description: 'Værdi af ydelser købt fra udlandet',
|
||||
skippable: true,
|
||||
isDeductible: false,
|
||||
},
|
||||
|
|
@ -310,9 +310,9 @@ export function getPeriodicitetOptions(): Array<{ value: VATPeriodicitet; label:
|
|||
value: config.type,
|
||||
label: config.nameDanish,
|
||||
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
|
||||
? `Omsaetning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
|
||||
? `Omsætning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
|
||||
: 'Standard',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from 'antd';
|
||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||
import {
|
||||
ToolOutlined,
|
||||
ReloadOutlined,
|
||||
LockOutlined,
|
||||
DashboardOutlined,
|
||||
|
|
@ -24,6 +23,7 @@ import { useCanAdmin } from '@/stores/companyStore';
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { graphqlClient } from '@/api/client';
|
||||
import { gql } from 'graphql-request';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -129,13 +129,11 @@ export default function Admin() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ToolOutlined /> Administration
|
||||
</Title>
|
||||
<Text type="secondary">Systemværktøjer til fejlfinding og vedligeholdelse</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
subtitle="Systemværktøjer til fejlfinding og vedligeholdelse"
|
||||
breadcrumbs={[{ title: 'Administration' }]}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
message="Advarsel"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { useReconciliationStore } from '@/stores/reconciliationStore';
|
|||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
|
||||
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
|
||||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||
import { formatCurrency, formatDate } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import type { BankTransaction } from '@/types/accounting';
|
||||
|
|
@ -71,6 +72,7 @@ export default function Bankafstemning() {
|
|||
// Fetch data from API
|
||||
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
|
||||
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
|
||||
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id);
|
||||
|
||||
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);
|
||||
setSelectedBankTx(null);
|
||||
} 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) => {
|
||||
addPendingMatch({
|
||||
bankTransactionId: suggestion.bankTransactionId,
|
||||
|
|
@ -263,14 +254,15 @@ export default function Bankafstemning() {
|
|||
>
|
||||
Nulstil valg
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={pendingMatches.length === 0}
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
<Tooltip title="Gem-funktionen er under udvikling">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
disabled
|
||||
>
|
||||
Gem afstemninger ({pendingMatches.length})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
|
@ -389,7 +381,7 @@ export default function Bankafstemning() {
|
|||
</Space>
|
||||
}
|
||||
size="small"
|
||||
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
|
||||
styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
|
||||
>
|
||||
{bankTransactions.length === 0 ? (
|
||||
<Empty
|
||||
|
|
@ -500,7 +492,7 @@ export default function Bankafstemning() {
|
|||
</Space>
|
||||
}
|
||||
size="small"
|
||||
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
|
||||
styles={{ body: { padding: 0, maxHeight: 500, overflow: 'auto' } }}
|
||||
>
|
||||
{ledgerEntries.length === 0 ? (
|
||||
<Empty
|
||||
|
|
@ -626,7 +618,7 @@ export default function Bankafstemning() {
|
|||
|
||||
{/* Create Entry Modal */}
|
||||
<Modal
|
||||
title="Opret bogforingspost"
|
||||
title="Opret bogføringspost"
|
||||
open={isCreateModalOpen}
|
||||
onCancel={() => {
|
||||
setIsCreateModalOpen(false);
|
||||
|
|
@ -674,12 +666,12 @@ export default function Bankafstemning() {
|
|||
>
|
||||
<Select
|
||||
placeholder="Vælg konto"
|
||||
options={[
|
||||
{ value: '6100', label: '6100 - Husleje' },
|
||||
{ value: '6800', label: '6800 - Kontorartikler' },
|
||||
{ value: '5000', label: '5000 - Varekøb' },
|
||||
{ value: '4000', label: '4000 - Salg' },
|
||||
]}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={activeAccounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.accountNumber} - ${acc.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="vatCode" label="Momskode">
|
||||
|
|
|
|||
|
|
@ -119,9 +119,6 @@ export default function CompanySetupWizard() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('Creating company with values:', values);
|
||||
|
||||
const company = await createCompany.mutateAsync({
|
||||
name: values.name.trim(),
|
||||
cvr: values.cvr?.trim() || undefined,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Line, Pie, Column } from '@ant-design/charts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { useCompanyStore } from '@/stores/companyStore';
|
||||
|
|
@ -312,7 +313,7 @@ export default function Dashboard() {
|
|||
formatter={(value) => formatCurrency(value as number)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href="/momsindberetning">Se momsindberetning</a>
|
||||
<Link to="/momsindberetning">Se momsindberetning</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -384,7 +385,7 @@ export default function Dashboard() {
|
|||
<Card
|
||||
title="Seneste transaktioner"
|
||||
size="small"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
||||
{recentTransactions.length > 0 ? (
|
||||
|
|
@ -438,9 +439,9 @@ export default function Dashboard() {
|
|||
<Col>
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
|
||||
<a href="/momsindberetning">
|
||||
<Link to="/momsindberetning">
|
||||
<Text>Se momsindberetning</Text>
|
||||
</a>
|
||||
</Link>
|
||||
</Space>
|
||||
</Col>
|
||||
{metrics.overdueInvoices > 0 && (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Button,
|
||||
Tag,
|
||||
Alert,
|
||||
Spin,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||
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 { formatDate } from '@/lib/formatters';
|
||||
import { spacing } from '@/styles/designTokens';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
export default function Eksport() {
|
||||
const { company } = useCompany();
|
||||
|
|
@ -53,8 +54,20 @@ export default function Eksport() {
|
|||
|
||||
if (fiscalYearsLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: spacing.xl }}>
|
||||
<Spin size="large" />
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,10 +76,11 @@ export default function Eksport() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>Eksporter data</Title>
|
||||
<Paragraph type="secondary">
|
||||
Eksporter regnskabsdata til forskellige formater for compliance og rapportering.
|
||||
</Paragraph>
|
||||
<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]}>
|
||||
{/* SAF-T Export Card */}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
Spin,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -42,6 +42,7 @@ import { useCurrentFiscalYear } from '@/stores/periodStore';
|
|||
import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries';
|
||||
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
|
||||
import { useActiveProducts, type Product } from '@/api/queries/productQueries';
|
||||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||
import {
|
||||
useCreateInvoice,
|
||||
useAddInvoiceLine,
|
||||
|
|
@ -124,6 +125,13 @@ export default function Fakturaer() {
|
|||
// Fetch products for line form
|
||||
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
|
||||
const createInvoiceMutation = useCreateInvoice();
|
||||
const addInvoiceLineMutation = useAddInvoiceLine();
|
||||
|
|
@ -560,9 +568,7 @@ export default function Fakturaer() {
|
|||
{/* Invoice Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin tip="Indlæser fakturaer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : filteredInvoices.length > 0 ? (
|
||||
<Table
|
||||
dataSource={filteredInvoices}
|
||||
|
|
@ -968,9 +974,12 @@ export default function Fakturaer() {
|
|||
>
|
||||
<Select
|
||||
placeholder="Vælg bankkonto"
|
||||
options={[
|
||||
{ value: 'bank-hovedkonto', label: '5600 - Bankkonto' },
|
||||
]}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={bankAccounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.accountNumber} - ${acc.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentDate" label="Betalingsdato">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
Dropdown,
|
||||
Skeleton,
|
||||
Empty,
|
||||
Descriptions,
|
||||
Table,
|
||||
Drawer,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
|
|
@ -34,7 +37,7 @@ import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
|
|||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { validateDoubleEntry } from '@/lib/accounting';
|
||||
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
|
||||
import type { TransactionLine, JournalEntryDraft, JournalEntryDraftStatus } from '@/types/accounting';
|
||||
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
|
||||
import { usePeriodStore } from '@/stores/periodStore';
|
||||
|
||||
|
|
@ -51,7 +54,10 @@ interface DraftDisplay {
|
|||
totalCredit: number;
|
||||
isReconciled: boolean;
|
||||
isVoided: boolean;
|
||||
status: JournalEntryDraftStatus;
|
||||
lines: JournalEntryDraft['lines'];
|
||||
postedAt?: string;
|
||||
postedBy?: string;
|
||||
}
|
||||
|
||||
export default function Kassekladde() {
|
||||
|
|
@ -59,6 +65,10 @@ export default function Kassekladde() {
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingDraft, setEditingDraft] = useState<DraftDisplay | 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 [lines, setLines] = useState<Partial<TransactionLine>[]>([
|
||||
{ debit: 0, credit: 0 },
|
||||
|
|
@ -79,7 +89,7 @@ export default function Kassekladde() {
|
|||
const isLoading = accountsLoading || draftsLoading;
|
||||
|
||||
// Convert drafts to display format
|
||||
const displayData: DraftDisplay[] = drafts.map(draft => ({
|
||||
const displayData: DraftDisplay[] = useMemo(() => drafts.map(draft => ({
|
||||
id: draft.id,
|
||||
transactionNumber: draft.voucherNumber || draft.name,
|
||||
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,
|
||||
isReconciled: draft.status === 'posted',
|
||||
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>[] = [
|
||||
{
|
||||
|
|
@ -134,7 +199,7 @@ export default function Kassekladde() {
|
|||
return <Tag color="red">Annulleret</Tag>;
|
||||
}
|
||||
return value ? (
|
||||
<Tag color="green">Bogført</Tag>
|
||||
<Tag color="green">Bogfort</Tag>
|
||||
) : (
|
||||
<Tag color="orange">Kladde</Tag>
|
||||
);
|
||||
|
|
@ -192,7 +257,7 @@ export default function Kassekladde() {
|
|||
const handleAction = (action: string, record: DraftDisplay) => {
|
||||
switch (action) {
|
||||
case 'view':
|
||||
message.info(`Vis detaljer for bilag ${record.transactionNumber}`);
|
||||
setDetailDraft(record);
|
||||
break;
|
||||
case 'edit':
|
||||
setEditingDraft(record);
|
||||
|
|
@ -236,7 +301,7 @@ export default function Kassekladde() {
|
|||
case 'void':
|
||||
Modal.confirm({
|
||||
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',
|
||||
okType: 'danger',
|
||||
cancelText: 'Fortryd',
|
||||
|
|
@ -287,7 +352,7 @@ export default function Kassekladde() {
|
|||
const validation = validateDoubleEntry(lines as TransactionLine[]);
|
||||
if (!validation.valid) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -347,6 +412,7 @@ export default function Kassekladde() {
|
|||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingDraft(null);
|
||||
form.resetFields();
|
||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||
} 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[]);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -364,7 +447,7 @@ export default function Kassekladde() {
|
|||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
|
|
@ -376,13 +459,15 @@ export default function Kassekladde() {
|
|||
<PageHeader
|
||||
title="Kassekladde"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
breadcrumbs={[{ title: 'Bogforing', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingDraft(null);
|
||||
form.resetFields();
|
||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
|
|
@ -397,36 +482,58 @@ export default function Kassekladde() {
|
|||
placeholder={['Fra dato', 'Til dato']}
|
||||
value={dateFilter}
|
||||
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
|
||||
format="DD/MM/YYYY"
|
||||
format="DD-MM-YYYY"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Konto"
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
options={accounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.accountNumber} - ${acc.name}`,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'posted', label: 'Bogført' },
|
||||
{ value: 'draft', label: 'Kladde' },
|
||||
{ value: 'discarded', label: 'Annulleret' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>Flere filtre</Button>
|
||||
{showAdvancedFilters && (
|
||||
<>
|
||||
<Select
|
||||
placeholder="Konto"
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
value={accountFilter}
|
||||
onChange={(value) => setAccountFilter(value ?? null)}
|
||||
options={accounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.accountNumber} - ${acc.name}`,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={statusFilter}
|
||||
onChange={(value) => setStatusFilter(value ?? null)}
|
||||
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>
|
||||
|
||||
{/* Data Table */}
|
||||
{displayData.length === 0 ? (
|
||||
{filteredData.length === 0 ? (
|
||||
<Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." />
|
||||
) : (
|
||||
<DataTable<DraftDisplay>
|
||||
data={displayData}
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
exportFilename="kassekladde"
|
||||
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 */}
|
||||
<Modal
|
||||
title={editingDraft ? 'Rediger bilag' : 'Nyt bilag'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingDraft(null);
|
||||
form.resetFields();
|
||||
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
|
||||
}}
|
||||
|
|
@ -457,10 +647,10 @@ export default function Kassekladde() {
|
|||
<Form.Item
|
||||
name="date"
|
||||
label="Dato"
|
||||
rules={[{ required: true, message: 'Vælg dato' }]}
|
||||
rules={[{ required: true, message: 'Vaelg dato' }]}
|
||||
initialValue={dayjs()}
|
||||
>
|
||||
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
|
||||
<DatePicker format="DD-MM-YYYY" style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
|
|
@ -493,7 +683,7 @@ export default function Kassekladde() {
|
|||
<td style={{ padding: 4 }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Vælg konto"
|
||||
placeholder="Vaelg konto"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={line.accountId}
|
||||
|
|
@ -562,7 +752,7 @@ export default function Kassekladde() {
|
|||
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<Button type="dashed" size="small" onClick={handleAddLine}>
|
||||
+ Tilføj linje
|
||||
+ Tilføj linje
|
||||
</Button>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export default function Kontooversigt() {
|
|||
if (selectedAccount) {
|
||||
// TODO: Backend does not yet have an updateAccount mutation.
|
||||
// For now, show a message indicating this is not yet supported.
|
||||
message.warning('Redigering af konti er endnu ikke understottet i backend');
|
||||
message.warning('Redigering af konti er endnu ikke understøttet i backend');
|
||||
} else {
|
||||
// Create new account
|
||||
await createAccountMutation.mutateAsync({
|
||||
|
|
@ -252,7 +252,7 @@ export default function Kontooversigt() {
|
|||
title="Kontooversigt"
|
||||
subtitle={activeCompany?.name}
|
||||
breadcrumbs={[
|
||||
{ title: 'Bogføring', path: '/bogforing' },
|
||||
{ title: 'Bogføring' },
|
||||
{ title: 'Kontooversigt' },
|
||||
]}
|
||||
extra={
|
||||
|
|
@ -316,7 +316,7 @@ export default function Kontooversigt() {
|
|||
|
||||
<Card
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
title={
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
Spin,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -65,6 +65,7 @@ import { spacing } from '@/styles/designTokens';
|
|||
import { accountingColors } from '@/styles/theme';
|
||||
import { AmountText } from '@/components/shared/AmountText';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -438,25 +439,16 @@ export default function Kreditnotaer() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<PageHeader
|
||||
title="Kreditnotaer"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Salg' }, { title: 'Kreditnotaer' }]}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
|
||||
Ny kreditnota
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
|
|
@ -549,12 +541,7 @@ export default function Kreditnotaer() {
|
|||
{/* Credit Note Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin
|
||||
tip="Indlæser kreditnotaer..."
|
||||
style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}
|
||||
>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : filteredCreditNotes.length > 0 ? (
|
||||
<Table
|
||||
dataSource={filteredCreditNotes}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Spin,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -394,9 +394,7 @@ export default function Kunder() {
|
|||
{/* Customer Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin tip="Indlæser kunder..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : filteredCustomers.length > 0 ? (
|
||||
<Table
|
||||
dataSource={filteredCustomers}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ import { useCompany } from '@/hooks/useCompany';
|
|||
import { formatCurrency } from '@/lib/formatters';
|
||||
import { accountingColors } from '@/styles/theme';
|
||||
import { DemoDataDisclaimer } from '@/components/shared';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import type { Employee, PayrollEntry } from '@/types/accounting';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// Mock data
|
||||
const mockEmployees: Employee[] = [
|
||||
|
|
@ -273,25 +274,14 @@ export default function Loenforstaelse() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Lønforståelse
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<PageHeader
|
||||
title="Lønforståelse"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Løn' }, { title: 'Lønforståelse' }]}
|
||||
extra={
|
||||
<Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DemoDataDisclaimer />
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Descriptions,
|
||||
Empty,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
|
|
@ -89,25 +90,25 @@ export default function Momsindberetning() {
|
|||
},
|
||||
{
|
||||
boxNumber: 2,
|
||||
nameDanish: 'Moms af varekob i udlandet (EU)',
|
||||
nameDanish: 'Moms af varekøb i udlandet (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,
|
||||
basis: vatReport.basis3,
|
||||
},
|
||||
{
|
||||
boxNumber: 3,
|
||||
nameDanish: 'Moms af ydelseskob i udlandet',
|
||||
nameDanish: 'Moms af ydelseskøb i udlandet',
|
||||
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,
|
||||
basis: vatReport.basis4,
|
||||
},
|
||||
{
|
||||
boxNumber: 4,
|
||||
nameDanish: 'Kobsmoms',
|
||||
nameDanish: 'Købsmoms',
|
||||
nameEnglish: 'Input VAT',
|
||||
description: 'Fradragsberettiget moms af kob',
|
||||
description: 'Fradragsberettiget moms af køb',
|
||||
amount: vatReport.boxB,
|
||||
basis: undefined, // Backend doesn't provide a specific basis for input VAT
|
||||
},
|
||||
|
|
@ -125,7 +126,7 @@ export default function Momsindberetning() {
|
|||
return [
|
||||
{ type: 'Salgsmoms', value: vatReport.boxA },
|
||||
{ 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);
|
||||
}, [vatReport, inputVAT]);
|
||||
|
||||
|
|
@ -223,7 +224,9 @@ export default function Momsindberetning() {
|
|||
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />}>Eksporter</Button>
|
||||
<Tooltip title="Eksport er endnu ikke implementeret">
|
||||
<Button icon={<DownloadOutlined />} disabled>Eksporter</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
|
|
@ -254,7 +257,7 @@ export default function Momsindberetning() {
|
|||
onChange={setPeriodType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Maanedlig' },
|
||||
{ value: 'monthly', label: 'Månedlig' },
|
||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -277,7 +280,7 @@ export default function Momsindberetning() {
|
|||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Fejl ved indlaesning af momsdata"
|
||||
message="Fejl ved indlæsning af momsdata"
|
||||
description={error.message}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
|
|
@ -376,7 +379,7 @@ export default function Momsindberetning() {
|
|||
<Card title="Tidligere indberetninger" size="small">
|
||||
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
|
||||
<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>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -392,9 +395,11 @@ export default function Momsindberetning() {
|
|||
<Button key="cancel" onClick={() => setIsPreviewOpen(false)}>
|
||||
Luk
|
||||
</Button>,
|
||||
<Button key="export" icon={<DownloadOutlined />}>
|
||||
Download PDF
|
||||
</Button>,
|
||||
<Tooltip title="PDF-download er endnu ikke implementeret" key="export-tooltip">
|
||||
<Button key="export" icon={<DownloadOutlined />} disabled>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
<Button
|
||||
key="skat-link"
|
||||
type="primary"
|
||||
|
|
@ -404,7 +409,7 @@ export default function Momsindberetning() {
|
|||
setIsPreviewOpen(false);
|
||||
}}
|
||||
>
|
||||
Ga til skat.dk
|
||||
Gå til skat.dk
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
|
|
@ -436,7 +441,7 @@ export default function Momsindberetning() {
|
|||
{ dataIndex: 'nameDanish', title: 'Felt' },
|
||||
{
|
||||
dataIndex: 'amount',
|
||||
title: 'Belob',
|
||||
title: 'Beløb',
|
||||
align: 'right',
|
||||
render: (v: number) => formatCurrency(v),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Spin,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
|
|
@ -495,9 +495,7 @@ export default function Ordrer() {
|
|||
{/* Order Table */}
|
||||
<Card size="small">
|
||||
{loading ? (
|
||||
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : filteredOrders.length > 0 ? (
|
||||
<Table
|
||||
dataSource={filteredOrders}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Divider,
|
||||
message,
|
||||
Space,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
|
|
@ -24,6 +23,8 @@ import {
|
|||
import { useCompany } from '@/hooks/useCompany';
|
||||
import { useUpdateCompany } from '@/api/mutations/companyMutations';
|
||||
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import BankConnectionsTab from '@/components/settings/BankConnectionsTab';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -293,33 +294,7 @@ export default function Settings() {
|
|||
</span>
|
||||
),
|
||||
children: (
|
||||
<Card>
|
||||
<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>
|
||||
<BankConnectionsTab companyId={company?.id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -360,13 +335,11 @@ export default function Settings() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Indstillinger
|
||||
</Title>
|
||||
<Text type="secondary">{company?.name}</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Indstillinger"
|
||||
subtitle={company?.name}
|
||||
breadcrumbs={[{ title: 'Indstillinger' }]}
|
||||
/>
|
||||
|
||||
<Tabs items={tabItems} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { spacing } from '@/styles/designTokens';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -55,8 +56,7 @@ export default function UserSettings() {
|
|||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
const values = await profileForm.validateFields();
|
||||
console.log('Saving profile:', values);
|
||||
await profileForm.validateFields();
|
||||
showSuccess('Profil opdateret');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
|
|
@ -71,7 +71,6 @@ export default function UserSettings() {
|
|||
showError('Adgangskoderne stemmer ikke overens');
|
||||
return;
|
||||
}
|
||||
console.log('Changing password');
|
||||
showSuccess('Adgangskode opdateret');
|
||||
passwordForm.resetFields();
|
||||
setIsChangingPassword(false);
|
||||
|
|
@ -83,8 +82,7 @@ export default function UserSettings() {
|
|||
|
||||
const handleSaveNotifications = async () => {
|
||||
try {
|
||||
const values = await notificationForm.validateFields();
|
||||
console.log('Saving notifications:', values);
|
||||
await notificationForm.validateFields();
|
||||
showSuccess('Notifikationsindstillinger gemt');
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
|
|
@ -124,8 +122,7 @@ export default function UserSettings() {
|
|||
}
|
||||
return false; // Prevent auto upload
|
||||
},
|
||||
onChange: (info) => {
|
||||
console.log('Upload:', info.file);
|
||||
onChange: () => {
|
||||
showSuccess('Profilbillede opdateret');
|
||||
},
|
||||
};
|
||||
|
|
@ -458,13 +455,11 @@ export default function UserSettings() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: spacing.lg }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Min profil
|
||||
</Title>
|
||||
<Text type="secondary">Administrer dine personlige indstillinger</Text>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Min profil"
|
||||
subtitle="Administrer dine personlige indstillinger"
|
||||
breadcrumbs={[{ title: 'Brugerindstillinger' }]}
|
||||
/>
|
||||
|
||||
<Tabs items={tabItems} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ export const usePeriodStore = create<PeriodState>()(
|
|||
return {
|
||||
allowed: false,
|
||||
reason: 'Period is locked',
|
||||
reasonDanish: 'Perioden er laast',
|
||||
reasonDanish: 'Perioden er låst',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ export const usePeriodStore = create<PeriodState>()(
|
|||
return {
|
||||
allowed: false,
|
||||
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,
|
||||
},
|
||||
sidebar: {
|
||||
width: 200,
|
||||
width: 220,
|
||||
collapsedWidth: 80,
|
||||
},
|
||||
modal: {
|
||||
|
|
|
|||
|
|
@ -4,29 +4,29 @@
|
|||
* Period frequency - how often accounting periods are defined
|
||||
*/
|
||||
export type PeriodFrequency =
|
||||
| 'monthly' // Maanedlig
|
||||
| 'monthly' // Månedlig
|
||||
| 'quarterly' // Kvartalsvis
|
||||
| 'half-yearly' // Halvaarlig
|
||||
| 'yearly'; // Aarlig
|
||||
| 'half-yearly' // Halvårlig
|
||||
| 'yearly'; // Årlig
|
||||
|
||||
/**
|
||||
* Period status according to Danish accounting requirements
|
||||
*/
|
||||
export type PeriodStatus =
|
||||
| 'future' // Fremtidig - not yet started
|
||||
| 'open' // Aaben - current working period
|
||||
| 'open' // Åben - current working period
|
||||
| '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)
|
||||
* Based on SKAT requirements
|
||||
*/
|
||||
export type VATPeriodicitet =
|
||||
| 'monthly' // Maanedlig (omsaetning > 50M DKK)
|
||||
| 'monthly' // Månedlig (omsætning > 50M DKK)
|
||||
| 'quarterly' // Kvartalsvis (default for most)
|
||||
| 'half-yearly' // Halvaarlig (omsaetning < 1M DKK, optional)
|
||||
| 'yearly'; // Aarlig (omsaetning < 300K DKK, optional)
|
||||
| 'half-yearly' // Halvårlig (omsætning < 1M DKK, optional)
|
||||
| 'yearly'; // Årlig (omsætning < 300K DKK, optional)
|
||||
|
||||
/**
|
||||
* Fiscal Year (Regnskabsaar)
|
||||
|
|
@ -230,10 +230,10 @@ export const DANISH_MONTHS_SHORT = [
|
|||
* Period frequency display names
|
||||
*/
|
||||
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' },
|
||||
'half-yearly': { danish: 'Halvaarslig', english: 'Half-yearly' },
|
||||
'yearly': { danish: 'Aarlig', english: 'Yearly' },
|
||||
'half-yearly': { danish: 'Halvårslig', english: 'Half-yearly' },
|
||||
'yearly': { danish: 'Årlig', english: 'Yearly' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -252,7 +252,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
|
|||
icon: 'clock-circle'
|
||||
},
|
||||
'open': {
|
||||
danish: 'Aaben',
|
||||
danish: 'Åben',
|
||||
english: 'Open',
|
||||
color: 'green',
|
||||
icon: 'check-circle'
|
||||
|
|
@ -264,7 +264,7 @@ export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
|
|||
icon: 'minus-circle'
|
||||
},
|
||||
'locked': {
|
||||
danish: 'Laast',
|
||||
danish: 'Låst',
|
||||
english: 'Locked',
|
||||
color: 'red',
|
||||
icon: 'lock'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue