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>
1023 lines
33 KiB
TypeScript
1023 lines
33 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import {
|
|
Typography,
|
|
Button,
|
|
Card,
|
|
Table,
|
|
Space,
|
|
Tag,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Select,
|
|
InputNumber,
|
|
Spin,
|
|
Alert,
|
|
Drawer,
|
|
Descriptions,
|
|
Popconfirm,
|
|
Row,
|
|
Col,
|
|
Statistic,
|
|
DatePicker,
|
|
Divider,
|
|
List,
|
|
} from 'antd';
|
|
import { showSuccess, showError, showWarning } from '@/lib/errorHandling';
|
|
import {
|
|
PlusOutlined,
|
|
EditOutlined,
|
|
SearchOutlined,
|
|
EyeOutlined,
|
|
SendOutlined,
|
|
StopOutlined,
|
|
DeleteOutlined,
|
|
DollarOutlined,
|
|
FileTextOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import dayjs from 'dayjs';
|
|
import { useCompany } from '@/hooks/useCompany';
|
|
import { useCurrentFiscalYear } from '@/stores/periodStore';
|
|
import { useInvoices, type Invoice, type InvoiceLine, type InvoiceStatus } from '@/api/queries/invoiceQueries';
|
|
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
|
|
import { useActiveProducts, type Product } from '@/api/queries/productQueries';
|
|
import {
|
|
useCreateInvoice,
|
|
useAddInvoiceLine,
|
|
useUpdateInvoiceLine,
|
|
useRemoveInvoiceLine,
|
|
useSendInvoice,
|
|
useVoidInvoice,
|
|
useReceivePayment,
|
|
type CreateInvoiceInput,
|
|
type AddInvoiceLineInput,
|
|
type ReceivePaymentInput,
|
|
type VoidInvoiceInput,
|
|
} from '@/api/mutations/invoiceMutations';
|
|
import { formatCurrency, formatDate } from '@/lib/formatters';
|
|
import { spacing } from '@/styles/designTokens';
|
|
import { accountingColors } from '@/styles/theme';
|
|
import { AmountText } from '@/components/shared/AmountText';
|
|
import { EmptyState } from '@/components/shared/EmptyState';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
const statusColors: Record<string, string> = {
|
|
draft: 'default',
|
|
sent: 'processing',
|
|
issued: 'processing',
|
|
partially_paid: 'warning',
|
|
partially_applied: 'warning',
|
|
paid: 'success',
|
|
fully_applied: 'success',
|
|
voided: 'error',
|
|
};
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
draft: 'Kladde',
|
|
sent: 'Sendt',
|
|
issued: 'Udstedt',
|
|
partially_paid: 'Delvist betalt',
|
|
partially_applied: 'Delvist anvendt',
|
|
paid: 'Betalt',
|
|
fully_applied: 'Fuldt anvendt',
|
|
voided: 'Annulleret',
|
|
};
|
|
|
|
export default function Fakturaer() {
|
|
const navigate = useNavigate();
|
|
const { company } = useCompany();
|
|
const currentFiscalYear = useCurrentFiscalYear();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const customerIdFilter = searchParams.get('customer');
|
|
const shouldCreateNew = searchParams.get('create') === 'true';
|
|
const preselectedCustomerId = searchParams.get('customerId');
|
|
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [isLineModalOpen, setIsLineModalOpen] = useState(false);
|
|
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
|
const [isVoidModalOpen, setIsVoidModalOpen] = useState(false);
|
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
|
|
const [editingLine, setEditingLine] = useState<InvoiceLine | null>(null);
|
|
const [searchText, setSearchText] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | 'all'>('all');
|
|
const [createForm] = Form.useForm();
|
|
const [lineForm] = Form.useForm();
|
|
const [paymentForm] = Form.useForm();
|
|
const [voidForm] = Form.useForm();
|
|
|
|
// Fetch invoices
|
|
const {
|
|
data: invoices = [],
|
|
isLoading: loading,
|
|
error,
|
|
refetch,
|
|
} = useInvoices(company?.id);
|
|
|
|
// Fetch customers for dropdown
|
|
const { data: customers = [] } = useActiveCustomers(company?.id);
|
|
|
|
// Fetch products for line form
|
|
const { data: products = [] } = useActiveProducts(company?.id);
|
|
|
|
// Mutations
|
|
const createInvoiceMutation = useCreateInvoice();
|
|
const addInvoiceLineMutation = useAddInvoiceLine();
|
|
const updateInvoiceLineMutation = useUpdateInvoiceLine();
|
|
const removeInvoiceLineMutation = useRemoveInvoiceLine();
|
|
const sendInvoiceMutation = useSendInvoice();
|
|
const receivePaymentMutation = useReceivePayment();
|
|
const voidInvoiceMutation = useVoidInvoice();
|
|
|
|
// Auto-open create modal when coming from customer card
|
|
useEffect(() => {
|
|
if (shouldCreateNew && preselectedCustomerId && customers.length > 0) {
|
|
createForm.resetFields();
|
|
createForm.setFieldsValue({
|
|
customerId: preselectedCustomerId,
|
|
invoiceDate: dayjs(),
|
|
});
|
|
setIsCreateModalOpen(true);
|
|
// Clear URL params to prevent re-opening on refresh
|
|
setSearchParams({}, { replace: true });
|
|
}
|
|
}, [shouldCreateNew, preselectedCustomerId, customers, createForm, setSearchParams]);
|
|
|
|
// Filter invoices
|
|
const filteredInvoices = useMemo(() => {
|
|
return invoices.filter((invoice) => {
|
|
const matchesSearch =
|
|
searchText === '' ||
|
|
invoice.invoiceNumber.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
invoice.customerName.toLowerCase().includes(searchText.toLowerCase());
|
|
|
|
const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter;
|
|
|
|
const matchesCustomer = !customerIdFilter || invoice.customerId === customerIdFilter;
|
|
|
|
return matchesSearch && matchesStatus && matchesCustomer;
|
|
});
|
|
}, [invoices, searchText, statusFilter, customerIdFilter]);
|
|
|
|
// Statistics
|
|
const stats = useMemo(() => {
|
|
const total = invoices.length;
|
|
const draft = invoices.filter((i) => i.status === 'draft').length;
|
|
const outstanding = invoices
|
|
.filter((i) => ['sent', 'partially_paid'].includes(i.status))
|
|
.reduce((sum, i) => sum + i.amountRemaining, 0);
|
|
const totalValue = invoices
|
|
.filter((i) => i.status !== 'voided')
|
|
.reduce((sum, i) => sum + i.amountTotal, 0);
|
|
return { total, draft, outstanding, totalValue };
|
|
}, [invoices]);
|
|
|
|
const handleCreateInvoice = () => {
|
|
createForm.resetFields();
|
|
createForm.setFieldsValue({
|
|
invoiceDate: dayjs(),
|
|
});
|
|
setIsCreateModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitCreate = async () => {
|
|
if (!company || !currentFiscalYear) {
|
|
showError('Virksomhed eller regnskabsår ikke valgt');
|
|
return;
|
|
}
|
|
try {
|
|
const values = await createForm.validateFields();
|
|
const input: CreateInvoiceInput = {
|
|
companyId: company.id,
|
|
fiscalYearId: currentFiscalYear.id,
|
|
customerId: values.customerId,
|
|
invoiceDate: values.invoiceDate?.toISOString(),
|
|
notes: values.notes || undefined,
|
|
reference: values.reference || undefined,
|
|
};
|
|
const result = await createInvoiceMutation.mutateAsync(input);
|
|
showSuccess('Faktura oprettet');
|
|
setIsCreateModalOpen(false);
|
|
createForm.resetFields();
|
|
setSelectedInvoice(result);
|
|
setIsDrawerOpen(true);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAddLine = () => {
|
|
setEditingLine(null);
|
|
lineForm.resetFields();
|
|
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' });
|
|
setIsLineModalOpen(true);
|
|
};
|
|
|
|
const handleEditLine = (line: InvoiceLine) => {
|
|
setEditingLine(line);
|
|
lineForm.setFieldsValue({
|
|
description: line.description,
|
|
quantity: line.quantity,
|
|
unitPrice: line.unitPrice,
|
|
unit: line.unit,
|
|
discountPercent: line.discountPercent,
|
|
vatCode: line.vatCode,
|
|
});
|
|
setIsLineModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitLine = async () => {
|
|
if (!selectedInvoice) return;
|
|
try {
|
|
const values = await lineForm.validateFields();
|
|
if (editingLine) {
|
|
const result = await updateInvoiceLineMutation.mutateAsync({
|
|
invoiceId: selectedInvoice.id,
|
|
lineNumber: editingLine.lineNumber,
|
|
...values,
|
|
});
|
|
showSuccess('Linje opdateret');
|
|
setSelectedInvoice(result);
|
|
} else {
|
|
const input: AddInvoiceLineInput = {
|
|
invoiceId: selectedInvoice.id,
|
|
description: values.description,
|
|
quantity: values.quantity,
|
|
unitPrice: values.unitPrice,
|
|
unit: values.unit || undefined,
|
|
discountPercent: values.discountPercent || 0,
|
|
vatCode: values.vatCode,
|
|
};
|
|
const result = await addInvoiceLineMutation.mutateAsync(input);
|
|
showSuccess('Linje tilføjet');
|
|
setSelectedInvoice(result);
|
|
}
|
|
setIsLineModalOpen(false);
|
|
setEditingLine(null);
|
|
lineForm.resetFields();
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveLine = async (lineNumber: number) => {
|
|
if (!selectedInvoice) return;
|
|
try {
|
|
const result = await removeInvoiceLineMutation.mutateAsync({
|
|
invoiceId: selectedInvoice.id,
|
|
lineNumber,
|
|
});
|
|
showSuccess('Linje fjernet');
|
|
setSelectedInvoice(result);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSendInvoice = async () => {
|
|
if (!selectedInvoice) return;
|
|
if (selectedInvoice.lines.length === 0) {
|
|
showWarning('Tilføj mindst én linje før afsendelse');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await sendInvoiceMutation.mutateAsync(selectedInvoice.id);
|
|
showSuccess('Faktura sendt og bogført');
|
|
setSelectedInvoice(result);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReceivePayment = () => {
|
|
paymentForm.resetFields();
|
|
paymentForm.setFieldsValue({
|
|
amount: selectedInvoice?.amountRemaining,
|
|
paymentDate: dayjs(),
|
|
});
|
|
setIsPaymentModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitPayment = async () => {
|
|
if (!selectedInvoice) return;
|
|
try {
|
|
const values = await paymentForm.validateFields();
|
|
const input: ReceivePaymentInput = {
|
|
invoiceId: selectedInvoice.id,
|
|
amount: values.amount,
|
|
bankAccountId: values.bankAccountId,
|
|
paymentDate: values.paymentDate?.toISOString(),
|
|
paymentReference: values.paymentReference || undefined,
|
|
};
|
|
const result = await receivePaymentMutation.mutateAsync(input);
|
|
showSuccess('Betaling registreret');
|
|
setIsPaymentModalOpen(false);
|
|
paymentForm.resetFields();
|
|
setSelectedInvoice(result);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleVoidInvoice = () => {
|
|
voidForm.resetFields();
|
|
setIsVoidModalOpen(true);
|
|
};
|
|
|
|
const handleSubmitVoid = async () => {
|
|
if (!selectedInvoice) return;
|
|
try {
|
|
const values = await voidForm.validateFields();
|
|
const input: VoidInvoiceInput = {
|
|
invoiceId: selectedInvoice.id,
|
|
reason: values.reason,
|
|
};
|
|
const result = await voidInvoiceMutation.mutateAsync(input);
|
|
showSuccess('Faktura annulleret');
|
|
setIsVoidModalOpen(false);
|
|
voidForm.resetFields();
|
|
setSelectedInvoice(result);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
showError(err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleViewInvoice = (invoice: Invoice) => {
|
|
setSelectedInvoice(invoice);
|
|
setIsDrawerOpen(true);
|
|
};
|
|
|
|
const columns: ColumnsType<Invoice> = [
|
|
{
|
|
title: 'Fakturanr.',
|
|
dataIndex: 'invoiceNumber',
|
|
key: 'invoiceNumber',
|
|
width: 120,
|
|
sorter: (a, b) => a.invoiceNumber.localeCompare(b.invoiceNumber),
|
|
render: (value: string) => <Text code>{value}</Text>,
|
|
},
|
|
{
|
|
title: 'Kunde',
|
|
dataIndex: 'customerName',
|
|
key: 'customerName',
|
|
sorter: (a, b) => a.customerName.localeCompare(b.customerName),
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: 'Dato',
|
|
dataIndex: 'invoiceDate',
|
|
key: 'invoiceDate',
|
|
width: 100,
|
|
sorter: (a, b) => (a.invoiceDate || '').localeCompare(b.invoiceDate || ''),
|
|
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
|
|
},
|
|
{
|
|
title: 'Forfald',
|
|
dataIndex: 'dueDate',
|
|
key: 'dueDate',
|
|
width: 100,
|
|
render: (value: string | undefined, record: Invoice) => {
|
|
if (!value) return '-';
|
|
const isOverdue =
|
|
['sent', 'partially_paid'].includes(record.status) &&
|
|
dayjs(value).isBefore(dayjs(), 'day');
|
|
return (
|
|
<Text type={isOverdue ? 'danger' : undefined}>
|
|
{formatDate(value)}
|
|
</Text>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Beløb',
|
|
dataIndex: 'amountTotal',
|
|
key: 'amountTotal',
|
|
width: 120,
|
|
align: 'right',
|
|
sorter: (a, b) => a.amountTotal - b.amountTotal,
|
|
render: (value: number) => <AmountText amount={value} />,
|
|
},
|
|
{
|
|
title: 'Restbeløb',
|
|
dataIndex: 'amountRemaining',
|
|
key: 'amountRemaining',
|
|
width: 120,
|
|
align: 'right',
|
|
render: (value: number, record: Invoice) =>
|
|
record.status === 'voided' ? '-' : <AmountText amount={value} />,
|
|
},
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 120,
|
|
align: 'center',
|
|
filters: [
|
|
{ text: 'Kladde', value: 'draft' },
|
|
{ text: 'Sendt', value: 'sent' },
|
|
{ text: 'Delvist betalt', value: 'partially_paid' },
|
|
{ text: 'Betalt', value: 'paid' },
|
|
{ text: 'Annulleret', value: 'voided' },
|
|
],
|
|
onFilter: (value, record) => record.status === value,
|
|
render: (value: InvoiceStatus) => (
|
|
<Tag color={statusColors[value]}>{statusLabels[value]}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
width: 80,
|
|
align: 'center',
|
|
render: (_: unknown, record: Invoice) => (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => handleViewInvoice(record)}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: spacing.lg,
|
|
}}
|
|
>
|
|
<div>
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
Fakturaer
|
|
</Title>
|
|
<Text type="secondary">{company?.name}</Text>
|
|
</div>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
|
|
Ny fakturakladde
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<Alert
|
|
message="Fejl ved indlæsning af fakturaer"
|
|
description={error.message}
|
|
type="error"
|
|
showIcon
|
|
style={{ marginBottom: spacing.lg }}
|
|
action={
|
|
<Button size="small" onClick={() => refetch()}>
|
|
Prøv igen
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Statistics */}
|
|
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic title="Fakturaer i alt" value={stats.total} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="Kladder"
|
|
value={stats.draft}
|
|
valueStyle={{ color: '#8c8c8c' }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="Udestående"
|
|
value={stats.outstanding}
|
|
precision={2}
|
|
valueStyle={{ color: accountingColors.debit }}
|
|
formatter={(value) => formatCurrency(value as number)}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="Samlet omsætning"
|
|
value={stats.totalValue}
|
|
precision={2}
|
|
valueStyle={{ color: accountingColors.credit }}
|
|
formatter={(value) => formatCurrency(value as number)}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Filters */}
|
|
<Card size="small" style={{ marginBottom: spacing.lg }}>
|
|
<Space wrap>
|
|
<Input
|
|
placeholder="Søg faktura..."
|
|
prefix={<SearchOutlined />}
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
style={{ width: 250 }}
|
|
allowClear
|
|
/>
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
style={{ width: 150 }}
|
|
options={[
|
|
{ value: 'all', label: 'Alle status' },
|
|
{ value: 'draft', label: 'Kladde' },
|
|
{ value: 'sent', label: 'Sendt' },
|
|
{ value: 'partially_paid', label: 'Delvist betalt' },
|
|
{ value: 'paid', label: 'Betalt' },
|
|
{ value: 'voided', label: 'Annulleret' },
|
|
]}
|
|
/>
|
|
{customerIdFilter && (
|
|
<Tag closable onClose={() => navigate('/fakturaer')}>
|
|
Filtreret på kunde
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
|
|
{/* Invoice Table */}
|
|
<Card size="small">
|
|
{loading ? (
|
|
<Spin tip="Indlæser fakturaer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
|
|
<div style={{ minHeight: 200 }} />
|
|
</Spin>
|
|
) : filteredInvoices.length > 0 ? (
|
|
<Table
|
|
dataSource={filteredInvoices}
|
|
columns={columns}
|
|
rowKey="id"
|
|
size="small"
|
|
pagination={{ pageSize: 20, showSizeChanger: true }}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
variant="invoices"
|
|
title="Ingen fakturaer"
|
|
description={searchText ? 'Ingen fakturaer matcher din søgning' : 'Opret din første fakturakladde'}
|
|
primaryAction={
|
|
!searchText
|
|
? {
|
|
label: 'Opret fakturakladde',
|
|
onClick: handleCreateInvoice,
|
|
icon: <PlusOutlined />,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Create Invoice Draft Modal */}
|
|
<Modal
|
|
title="Opret fakturakladde"
|
|
open={isCreateModalOpen}
|
|
onCancel={() => setIsCreateModalOpen(false)}
|
|
onOk={handleSubmitCreate}
|
|
okText="Opret kladde"
|
|
cancelText="Annuller"
|
|
confirmLoading={createInvoiceMutation.isPending}
|
|
>
|
|
<Alert
|
|
message="Betalingsbetingelser hentes automatisk fra kundens standardindstillinger"
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
<Form form={createForm} layout="vertical">
|
|
<Form.Item
|
|
name="customerId"
|
|
label="Kunde"
|
|
rules={[{ required: true, message: 'Vælg kunde' }]}
|
|
>
|
|
<Select
|
|
showSearch
|
|
placeholder="Vælg kunde"
|
|
optionFilterProp="children"
|
|
filterOption={(input, option) =>
|
|
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
options={customers.map((c: Customer) => ({
|
|
value: c.id,
|
|
label: `${c.customerNumber} - ${c.name}`,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="invoiceDate" label="Fakturadato">
|
|
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
|
|
</Form.Item>
|
|
<Form.Item name="reference" label="Reference">
|
|
<Input placeholder="Ordrenummer, projektnavn, etc." />
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="Bemærkninger">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Invoice Detail Drawer */}
|
|
<Drawer
|
|
title={
|
|
selectedInvoice && (
|
|
<Space>
|
|
<FileTextOutlined />
|
|
<span>Faktura {selectedInvoice.invoiceNumber}</span>
|
|
<Tag color={statusColors[selectedInvoice.status]}>
|
|
{statusLabels[selectedInvoice.status]}
|
|
</Tag>
|
|
</Space>
|
|
)
|
|
}
|
|
placement="right"
|
|
width={700}
|
|
open={isDrawerOpen}
|
|
onClose={() => {
|
|
setIsDrawerOpen(false);
|
|
setSelectedInvoice(null);
|
|
}}
|
|
extra={
|
|
selectedInvoice && (
|
|
<Space>
|
|
{selectedInvoice.status === 'draft' && (
|
|
<>
|
|
<Button icon={<PlusOutlined />} onClick={handleAddLine}>
|
|
Tilføj linje
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<SendOutlined />}
|
|
onClick={handleSendInvoice}
|
|
loading={sendInvoiceMutation.isPending}
|
|
disabled={selectedInvoice.lines.length === 0}
|
|
>
|
|
Send
|
|
</Button>
|
|
</>
|
|
)}
|
|
{['sent', 'partially_paid'].includes(selectedInvoice.status) && (
|
|
<>
|
|
<Button icon={<DollarOutlined />} onClick={handleReceivePayment}>
|
|
Registrer betaling
|
|
</Button>
|
|
<Button danger icon={<StopOutlined />} onClick={handleVoidInvoice}>
|
|
Annuller
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Space>
|
|
)
|
|
}
|
|
>
|
|
{selectedInvoice && (
|
|
<div>
|
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: spacing.lg }}>
|
|
<Descriptions.Item label="Kunde" span={2}>
|
|
{selectedInvoice.customerName}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Fakturadato">
|
|
{selectedInvoice.invoiceDate ? formatDate(selectedInvoice.invoiceDate) : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Forfaldsdato">
|
|
{selectedInvoice.dueDate ? formatDate(selectedInvoice.dueDate) : '-'}
|
|
</Descriptions.Item>
|
|
{selectedInvoice.reference && (
|
|
<Descriptions.Item label="Reference" span={2}>
|
|
{selectedInvoice.reference}
|
|
</Descriptions.Item>
|
|
)}
|
|
</Descriptions>
|
|
|
|
<Title level={5}>Linjer</Title>
|
|
{selectedInvoice.lines.length > 0 ? (
|
|
<List
|
|
size="small"
|
|
bordered
|
|
dataSource={selectedInvoice.lines}
|
|
renderItem={(line: InvoiceLine) => (
|
|
<List.Item
|
|
actions={
|
|
selectedInvoice.status === 'draft'
|
|
? [
|
|
<Button
|
|
key="edit"
|
|
type="text"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEditLine(line)}
|
|
/>,
|
|
<Popconfirm
|
|
key="delete"
|
|
title="Fjern linje?"
|
|
onConfirm={() => handleRemoveLine(line.lineNumber)}
|
|
okText="Ja"
|
|
cancelText="Nej"
|
|
>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
/>
|
|
</Popconfirm>,
|
|
]
|
|
: undefined
|
|
}
|
|
>
|
|
<List.Item.Meta
|
|
title={line.description}
|
|
description={
|
|
<Space>
|
|
<span>
|
|
{line.quantity} {line.unit || 'stk'} x {formatCurrency(line.unitPrice)}
|
|
</span>
|
|
{line.discountPercent > 0 && (
|
|
<Tag color="orange">-{line.discountPercent}%</Tag>
|
|
)}
|
|
<Tag>{line.vatCode}</Tag>
|
|
</Space>
|
|
}
|
|
/>
|
|
<AmountText amount={line.amountTotal} style={{ fontWeight: 'bold' }} />
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Alert
|
|
message="Ingen linjer endnu"
|
|
description="Tilføj linjer for at kunne sende fakturaen."
|
|
type="info"
|
|
showIcon
|
|
/>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
{selectedInvoice.notes && (
|
|
<>
|
|
<Text type="secondary">Bemærkninger:</Text>
|
|
<p>{selectedInvoice.notes}</p>
|
|
</>
|
|
)}
|
|
</Col>
|
|
<Col span={12}>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<div style={{ marginBottom: 4 }}>
|
|
<Text type="secondary">Beløb ex. moms: </Text>
|
|
<Text>{formatCurrency(selectedInvoice.amountExVat)}</Text>
|
|
</div>
|
|
<div style={{ marginBottom: 4 }}>
|
|
<Text type="secondary">Moms: </Text>
|
|
<Text>{formatCurrency(selectedInvoice.amountVat)}</Text>
|
|
</div>
|
|
<div style={{ marginBottom: 4 }}>
|
|
<Text strong>Total: </Text>
|
|
<Text strong style={{ fontSize: 16 }}>
|
|
{formatCurrency(selectedInvoice.amountTotal)}
|
|
</Text>
|
|
</div>
|
|
{selectedInvoice.amountPaid > 0 && (
|
|
<div style={{ marginBottom: 4 }}>
|
|
<Text type="secondary">Betalt: </Text>
|
|
<Text type="success">{formatCurrency(selectedInvoice.amountPaid)}</Text>
|
|
</div>
|
|
)}
|
|
{selectedInvoice.amountRemaining > 0 &&
|
|
selectedInvoice.status !== 'voided' && (
|
|
<div>
|
|
<Text type="secondary">Restbeløb: </Text>
|
|
<Text type="danger" strong>
|
|
{formatCurrency(selectedInvoice.amountRemaining)}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
)}
|
|
</Drawer>
|
|
|
|
{/* Add/Edit Line Modal */}
|
|
<Modal
|
|
title={editingLine ? 'Rediger linje' : 'Tilføj linje'}
|
|
open={isLineModalOpen}
|
|
onCancel={() => {
|
|
setIsLineModalOpen(false);
|
|
setEditingLine(null);
|
|
lineForm.resetFields();
|
|
}}
|
|
onOk={handleSubmitLine}
|
|
okText="Gem"
|
|
cancelText="Annuller"
|
|
confirmLoading={addInvoiceLineMutation.isPending || updateInvoiceLineMutation.isPending}
|
|
>
|
|
<Form form={lineForm} layout="vertical">
|
|
{/* Product selector - copies values to form fields when selected */}
|
|
<Form.Item label="Produkt (valgfrit)">
|
|
<Select
|
|
showSearch
|
|
allowClear
|
|
placeholder="Vælg produkt for at udfylde automatisk"
|
|
optionFilterProp="children"
|
|
filterOption={(input, option) =>
|
|
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
options={products.map((p: Product) => ({
|
|
value: p.id,
|
|
label: p.productNumber ? `${p.productNumber} - ${p.name}` : p.name,
|
|
}))}
|
|
onChange={(productId) => {
|
|
if (productId) {
|
|
const product = products.find((p: Product) => p.id === productId);
|
|
if (product) {
|
|
lineForm.setFieldsValue({
|
|
description: product.description || product.name,
|
|
unitPrice: product.unitPrice,
|
|
vatCode: product.vatCode === 'U25' ? 'S25' : product.vatCode,
|
|
unit: product.unit || undefined,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="description"
|
|
label="Beskrivelse"
|
|
rules={[{ required: true, message: 'Indtast beskrivelse' }]}
|
|
>
|
|
<Input placeholder="Vare eller ydelse" />
|
|
</Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Form.Item
|
|
name="quantity"
|
|
label="Antal"
|
|
rules={[{ required: true, message: 'Indtast antal' }]}
|
|
>
|
|
<InputNumber min={0.01} step={1} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item name="unit" label="Enhed">
|
|
<Select
|
|
allowClear
|
|
placeholder="Vælg"
|
|
options={[
|
|
{ value: 'stk', label: 'stk' },
|
|
{ value: 'timer', label: 'timer' },
|
|
{ value: 'kg', label: 'kg' },
|
|
{ value: 'm', label: 'meter' },
|
|
{ value: 'm2', label: 'm2' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Form.Item
|
|
name="unitPrice"
|
|
label="Enhedspris"
|
|
rules={[{ required: true, message: 'Indtast pris' }]}
|
|
>
|
|
<InputNumber
|
|
min={0}
|
|
step={0.01}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item name="discountPercent" label="Rabat %">
|
|
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="vatCode"
|
|
label="Momskode"
|
|
rules={[{ required: true, message: 'Vælg momskode' }]}
|
|
>
|
|
<Select
|
|
options={[
|
|
{ value: 'S25', label: 'S25 - 25% moms' },
|
|
{ value: 'U0', label: 'U0 - Momsfrit' },
|
|
{ value: 'UEU', label: 'UEU - EU-salg' },
|
|
{ value: 'UEXP', label: 'UEXP - Eksport' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Payment Modal */}
|
|
<Modal
|
|
title="Registrer betaling"
|
|
open={isPaymentModalOpen}
|
|
onCancel={() => setIsPaymentModalOpen(false)}
|
|
onOk={handleSubmitPayment}
|
|
okText="Registrer"
|
|
cancelText="Annuller"
|
|
confirmLoading={receivePaymentMutation.isPending}
|
|
>
|
|
<Form form={paymentForm} layout="vertical">
|
|
<Form.Item
|
|
name="amount"
|
|
label="Beløb"
|
|
rules={[{ required: true, message: 'Indtast beløb' }]}
|
|
>
|
|
<InputNumber
|
|
min={0.01}
|
|
max={selectedInvoice?.amountRemaining}
|
|
style={{ width: '100%' }}
|
|
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
|
parser={(value) => value!.replace(/\./g, '') as unknown as number}
|
|
addonAfter="DKK"
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="bankAccountId"
|
|
label="Bankkonto"
|
|
rules={[{ required: true, message: 'Vælg bankkonto' }]}
|
|
>
|
|
<Select
|
|
placeholder="Vælg bankkonto"
|
|
options={[
|
|
{ value: 'bank-hovedkonto', label: '5600 - Bankkonto' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="paymentDate" label="Betalingsdato">
|
|
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
|
|
</Form.Item>
|
|
<Form.Item name="paymentReference" label="Reference">
|
|
<Input placeholder="Betalingsreference" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Void Modal */}
|
|
<Modal
|
|
title="Annuller faktura"
|
|
open={isVoidModalOpen}
|
|
onCancel={() => setIsVoidModalOpen(false)}
|
|
onOk={handleSubmitVoid}
|
|
okText="Annuller faktura"
|
|
okButtonProps={{ danger: true }}
|
|
cancelText="Fortryd"
|
|
confirmLoading={voidInvoiceMutation.isPending}
|
|
>
|
|
<Alert
|
|
message="Advarsel"
|
|
description="At annullere fakturaen vil tilbageføre alle bogførte posteringer. Denne handling kan ikke fortrydes."
|
|
type="warning"
|
|
showIcon
|
|
style={{ marginBottom: spacing.lg }}
|
|
/>
|
|
<Form form={voidForm} layout="vertical">
|
|
<Form.Item
|
|
name="reason"
|
|
label="Årsag til annullering"
|
|
rules={[{ required: true, message: 'Angiv årsag' }]}
|
|
>
|
|
<Input.TextArea rows={3} placeholder="Beskriv hvorfor fakturaen annulleres" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|