books/frontend/src/pages/Kassekladde.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

795 lines
26 KiB
TypeScript

import { useState, useMemo, useEffect } from 'react';
import {
Typography,
Button,
Space,
DatePicker,
Select,
Modal,
Form,
Input,
InputNumber,
message,
Tag,
Tooltip,
Dropdown,
Skeleton,
Empty,
Descriptions,
Table,
Drawer,
} from 'antd';
import {
PlusOutlined,
FilterOutlined,
EyeOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import dayjs from 'dayjs';
import DataTable, { DataTableColumn } from '@/components/tables/DataTable';
import { useCompanyStore } from '@/stores/companyStore';
import { useActiveAccounts } from '@/api/queries/accountQueries';
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
import { formatCurrency } from '@/lib/formatters';
import { PageHeader } from '@/components/shared/PageHeader';
import { validateDoubleEntry } from '@/lib/accounting';
import type { TransactionLine, JournalEntryDraft, JournalEntryDraftStatus } from '@/types/accounting';
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
import { usePeriodStore } from '@/stores/periodStore';
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Display type for journal entry drafts
interface DraftDisplay {
id: string;
transactionNumber: string;
date: string;
description: string;
totalDebit: number;
totalCredit: number;
isReconciled: boolean;
isVoided: boolean;
status: JournalEntryDraftStatus;
lines: JournalEntryDraft['lines'];
postedAt?: string;
postedBy?: string;
}
export default function Kassekladde() {
const { activeCompany } = useCompanyStore();
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 },
{ debit: 0, credit: 0 },
]);
const { currentFiscalYear } = usePeriodStore();
// Mutation hooks
const createDraftMutation = useCreateJournalEntryDraft();
const updateDraftMutation = useUpdateJournalEntryDraft();
const discardDraftMutation = useDiscardJournalEntryDraft();
// Fetch accounts and drafts from API
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id);
const isLoading = accountsLoading || draftsLoading;
// Convert drafts to display format
const displayData: DraftDisplay[] = useMemo(() => drafts.map(draft => ({
id: draft.id,
transactionNumber: draft.voucherNumber || draft.name,
date: draft.documentDate || draft.createdAt,
description: draft.description || draft.name,
lines: draft.lines || [],
totalDebit: draft.lines?.reduce((sum, l) => sum + (l.debitAmount || 0), 0) ?? 0,
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>[] = [
{
dataIndex: 'transactionNumber',
title: 'Bilagsnr.',
width: 120,
sortable: true,
render: (value) => <Text strong>#{value as string}</Text>,
},
{
dataIndex: 'date',
title: 'Dato',
width: 100,
sortable: true,
columnType: 'date',
},
{
dataIndex: 'description',
title: 'Beskrivelse',
ellipsis: true,
},
{
dataIndex: 'totalDebit',
title: 'Debet',
width: 120,
sortable: true,
columnType: 'currency',
},
{
dataIndex: 'totalCredit',
title: 'Kredit',
width: 120,
sortable: true,
columnType: 'currency',
},
{
dataIndex: 'isReconciled',
title: 'Status',
width: 100,
render: (value, record) => {
if (record.isVoided) {
return <Tag color="red">Annulleret</Tag>;
}
return value ? (
<Tag color="green">Bogført</Tag>
) : (
<Tag color="orange">Kladde</Tag>
);
},
},
{
dataIndex: 'id',
title: '',
width: 50,
render: (_, record) => {
const menuItems: MenuProps['items'] = [
{
key: 'view',
icon: <EyeOutlined />,
label: 'Vis detaljer',
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Rediger',
disabled: record.isReconciled || record.isVoided,
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Kopier',
},
{
type: 'divider',
},
{
key: 'void',
icon: <DeleteOutlined />,
label: 'Annuller',
danger: true,
disabled: record.isVoided,
},
];
return (
<Dropdown
menu={{
items: menuItems,
onClick: ({ key }) => handleAction(key, record),
}}
trigger={['click']}
>
<Button type="text" icon={<MoreOutlined />} size="small" />
</Dropdown>
);
},
},
];
const handleAction = (action: string, record: DraftDisplay) => {
switch (action) {
case 'view':
setDetailDraft(record);
break;
case 'edit':
setEditingDraft(record);
setIsModalOpen(true);
break;
case 'copy':
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
break;
}
(async () => {
try {
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: `Kopi af ${record.description}`,
description: record.description,
fiscalYearId: currentFiscalYear?.id,
});
// Copy lines to the new draft
if (record.lines && record.lines.length > 0) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: record.lines.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId,
debitAmount: l.debitAmount || 0,
creditAmount: l.creditAmount || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success(`Bilag ${record.transactionNumber} kopieret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved kopiering: ${error.message}`);
}
}
})();
break;
case 'void':
Modal.confirm({
title: 'Annuller bilag',
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
okText: 'Annuller bilag',
okType: 'danger',
cancelText: 'Fortryd',
onOk: async () => {
try {
await discardDraftMutation.mutateAsync(record.id);
message.success(`Bilag ${record.transactionNumber} annulleret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved annullering: ${error.message}`);
}
}
},
});
break;
}
};
const handleAddLine = () => {
setLines([...lines, { debit: 0, credit: 0 }]);
};
const handleRemoveLine = (index: number) => {
if (lines.length > 2) {
setLines(lines.filter((_, i) => i !== index));
}
};
const handleLineChange = (index: number, field: string, value: unknown) => {
const newLines = [...lines];
newLines[index] = { ...newLines[index], [field]: value };
// Auto-balance: if debit is entered, clear credit and vice versa
if (field === 'debit' && value) {
newLines[index].credit = 0;
} else if (field === 'credit' && value) {
newLines[index].debit = 0;
}
setLines(newLines);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Validate double-entry
const validation = validateDoubleEntry(lines as TransactionLine[]);
if (!validation.valid) {
message.error(
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
);
return;
}
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
return;
}
if (editingDraft) {
// Update existing draft
await updateDraftMutation.mutateAsync({
id: editingDraft.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
message.success('Bilag opdateret');
} else {
// Create new draft
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
});
// Update the draft with lines
if (lines.some(l => l.accountId)) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success('Bilag oprettet');
}
setIsModalOpen(false);
setEditingDraft(null);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
// 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: 'Bogført', 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) {
return (
<div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
/>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
return (
<div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', 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);
}}
>
Nyt bilag
</Button>
}
/>
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
<RangePicker
placeholder={['Fra dato', 'Til dato']}
value={dateFilter}
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
format="DD-MM-YYYY"
/>
{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: 'Bogført' },
{ 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 */}
{filteredData.length === 0 ? (
<Empty description="Ingen bilag fundet. Opret et nyt bilag for at komme i gang." />
) : (
<DataTable<DraftDisplay>
data={filteredData}
columns={columns}
exportFilename="kassekladde"
rowSelection="multiple"
onRowClick={(record) => handleAction('view', record)}
rowClassName={(record) =>
record.isVoided ? 'voided-row' : ''
}
/>
)}
{/* 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="Bogført">
{dayjs(detailDraft.postedAt).format('DD-MM-YYYY HH:mm')}
</Descriptions.Item>
)}
{detailDraft.postedBy && (
<Descriptions.Item label="Bogført 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 }]);
}}
onOk={handleSubmit}
okText="Gem"
cancelText="Annuller"
width={800}
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }} direction="vertical" size="middle">
<Space style={{ width: '100%' }}>
<Form.Item
name="date"
label="Dato"
rules={[{ required: true, message: 'Vælg dato' }]}
initialValue={dayjs()}
>
<DatePicker format="DD-MM-YYYY" style={{ width: 150 }} />
</Form.Item>
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true, message: 'Indtast beskrivelse' }]}
style={{ flex: 1 }}
>
<Input placeholder="F.eks. Faktura #1234 til kunde" />
</Form.Item>
</Space>
{/* Transaction Lines */}
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Posteringslinjer
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<th style={{ textAlign: 'left', padding: 8, width: '40%' }}>Konto</th>
<th style={{ textAlign: 'right', padding: 8, width: '20%' }}>Debet</th>
<th style={{ textAlign: 'right', padding: 8, width: '20%' }}>Kredit</th>
<th style={{ textAlign: 'left', padding: 8, width: '15%' }}>Tekst</th>
<th style={{ width: '5%' }}></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index}>
<td style={{ padding: 4 }}>
<Select
style={{ width: '100%' }}
placeholder="Vælg konto"
showSearch
optionFilterProp="label"
value={line.accountId}
onChange={(value) => handleLineChange(index, 'accountId', value)}
options={accounts.map((acc) => ({
value: acc.id,
label: `${acc.accountNumber} - ${acc.name}`,
}))}
/>
</td>
<td style={{ padding: 4 }}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
value={line.debit}
onChange={(value) => handleLineChange(index, 'debit', value || 0)}
formatter={(value) =>
`${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
parser={(value) =>
value?.replace(/\./g, '').replace(',', '.') as unknown as number
}
/>
</td>
<td style={{ padding: 4 }}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
value={line.credit}
onChange={(value) => handleLineChange(index, 'credit', value || 0)}
formatter={(value) =>
`${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
parser={(value) =>
value?.replace(/\./g, '').replace(',', '.') as unknown as number
}
/>
</td>
<td style={{ padding: 4 }}>
<Input
placeholder="Valgfri"
value={line.description}
onChange={(e) =>
handleLineChange(index, 'description', e.target.value)
}
/>
</td>
<td style={{ padding: 4 }}>
{lines.length > 2 && (
<Button
type="text"
danger
size="small"
onClick={() => handleRemoveLine(index)}
>
x
</Button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
<td style={{ padding: 8 }}>
<Button type="dashed" size="small" onClick={handleAddLine}>
+ Tilføj linje
</Button>
</td>
<td
style={{
padding: 8,
textAlign: 'right',
fontWeight: 'bold',
}}
>
{formatCurrency(balance.totalDebit)}
</td>
<td
style={{
padding: 8,
textAlign: 'right',
fontWeight: 'bold',
}}
>
{formatCurrency(balance.totalCredit)}
</td>
<td colSpan={2} style={{ padding: 8 }}>
{!balance.valid && (
<Tooltip title={`Differens: ${formatCurrency(balance.difference)}`}>
<Tag color="red">Ubalance!</Tag>
</Tooltip>
)}
{balance.valid && balance.totalDebit > 0 && (
<Tag color="green">Balancerer</Tag>
)}
</td>
</tr>
</tfoot>
</table>
</div>
</Space>
</Form>
</Modal>
</div>
);
}