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 = { draft: 'default', sent: 'processing', issued: 'processing', partially_paid: 'warning', partially_applied: 'warning', paid: 'success', fully_applied: 'success', voided: 'error', }; const statusLabels: Record = { 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(null); const [editingLine, setEditingLine] = useState(null); const [searchText, setSearchText] = useState(''); const [statusFilter, setStatusFilter] = useState('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 = [ { title: 'Fakturanr.', dataIndex: 'invoiceNumber', key: 'invoiceNumber', width: 120, sorter: (a, b) => a.invoiceNumber.localeCompare(b.invoiceNumber), render: (value: string) => {value}, }, { 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 ( {formatDate(value)} ); }, }, { title: 'Beløb', dataIndex: 'amountTotal', key: 'amountTotal', width: 120, align: 'right', sorter: (a, b) => a.amountTotal - b.amountTotal, render: (value: number) => , }, { title: 'Restbeløb', dataIndex: 'amountRemaining', key: 'amountRemaining', width: 120, align: 'right', render: (value: number, record: Invoice) => record.status === 'voided' ? '-' : , }, { 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) => ( {statusLabels[value]} ), }, { title: '', key: 'actions', width: 80, align: 'center', render: (_: unknown, record: Invoice) => ( {/* Error State */} {error && ( refetch()}> Prøv igen } /> )} {/* Statistics */} formatCurrency(value as number)} /> formatCurrency(value as number)} /> {/* Filters */} } value={searchText} onChange={(e) => setSearchText(e.target.value)} style={{ width: 250 }} allowClear /> (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) } options={customers.map((c: Customer) => ({ value: c.id, label: `${c.customerNumber} - ${c.name}`, }))} /> {/* Invoice Detail Drawer */} Faktura {selectedInvoice.invoiceNumber} {statusLabels[selectedInvoice.status]} ) } placement="right" width={700} open={isDrawerOpen} onClose={() => { setIsDrawerOpen(false); setSelectedInvoice(null); }} extra={ selectedInvoice && ( {selectedInvoice.status === 'draft' && ( <> )} {['sent', 'partially_paid'].includes(selectedInvoice.status) && ( <> )} ) } > {selectedInvoice && (
{selectedInvoice.customerName} {selectedInvoice.invoiceDate ? formatDate(selectedInvoice.invoiceDate) : '-'} {selectedInvoice.dueDate ? formatDate(selectedInvoice.dueDate) : '-'} {selectedInvoice.reference && ( {selectedInvoice.reference} )} Linjer {selectedInvoice.lines.length > 0 ? ( ( } onClick={() => handleEditLine(line)} />, handleRemoveLine(line.lineNumber)} okText="Ja" cancelText="Nej" >
)} {/* Add/Edit Line Modal */} { setIsLineModalOpen(false); setEditingLine(null); lineForm.resetFields(); }} onOk={handleSubmitLine} okText="Gem" cancelText="Annuller" confirmLoading={addInvoiceLineMutation.isPending || updateInvoiceLineMutation.isPending} >
{/* Product selector - copies values to form fields when selected */}
{/* Payment Modal */} setIsPaymentModalOpen(false)} onOk={handleSubmitPayment} okText="Registrer" cancelText="Annuller" confirmLoading={receivePaymentMutation.isPending} >
`${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')} parser={(value) => value!.replace(/\./g, '') as unknown as number} addonAfter="DKK" />
{/* Void Modal */} setIsVoidModalOpen(false)} onOk={handleSubmitVoid} okText="Annuller faktura" okButtonProps={{ danger: true }} cancelText="Fortryd" confirmLoading={voidInvoiceMutation.isPending} >
); }