books/frontend/src/components/simple-booking/SplitBookModal.tsx

473 lines
14 KiB
TypeScript
Raw Normal View History

// 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;