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>
580 lines
17 KiB
TypeScript
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 på <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 på <strong>{formatCurrency(summary.netResult)}</strong> vil blive
|
|
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} på 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>
|
|
);
|
|
}
|