books/frontend/src/components/bank-reconciliation/DocumentUploadModal.tsx
Nicolaj Hartmann 381156ade7 Add frontend components, API mutations, and project config
Frontend:
- API mutations for accounts, bank connections, customers, invoices
- Document processing API
- Shared components (PageHeader, EmptyState, etc.)
- Pages: Admin, Fakturaer, Kunder, Ordrer, Produkter, etc.
- Hooks and stores

Config:
- CLAUDE.md project instructions
- Beads issue tracking config
- Git attributes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:20:03 +01:00

624 lines
19 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Modal,
Spin,
Result,
Descriptions,
Tag,
Space,
Button,
Typography,
Divider,
Alert,
Table,
Collapse,
message,
} from 'antd';
import {
FileOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
LinkOutlined,
BankOutlined,
CalendarOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { formatCurrency, formatDateShort, formatCVR } from '@/lib/formatters';
import { AmountText } from '@/components/shared/AmountText';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { useResponsiveModal } from '@/hooks/useResponsiveModal';
import { usePostJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
import { accountingColors } from '@/styles/theme';
import { spacing } from '@/styles/designTokens';
import type { DocumentProcessingResult, ExtractedLineItem, SuggestedJournalLine } from '@/api/documentProcessing';
const { Text, Title } = Typography;
interface DocumentUploadModalProps {
visible: boolean;
result: DocumentProcessingResult | null;
isProcessing: boolean;
error: string | null;
fileName?: string;
onConfirm: () => void;
onClose: () => void;
}
interface JournalPreviewLine {
key: number;
accountNumber: string;
accountName: string;
debit: number;
credit: number;
vatCode?: string;
}
/**
* Modal showing document processing results from AI analysis.
* Displays extracted information, journal entry preview, and matched bank transaction.
* Offers options to save as draft or post immediately.
*/
export function DocumentUploadModal({
visible,
result,
isProcessing,
error,
fileName,
onConfirm,
onClose,
}: DocumentUploadModalProps) {
const responsiveModalProps = useResponsiveModal({ size: 'large' });
const [isPosting, setIsPosting] = useState(false);
// Mutations
const postDraftMutation = usePostJournalEntryDraft();
const discardDraftMutation = useDiscardJournalEntryDraft();
// Build journal preview lines directly from API response (no separate fetch needed)
const journalLines = useMemo((): JournalPreviewLine[] => {
if (!result?.suggestedLines || result.suggestedLines.length === 0) return [];
return result.suggestedLines.map((line: SuggestedJournalLine, idx: number) => ({
key: idx,
accountNumber: line.accountNumber || '-',
accountName: line.accountName || 'Ukendt konto',
debit: line.debitAmount,
credit: line.creditAmount,
vatCode: line.vatCode,
}));
}, [result?.suggestedLines]);
// Calculate totals
const { totalDebit, totalCredit, isBalanced } = useMemo(() => {
const debit = journalLines.reduce((sum, l) => sum + l.debit, 0);
const credit = journalLines.reduce((sum, l) => sum + l.credit, 0);
return {
totalDebit: debit,
totalCredit: credit,
isBalanced: Math.abs(debit - credit) < 0.01,
};
}, [journalLines]);
const handlePostNow = async () => {
if (!result?.draftId) return;
setIsPosting(true);
try {
await postDraftMutation.mutateAsync(result.draftId);
message.success('Bogfoert!');
onConfirm();
} catch (err) {
message.error('Kunne ikke bogfoere. Proev igen.');
} finally {
setIsPosting(false);
}
};
const handleSaveAsDraft = () => {
// Draft is already saved, just close the modal
onConfirm();
};
const handleCancel = async () => {
// Discard the draft if one was created
if (result?.draftId && !result.isDuplicate) {
try {
await discardDraftMutation.mutateAsync(result.draftId);
} catch {
// Silently fail - draft cleanup is best effort
}
}
onClose();
};
// Processing state
if (isProcessing) {
return (
<Modal
open={visible}
title={
<Space>
<FileOutlined />
Behandler dokument
</Space>
}
footer={null}
closable={false}
maskClosable={false}
width={500}
>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>
<Text type="secondary">Analyserer {fileName || 'dokument'}...</Text>
</div>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
AI-tjenesten udtraekker information fra dokumentet
</Text>
</div>
</div>
</Modal>
);
}
// Error state
if (error) {
return (
<Modal
open={visible}
title={
<Space>
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
Fejl ved behandling
</Space>
}
footer={[
<Button key="close" onClick={onClose}>
Luk
</Button>,
]}
onCancel={onClose}
width={500}
>
<Result
status="error"
title="Dokumentet kunne ikke behandles"
subTitle={error}
/>
</Modal>
);
}
// Duplicate detection
if (result?.isDuplicate) {
return (
<Modal
open={visible}
title={
<Space>
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
Dokument allerede behandlet
</Space>
}
footer={[
<Button key="close" onClick={onClose}>
Luk
</Button>,
<Button key="view" type="primary" onClick={onConfirm}>
Gaa til kladde
</Button>,
]}
onCancel={onClose}
width={500}
>
<Alert
message="Dette dokument er allerede blevet behandlet"
description={result.message || 'Du kan se den eksisterende kladde ved at klikke nedenfor.'}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
{result.draftId && <Text type="secondary">Kladde ID: {result.draftId}</Text>}
</Modal>
);
}
// Success state with comprehensive verification view
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: JournalPreviewLine) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
width: 120,
render: (value: number) =>
value > 0 ? <AmountText amount={value} type="debit" showCurrency={false} /> : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
width: 120,
render: (value: number) =>
value > 0 ? <AmountText amount={value} type="credit" showCurrency={false} /> : null,
},
];
return (
<Modal
open={visible}
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
Dokument analyseret
</Space>
}
footer={[
<Button key="cancel" onClick={handleCancel}>
Annuller
</Button>,
<Button key="draft" onClick={handleSaveAsDraft}>
Tilfoej til kladde
</Button>,
<Button
key="post"
type="primary"
onClick={handlePostNow}
loading={isPosting}
disabled={!result?.draftId || (journalLines.length > 0 && !isBalanced)}
>
Godkend og bogfoer
</Button>,
]}
onCancel={handleCancel}
{...responsiveModalProps}
>
{result && (
<div>
{/* Section 1: Extracted Document Info */}
{result.extraction && (
<ExtractedInfoSection extraction={result.extraction} />
)}
{/* Section 2: Journal Entry Preview */}
<Divider style={{ margin: `${spacing.md}px 0` }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: spacing.sm }}>
<Title level={5} style={{ margin: 0, marginRight: spacing.sm }}>
Foreslaaet bogfoering
</Title>
{journalLines.length > 0 && (
isBalanced ? (
<StatusBadge status="success" text="Afstemmer" />
) : (
<StatusBadge status="error" text="Ubalance" />
)
)}
</div>
{journalLines.length > 0 ? (
<Table
dataSource={journalLines}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>I alt</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<AmountText
amount={totalDebit}
type="debit"
showCurrency={false}
style={{ fontWeight: 'bold' }}
/>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<AmountText
amount={totalCredit}
type="credit"
showCurrency={false}
style={{ fontWeight: 'bold' }}
/>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
) : (
<Alert
message="Ingen kontobogfoering foreslaaet"
description="AI kunne ikke foreslaa konti til dette dokument. Du kan tilfoeje dokumentet til kladden og bogfoere manuelt."
type="info"
showIcon
icon={<InfoCircleOutlined />}
/>
)}
</div>
{/* Section 3: Bank Transaction Match */}
{result.bankTransactionMatch && (
<>
<Divider style={{ margin: `${spacing.md}px 0` }} />
<Title level={5} style={{ marginBottom: spacing.sm }}>
<LinkOutlined style={{ marginRight: 8 }} />
Matchet banktransaktion
</Title>
<div
style={{
padding: 12,
backgroundColor: '#e6f7ff',
borderRadius: 6,
border: '1px solid #91d5ff',
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<BankOutlined />
<Text>
{result.bankTransactionMatch.description ||
result.bankTransactionMatch.counterparty}
</Text>
</Space>
<AmountText amount={result.bankTransactionMatch.amount} showSign />
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{result.bankTransactionMatch.date &&
formatDateShort(result.bankTransactionMatch.date)}
{result.bankTransactionMatch.counterparty &&
` - ${result.bankTransactionMatch.counterparty}`}
</Text>
</Space>
</div>
</>
)}
{/* No match found */}
{!result.bankTransactionMatch && result.extraction?.amount && (
<>
<Divider style={{ margin: `${spacing.md}px 0` }} />
<Alert
message="Ingen matchende banktransaktion fundet"
description={`Der blev ikke fundet en pending banktransaktion paa ${formatCurrency(result.extraction.amount)}. Kladden er oprettet og kan matches manuelt.`}
type="info"
showIcon
icon={<InfoCircleOutlined />}
/>
</>
)}
{/* Account suggestion confidence */}
{result.accountSuggestion && (
<div style={{ marginTop: spacing.sm }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Kontoforslag baseret paa AI-analyse (
{Math.round(result.accountSuggestion.confidence * 100)}% sikkerhed)
</Text>
</div>
)}
</div>
)}
</Modal>
);
}
/**
* Sub-component for displaying extracted document information
*/
function ExtractedInfoSection({
extraction,
}: {
extraction: NonNullable<DocumentProcessingResult['extraction']>;
}) {
const hasLineItems = extraction.lineItems && extraction.lineItems.length > 0;
const lineItemColumns = [
{
title: 'Beskrivelse',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: 'Antal',
dataIndex: 'quantity',
key: 'quantity',
align: 'right' as const,
width: 80,
render: (val?: number) => val?.toLocaleString('da-DK') ?? '-',
},
{
title: 'Enhedspris',
dataIndex: 'unitPrice',
key: 'unitPrice',
align: 'right' as const,
width: 100,
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
},
{
title: 'Beloeb',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
width: 100,
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
},
];
return (
<div>
<Title level={5} style={{ marginBottom: 12 }}>
<FileOutlined style={{ marginRight: 8 }} />
Udtrukket information
</Title>
<Descriptions
bordered
size="small"
column={{ xs: 1, sm: 2 }}
style={{ marginBottom: hasLineItems ? 12 : 0 }}
>
{extraction.vendor && (
<Descriptions.Item label="Leverandoer" span={extraction.vendorCvr ? 1 : 2}>
<Text strong>{extraction.vendor}</Text>
</Descriptions.Item>
)}
{extraction.vendorCvr && (
<Descriptions.Item label="CVR">
<Text copyable={{ text: extraction.vendorCvr }}>
{formatCVR(extraction.vendorCvr)}
</Text>
</Descriptions.Item>
)}
{extraction.invoiceNumber && (
<Descriptions.Item label="Fakturanr.">
{extraction.invoiceNumber}
</Descriptions.Item>
)}
{extraction.documentType && (
<Descriptions.Item label="Type">
<Tag>{mapDocumentType(extraction.documentType)}</Tag>
</Descriptions.Item>
)}
{extraction.date && (
<Descriptions.Item label="Dokumentdato">
<Space>
<CalendarOutlined />
{formatDateShort(extraction.date)}
</Space>
</Descriptions.Item>
)}
{extraction.dueDate && (
<Descriptions.Item label="Forfaldsdato">
<Space>
<CalendarOutlined />
{formatDateShort(extraction.dueDate)}
</Space>
</Descriptions.Item>
)}
</Descriptions>
{/* Amount breakdown */}
<div
style={{
marginTop: 12,
padding: 12,
backgroundColor: '#fafafa',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{extraction.amountExVat != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Beloeb ekskl. moms</Text>
<AmountText amount={extraction.amountExVat} />
</div>
)}
{extraction.vatAmount != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Moms (25%)</Text>
<AmountText amount={extraction.vatAmount} />
</div>
)}
{extraction.amount != null && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
borderTop:
extraction.amountExVat != null || extraction.vatAmount != null
? '1px solid #e8e8e8'
: undefined,
paddingTop:
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
marginTop:
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
}}
>
<Text strong>Beloeb inkl. moms</Text>
<Text
strong
style={{
fontSize: 16,
color: accountingColors.debit,
}}
>
{formatCurrency(extraction.amount)}
</Text>
</div>
)}
{extraction.currency && extraction.currency !== 'DKK' && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Valuta</Text>
<Tag>{extraction.currency}</Tag>
</div>
)}
{extraction.paymentReference && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Betalingsreference</Text>
<Text copyable style={{ fontFamily: 'monospace' }}>
{extraction.paymentReference}
</Text>
</div>
)}
</Space>
</div>
{/* Line items (collapsible) */}
{hasLineItems && (
<Collapse
ghost
style={{ marginTop: 12 }}
items={[
{
key: 'lineItems',
label: `Fakturalinjer (${extraction.lineItems!.length})`,
children: (
<Table
dataSource={extraction.lineItems!.map((item: ExtractedLineItem, idx: number) => ({
...item,
key: idx,
}))}
columns={lineItemColumns}
pagination={false}
size="small"
bordered
/>
),
},
]}
/>
)}
</div>
);
}
function mapDocumentType(type: string): string {
const typeMap: Record<string, string> = {
invoice: 'Faktura',
receipt: 'Kvittering',
credit_note: 'Kreditnota',
bank_statement: 'Kontoudtog',
contract: 'Kontrakt',
other: 'Andet',
};
return typeMap[type] || type;
}
export default DocumentUploadModal;