books/frontend/src/pages/Fakturaer.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

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 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>
);
}