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>
624 lines
19 KiB
TypeScript
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;
|