books/frontend/src/components/modals/CloseFiscalYearWizard.tsx
Nicolaj Hartmann 1a0922b778 Audit v3: VAT alignment, security, encoding, UX, compliance
VAT System Alignment (LEGAL - Critical):
- Align frontend VAT codes with backend (S25→U25, K25→I25, etc.)
- Add missing codes: UEU, IVV, IVY, REP
- Fix output VAT account 5710→5611 to match StandardDanishAccounts
- Invoice posting now checks fiscal year status before allowing send
- Disallow custom invoice number override (always use auto-numbering)

Security:
- Fix open redirect in AuthController (validate returnUrl is local)
- Store seller CVR/name/address on invoice events (Momsloven §52)

Backend Compliance:
- Add description validation at posting (Bogføringsloven §7)
- SAF-T: add DefaultCurrencyCode, TaxAccountingBasis to header
- SAF-T: add TaxTable to MasterFiles with all VAT codes
- SAF-T: always write balance elements even when zero
- Add financial income account 9100 Renteindtægter

Danish Encoding (~25 fixes):
- Kassekladde: Bogført, Bogføring, Vælg, være, på, Tilføj, Differens
- AttachmentUpload: træk, Understøtter, påkrævet, Bogføringsloven
- keyboardShortcuts: Bogfør, Bogføring display name
- ShortcutsHelpModal: åbne
- DataTable: Genindlæs
- documentProcessing: være
- CloseFiscalYearWizard: årsafslutning

Bugs Fixed:
- Non-null assertion crashes in Kunder.tsx and Produkter.tsx (company!.id)
- StatusBadge typo "Succces"→"Succes"
- HTML entity ø in Kassekladde→proper UTF-8
- AmountText showSign prop was dead code (true || showSign)

UX Improvements:
- Add PageHeader to Bankafstemning and Dashboard loading/empty states
- Responsive columns in Bankafstemning (xs/sm/lg breakpoints)
- Disable misleading buttons: Settings preferences, Kontooversigt edit,
  Loenforstaelse export — with tooltips explaining status
- Add DemoDataDisclaimer to UserSettings
- Fix breadcrumb self-references on 3 pages
- Replace Dashboard fake progress bar with honest message
- Standardize date format DD-MM-YYYY in Bankafstemning and Ordrer
- Replace Input type="number" with InputNumber in Ordrer

Quality:
- Remove 8 redundant console.error statements
- Fix Kreditnotaer breadcrumb "Salg"→"Fakturering" for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:15:45 +01:00

580 lines
17 KiB
TypeScript

// CloseFiscalYearWizard - Multi-step wizard for year-end closing (årsafslutning)
import { useState, useEffect, useMemo } from 'react';
import {
Modal,
Steps,
Button,
Alert,
Typography,
Table,
Statistic,
Row,
Col,
Card,
Select,
Divider,
Checkbox,
Result,
Tag,
Form,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
LockOutlined,
ArrowRightOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { usePeriodStore } from '@/stores/periodStore';
import {
calculateClosingSummary,
validateFiscalYearClose,
generateClosingEntries,
calculateClosingBalances,
type FiscalYearClosingSummary,
type GeneratedClosingEntry,
} from '@/lib/fiscalYear';
import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
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;
interface CloseFiscalYearWizardProps {
open: boolean;
fiscalYear: FiscalYear;
accounts: Account[];
transactions: Transaction[];
onClose: () => void;
onSuccess?: () => void;
}
type WizardStep = 'validation' | 'summary' | 'transfer' | 'confirm' | 'complete';
// Default "Overført resultat" account (equity)
const DEFAULT_RESULT_ACCOUNT = {
id: 'acc-3900',
accountNumber: '3900',
name: 'Overført resultat',
};
export default function CloseFiscalYearWizard({
open,
fiscalYear,
accounts,
transactions,
onClose,
onSuccess,
}: CloseFiscalYearWizardProps) {
const [currentStep, setCurrentStep] = useState<WizardStep>('validation');
const [isSubmitting, setIsSubmitting] = useState(false);
const [resultAccountId, setResultAccountId] = useState<string>(DEFAULT_RESULT_ACCOUNT.id);
const [createNextYear, setCreateNextYear] = useState(true);
const [closeOpenPeriods, setCloseOpenPeriods] = useState(true);
const [confirmLock, setConfirmLock] = useState(false);
const {
periods,
closeFiscalYear,
lockFiscalYear,
closePeriod,
lockPeriod,
} = usePeriodStore();
const closeFiscalYearMutation = useCloseFiscalYear();
const createFiscalYearMutation = useCreateFiscalYear();
const createDraftMutation = useCreateJournalEntryDraft();
const updateDraftMutation = useUpdateJournalEntryDraft();
// Reset wizard when opened
useEffect(() => {
if (open) {
setCurrentStep('validation');
setIsSubmitting(false);
setConfirmLock(false);
}
}, [open]);
// Calculate closing balances
const closingBalances = useMemo(
() => calculateClosingBalances(fiscalYear, accounts, transactions),
[fiscalYear, accounts, transactions]
);
// Calculate summary
const summary: FiscalYearClosingSummary = useMemo(
() => calculateClosingSummary(fiscalYear, periods, closingBalances, transactions),
[fiscalYear, periods, closingBalances, transactions]
);
// Validate fiscal year close
const validation = useMemo(
() => validateFiscalYearClose(fiscalYear, periods),
[fiscalYear, periods]
);
// Generate closing entries preview
const closingEntries: GeneratedClosingEntry[] = useMemo(() => {
const resultAccount = accounts.find((a) => a.id === resultAccountId) || {
id: resultAccountId,
accountNumber: DEFAULT_RESULT_ACCOUNT.accountNumber,
name: DEFAULT_RESULT_ACCOUNT.name,
};
return generateClosingEntries(
fiscalYear,
closingBalances,
resultAccount.id,
resultAccount.accountNumber,
resultAccount.name
);
}, [fiscalYear, closingBalances, resultAccountId, accounts]);
// Equity accounts for result transfer
const equityAccounts = useMemo(
() => accounts.filter((a) => a.type === 'equity'),
[accounts]
);
// Year periods
const yearPeriods = useMemo(
() => periods.filter((p) => p.fiscalYearId === fiscalYear.id),
[periods, fiscalYear.id]
);
const openPeriodsInYear = yearPeriods.filter((p) => p.status === 'open');
const handleNext = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex < steps.length - 1) {
setCurrentStep(steps[currentIndex + 1]);
}
};
const handlePrevious = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex > 0) {
setCurrentStep(steps[currentIndex - 1]);
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
// 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,
});
// 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');
}
}
// 3. Lock all periods in the year (local store)
for (const period of yearPeriods) {
lockPeriod(period.id, 'system');
}
// 4. Call backend mutation to close the fiscal year
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
// 5. Also update local store
closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system');
// 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?.();
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved årsafslutning: ${error.message}`);
}
console.error('Failed to close fiscal year:', error);
} finally {
setIsSubmitting(false);
}
};
const getStepNumber = (step: WizardStep): number => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
return steps.indexOf(step);
};
const renderValidationStep = () => (
<div>
<Title level={5}>Validering af regnskabsår {fiscalYear.name}</Title>
<Paragraph type="secondary">
Før årsafslutning kontrolleres regnskabsåret for eventuelle problemer.
</Paragraph>
{validation.errors.length > 0 && (
<Alert
type="error"
message="Fejl fundet"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.errors.map((err, idx) => (
<li key={idx}>{err.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{validation.warnings.length > 0 && (
<Alert
type="warning"
icon={<WarningOutlined />}
message="Advarsler"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.warnings.map((warn, idx) => (
<li key={idx}>{warn.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{summary.unreconciledCount > 0 && (
<Alert
type="info"
icon={<ExclamationCircleOutlined />}
message={`${summary.unreconciledCount} transaktioner er ikke afstemt`}
description="Det anbefales at afstemme alle transaktioner før årsafslutning."
style={{ marginBottom: 16 }}
/>
)}
{openPeriodsInYear.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={closeOpenPeriods}
onChange={(e) => setCloseOpenPeriods(e.target.checked)}
>
<Text>
Luk automatisk {openPeriodsInYear.length} åbne periode(r) ved årsafslutning
</Text>
</Checkbox>
</Card>
)}
{validation.isValid && validation.warnings.length === 0 && (
<Alert
type="success"
icon={<CheckCircleOutlined />}
message="Regnskabsåret er klar til afslutning"
description="Ingen fejl eller advarsler fundet."
/>
)}
</div>
);
const renderSummaryStep = () => (
<div>
<Title level={5}>Resultatoversigt for {fiscalYear.name}</Title>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card>
<Statistic
title="Samlet indtægt"
value={summary.totalRevenue}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Samlet udgift"
value={summary.totalExpenses}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Årsresultat"
value={summary.netResult}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: summary.netResult >= 0 ? '#3f8600' : '#cf1322' }}
prefix={summary.netResult >= 0 ? '+' : ''}
/>
</Card>
</Col>
</Row>
<Divider />
<Text type="secondary">
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
overført til egenkapitalen ved årsafslutning.
</Text>
</div>
);
const renderTransferStep = () => (
<div>
<Title level={5}>Resultatoverførsel</Title>
<Paragraph type="secondary">
Vælg hvilken egenkapitalkonto årsresultatet skal overføres til.
</Paragraph>
<Form.Item label="Egenkapitalkonto for resultatoverførsel">
<Select
value={resultAccountId}
onChange={setResultAccountId}
style={{ width: '100%' }}
options={[
...equityAccounts.map((account) => ({
value: account.id,
label: `${account.accountNumber} - ${account.name}`,
})),
{
value: DEFAULT_RESULT_ACCOUNT.id,
label: `${DEFAULT_RESULT_ACCOUNT.accountNumber} - ${DEFAULT_RESULT_ACCOUNT.name} (Standard)`,
},
]}
/>
</Form.Item>
<Alert
type="info"
message="Resultatoverførsel"
description={
<Text>
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} den valgte konto.
</Text>
}
/>
</div>
);
const renderConfirmStep = () => (
<div>
<Title level={5}>Bekræft årsafslutning</Title>
<Alert
type="warning"
icon={<LockOutlined />}
message="Permanent handling"
description="Når regnskabsåret er låst, kan det ikke genåbnes. Alle perioder vil også blive låst."
style={{ marginBottom: 16 }}
/>
<Card title="Lukkeposter der oprettes" size="small" style={{ marginBottom: 16 }}>
<Table
dataSource={closingEntries.map((entry, idx) => ({ ...entry, key: idx }))}
columns={[
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag>
{type === 'revenue-close' ? 'Luk indtægter' : 'Luk udgifter'}
</Tag>
),
},
{
title: 'Beskrivelse',
dataIndex: 'descriptionDanish',
key: 'description',
},
{
title: 'Beløb',
dataIndex: 'totalAmount',
key: 'amount',
align: 'right',
render: (amount: number) => formatCurrency(amount),
},
]}
pagination={false}
size="small"
/>
</Card>
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={createNextYear}
onChange={(e) => setCreateNextYear(e.target.checked)}
>
<Text>Opret automatisk næste regnskabsår</Text>
</Checkbox>
</Card>
<Alert
type="error"
icon={<ExclamationCircleOutlined />}
message="Bekræft låsning"
description={
<Checkbox
checked={confirmLock}
onChange={(e) => setConfirmLock(e.target.checked)}
style={{ marginTop: 8 }}
>
<Text strong>
Jeg bekræfter at jeg vil låse regnskabsår {fiscalYear.name} permanent.
Dette kan ikke fortrydes.
</Text>
</Checkbox>
}
/>
</div>
);
const renderCompleteStep = () => (
<Result
status="success"
icon={<CheckCircleOutlined />}
title="Årsafslutning gennemført"
subTitle={`Regnskabsår ${fiscalYear.name} er nu lukket og låst.`}
extra={[
<Button type="primary" key="close" onClick={onClose}>
Luk
</Button>,
]}
/>
);
const renderStepContent = () => {
switch (currentStep) {
case 'validation':
return renderValidationStep();
case 'summary':
return renderSummaryStep();
case 'transfer':
return renderTransferStep();
case 'confirm':
return renderConfirmStep();
case 'complete':
return renderCompleteStep();
default:
return null;
}
};
const canProceed = currentStep === 'validation' ? validation.isValid : true;
const isLastStep = currentStep === 'confirm';
const isComplete = currentStep === 'complete';
return (
<Modal
title={`Årsafslutning - ${fiscalYear.name}`}
open={open}
onCancel={onClose}
width={700}
footer={
isComplete
? null
: [
<Button key="cancel" onClick={onClose}>
Annuller
</Button>,
currentStep !== 'validation' && (
<Button key="prev" onClick={handlePrevious}>
Tilbage
</Button>
),
isLastStep ? (
<Button
key="submit"
type="primary"
danger
icon={<LockOutlined />}
loading={isSubmitting}
disabled={!confirmLock}
onClick={handleSubmit}
>
Gennemfør årsafslutning
</Button>
) : (
<Button
key="next"
type="primary"
onClick={handleNext}
disabled={!canProceed}
icon={<ArrowRightOutlined />}
>
Næste
</Button>
),
].filter(Boolean)
}
destroyOnClose
>
<Steps
current={getStepNumber(currentStep)}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: 'Validering' },
{ title: 'Oversigt' },
{ title: 'Overførsel' },
{ title: 'Bekræft' },
{ title: 'Færdig' },
]}
/>
{renderStepContent()}
</Modal>
);
}