Backend (.NET 10): - EventFlow CQRS/Event Sourcing with PostgreSQL - GraphQL.NET API with mutations and queries - Custom ReadModelSqlGenerator for snake_case PostgreSQL columns - Hangfire for background job processing - Integration tests with isolated test databases Frontend (React/Vite): - Initial project structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
// SplitBookModal - Modal for splitting one bank transaction to multiple accounts
|
|
|
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
import {
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Card,
|
|
Table,
|
|
Space,
|
|
Typography,
|
|
Alert,
|
|
Divider,
|
|
Tag,
|
|
Button,
|
|
Row,
|
|
Col,
|
|
} from 'antd';
|
|
import {
|
|
CheckCircleOutlined,
|
|
WarningOutlined,
|
|
PlusOutlined,
|
|
DeleteOutlined,
|
|
} from '@ant-design/icons';
|
|
import { formatCurrency, formatDateShort } from '@/lib/formatters';
|
|
import { accountingColors } from '@/styles/theme';
|
|
import { generateSplitDoubleEntry, type SplitBookingInput, type SplitBookingLine } from '@/lib/accounting';
|
|
import type { VATCode } from '@/types/vat';
|
|
import type { Account } from '@/types/accounting';
|
|
import { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
|
|
import {
|
|
useBookingPreview,
|
|
useIsBookingSaving,
|
|
useSimpleBookingStore,
|
|
} from '@/stores/simpleBookingStore';
|
|
|
|
const { Text } = Typography;
|
|
|
|
interface SplitBookModalProps {
|
|
accounts: Account[];
|
|
onSubmit: (transaction: ReturnType<typeof generateSplitDoubleEntry>) => Promise<void>;
|
|
}
|
|
|
|
export function SplitBookModal({ accounts, onSubmit }: SplitBookModalProps) {
|
|
const {
|
|
modal,
|
|
closeModal,
|
|
setPreview,
|
|
splitState,
|
|
addSplitLine,
|
|
removeSplitLine,
|
|
clearSplitLines,
|
|
} = useSimpleBookingStore();
|
|
const preview = useBookingPreview();
|
|
const isSaving = useIsBookingSaving();
|
|
|
|
// New line form state
|
|
const [newLineAccountId, setNewLineAccountId] = useState<string | undefined>();
|
|
const [newLineAmount, setNewLineAmount] = useState<number | null>(null);
|
|
const [newLineVATCode, setNewLineVATCode] = useState<VATCode>('K25');
|
|
const [newLineDescription, setNewLineDescription] = useState('');
|
|
|
|
const bankTransaction = modal.bankTransaction;
|
|
const isOpen = modal.isOpen && modal.type === 'split';
|
|
const isExpense = bankTransaction ? bankTransaction.amount < 0 : true;
|
|
|
|
// Track previous open state to detect modal opening
|
|
const wasOpenRef = useRef(false);
|
|
const lastTransactionIdRef = useRef<string | null>(null);
|
|
|
|
// Reset form only when modal opens or transaction changes
|
|
useEffect(() => {
|
|
const isNewlyOpened = isOpen && !wasOpenRef.current;
|
|
const isNewTransaction = bankTransaction && bankTransaction.id !== lastTransactionIdRef.current;
|
|
|
|
if (isOpen && bankTransaction && (isNewlyOpened || isNewTransaction)) {
|
|
// Calculate isExpense inside effect to avoid dependency
|
|
const expense = bankTransaction.amount < 0;
|
|
clearSplitLines();
|
|
setNewLineAccountId(undefined);
|
|
setNewLineAmount(null);
|
|
setNewLineVATCode(expense ? 'K25' : 'S25');
|
|
setNewLineDescription('');
|
|
setPreview(null);
|
|
lastTransactionIdRef.current = bankTransaction.id;
|
|
}
|
|
|
|
wasOpenRef.current = isOpen;
|
|
}, [isOpen, bankTransaction, clearSplitLines, setPreview]);
|
|
|
|
// Generate preview when lines change
|
|
useEffect(() => {
|
|
if (!bankTransaction || splitState.lines.length === 0) {
|
|
setPreview(null);
|
|
return;
|
|
}
|
|
|
|
const input: SplitBookingInput = {
|
|
bankTransaction: {
|
|
id: bankTransaction.id,
|
|
date: bankTransaction.date,
|
|
amount: bankTransaction.amount,
|
|
description: bankTransaction.description,
|
|
counterparty: bankTransaction.counterparty,
|
|
bankAccountId: bankTransaction.bankAccountId,
|
|
bankAccountNumber: bankTransaction.bankAccountNumber,
|
|
},
|
|
lines: splitState.lines,
|
|
};
|
|
|
|
const result = generateSplitDoubleEntry(input);
|
|
setPreview(result);
|
|
}, [bankTransaction, splitState.lines, setPreview]);
|
|
|
|
// Account options for picker
|
|
const accountOptions = useMemo(
|
|
() =>
|
|
accounts.map((a) => ({
|
|
id: a.id,
|
|
accountNumber: a.accountNumber,
|
|
name: a.name,
|
|
type: a.type,
|
|
})),
|
|
[accounts]
|
|
);
|
|
|
|
// Add a new split line
|
|
const handleAddLine = () => {
|
|
if (!newLineAccountId || !newLineAmount) return;
|
|
|
|
const account = accounts.find((a) => a.id === newLineAccountId);
|
|
if (!account) return;
|
|
|
|
const newLine: SplitBookingLine = {
|
|
accountId: account.id,
|
|
accountNumber: account.accountNumber,
|
|
accountName: account.name,
|
|
amount: newLineAmount,
|
|
vatCode: newLineVATCode,
|
|
description: newLineDescription || undefined,
|
|
};
|
|
|
|
addSplitLine(newLine);
|
|
|
|
// Reset form for next line
|
|
setNewLineAccountId(undefined);
|
|
setNewLineAmount(null);
|
|
setNewLineDescription('');
|
|
};
|
|
|
|
// Fill remaining amount
|
|
const handleFillRemaining = () => {
|
|
setNewLineAmount(splitState.remainingAmount);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!preview || !preview.isValid) return;
|
|
await onSubmit(preview);
|
|
closeModal();
|
|
};
|
|
|
|
// Preview table columns
|
|
const previewColumns = [
|
|
{
|
|
title: 'Konto',
|
|
key: 'account',
|
|
render: (_: unknown, record: { accountNumber: string; accountName: string }) => (
|
|
<span>
|
|
<Text strong>{record.accountNumber}</Text>
|
|
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
title: 'Debet',
|
|
dataIndex: 'debit',
|
|
key: 'debit',
|
|
align: 'right' as const,
|
|
render: (value: number) =>
|
|
value > 0 ? (
|
|
<Text style={{ color: accountingColors.debit }}>{formatCurrency(value)}</Text>
|
|
) : null,
|
|
},
|
|
{
|
|
title: 'Kredit',
|
|
dataIndex: 'credit',
|
|
key: 'credit',
|
|
align: 'right' as const,
|
|
render: (value: number) =>
|
|
value > 0 ? (
|
|
<Text style={{ color: accountingColors.credit }}>{formatCurrency(value)}</Text>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
// Split lines table columns
|
|
const splitLinesColumns = [
|
|
{
|
|
title: '#',
|
|
key: 'index',
|
|
width: 40,
|
|
render: (_: unknown, __: unknown, index: number) => index + 1,
|
|
},
|
|
{
|
|
title: 'Konto',
|
|
key: 'account',
|
|
render: (_: unknown, record: SplitBookingLine) => (
|
|
<span>
|
|
<Text strong>{record.accountNumber}</Text>
|
|
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
title: 'Beloeb',
|
|
dataIndex: 'amount',
|
|
key: 'amount',
|
|
align: 'right' as const,
|
|
render: (value: number) => formatCurrency(value),
|
|
},
|
|
{
|
|
title: 'Moms',
|
|
dataIndex: 'vatCode',
|
|
key: 'vatCode',
|
|
render: (code: VATCode) => <Tag>{code}</Tag>,
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
width: 50,
|
|
render: (_: unknown, __: unknown, index: number) => (
|
|
<Button
|
|
type="text"
|
|
danger
|
|
size="small"
|
|
icon={<DeleteOutlined />}
|
|
onClick={() => removeSplitLine(index)}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (!bankTransaction) return null;
|
|
|
|
const canAddLine =
|
|
newLineAccountId &&
|
|
newLineAmount &&
|
|
newLineAmount > 0 &&
|
|
newLineAmount <= splitState.remainingAmount + 0.01;
|
|
|
|
const canSubmit =
|
|
preview?.isValid && splitState.lines.length > 0 && splitState.remainingAmount < 0.01;
|
|
|
|
return (
|
|
<Modal
|
|
title="Opdel transaktion"
|
|
open={isOpen}
|
|
onCancel={closeModal}
|
|
onOk={handleSubmit}
|
|
okText="Bogfoer opdeling"
|
|
cancelText="Annuller"
|
|
okButtonProps={{
|
|
disabled: !canSubmit || isSaving,
|
|
loading: isSaving,
|
|
}}
|
|
width={800}
|
|
destroyOnClose
|
|
>
|
|
{/* Bank transaction summary */}
|
|
<Card
|
|
size="small"
|
|
style={{
|
|
marginBottom: 16,
|
|
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
|
|
}}
|
|
>
|
|
<Row justify="space-between" align="middle">
|
|
<Col>
|
|
<Text>Banktransaktion:</Text>
|
|
<Text
|
|
strong
|
|
style={{
|
|
fontSize: 18,
|
|
marginLeft: 8,
|
|
color: isExpense ? accountingColors.debit : accountingColors.credit,
|
|
}}
|
|
>
|
|
{formatCurrency(bankTransaction.amount)}
|
|
</Text>
|
|
<Text style={{ marginLeft: 16 }}>{bankTransaction.description}</Text>
|
|
</Col>
|
|
<Col>
|
|
<Text type="secondary">{formatDateShort(bankTransaction.date)}</Text>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* Remaining amount indicator */}
|
|
<Alert
|
|
type={splitState.remainingAmount < 0.01 ? 'success' : 'warning'}
|
|
message={
|
|
<span>
|
|
Restbeloeb:{' '}
|
|
<Text strong style={{ color: splitState.remainingAmount < 0.01 ? accountingColors.credit : accountingColors.warning }}>
|
|
{formatCurrency(splitState.remainingAmount)}
|
|
</Text>
|
|
{splitState.remainingAmount < 0.01 && ' - Fuld fordeling'}
|
|
</span>
|
|
}
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
{/* Existing split lines */}
|
|
{splitState.lines.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
|
Fordeling:
|
|
</Text>
|
|
<Table
|
|
dataSource={splitState.lines.map((line, idx) => ({ ...line, key: idx }))}
|
|
columns={splitLinesColumns}
|
|
pagination={false}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add new line form */}
|
|
{splitState.remainingAmount > 0.01 && (
|
|
<Card size="small" title="Tilfoej linje" style={{ marginBottom: 16 }}>
|
|
<Row gutter={16}>
|
|
<Col span={10}>
|
|
<Form.Item label="Konto" style={{ marginBottom: 8 }}>
|
|
<AccountQuickPicker
|
|
accounts={accountOptions}
|
|
value={newLineAccountId}
|
|
onChange={setNewLineAccountId}
|
|
isExpense={isExpense}
|
|
placeholder="Vaelg konto..."
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Form.Item label="Beloeb" style={{ marginBottom: 8 }}>
|
|
<Space.Compact style={{ width: '100%' }}>
|
|
<InputNumber
|
|
style={{ width: '100%' }}
|
|
value={newLineAmount}
|
|
onChange={(value) => setNewLineAmount(value)}
|
|
min={0.01}
|
|
max={splitState.remainingAmount}
|
|
precision={2}
|
|
placeholder="0,00"
|
|
addonAfter="kr"
|
|
/>
|
|
<Button onClick={handleFillRemaining} title="Udfyld resten">
|
|
Rest
|
|
</Button>
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={5}>
|
|
<Form.Item label="Moms" style={{ marginBottom: 8 }}>
|
|
<VATCodePicker
|
|
value={newLineVATCode}
|
|
onChange={setNewLineVATCode}
|
|
isExpense={isExpense}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={3}>
|
|
<Form.Item label=" " style={{ marginBottom: 8 }}>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleAddLine}
|
|
disabled={!canAddLine}
|
|
>
|
|
Tilfoej
|
|
</Button>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row>
|
|
<Col span={24}>
|
|
<Form.Item label="Beskrivelse (valgfri)" style={{ marginBottom: 0 }}>
|
|
<Input
|
|
value={newLineDescription}
|
|
onChange={(e) => setNewLineDescription(e.target.value)}
|
|
placeholder={bankTransaction.description}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Preview */}
|
|
{preview && preview.lines.length > 0 && (
|
|
<>
|
|
<Divider style={{ margin: '16px 0' }} />
|
|
<div>
|
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
|
<Text strong style={{ marginRight: 8 }}>
|
|
Forhaandsvisning af bilag:
|
|
</Text>
|
|
{preview.isValid ? (
|
|
<Tag color="success" icon={<CheckCircleOutlined />}>
|
|
Balancerer
|
|
</Tag>
|
|
) : (
|
|
<Tag color="error" icon={<WarningOutlined />}>
|
|
Fejl
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
|
|
{!preview.isValid && preview.validationMessage && (
|
|
<Alert
|
|
type="error"
|
|
message={preview.validationMessage}
|
|
style={{ marginBottom: 8 }}
|
|
/>
|
|
)}
|
|
|
|
<Table
|
|
dataSource={preview.lines.map((line, idx) => ({ ...line, key: idx }))}
|
|
columns={previewColumns}
|
|
pagination={false}
|
|
size="small"
|
|
bordered
|
|
summary={() => {
|
|
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit, 0);
|
|
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit, 0);
|
|
return (
|
|
<Table.Summary.Row>
|
|
<Table.Summary.Cell index={0}>
|
|
<Text strong>Total</Text>
|
|
</Table.Summary.Cell>
|
|
<Table.Summary.Cell index={1} align="right">
|
|
<Text strong style={{ color: accountingColors.debit }}>
|
|
{formatCurrency(totalDebit)}
|
|
</Text>
|
|
</Table.Summary.Cell>
|
|
<Table.Summary.Cell index={2} align="right">
|
|
<Text strong style={{ color: accountingColors.credit }}>
|
|
{formatCurrency(totalCredit)}
|
|
</Text>
|
|
</Table.Summary.Cell>
|
|
</Table.Summary.Row>
|
|
);
|
|
}}
|
|
/>
|
|
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
|
<Text type="secondary">
|
|
Debet = Kredit{' '}
|
|
{preview.isValid ? (
|
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
|
) : (
|
|
<WarningOutlined style={{ color: '#ff4d4f' }} />
|
|
)}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default SplitBookModal;
|