books/frontend/src/pages/Kreditnotaer.tsx

944 lines
30 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react';
import {
Typography,
Button,
Card,
Table,
Space,
Tag,
Modal,
Form,
Input,
Select,
InputNumber,
Audit v2: fix security, data integrity, compliance, bugs, encoding, UX Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:18:19 +01:00
Skeleton,
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,
FileTextOutlined,
LinkOutlined,
} from '@ant-design/icons';
import { useSearchParams, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import type { ColumnsType } from 'antd/es/table';
import { useCompany } from '@/hooks/useCompany';
import { useCurrentFiscalYear } from '@/stores/periodStore';
import {
useCreditNotes,
useInvoices,
type Invoice,
type InvoiceLine,
type InvoiceStatus,
} from '@/api/queries/invoiceQueries';
import { useActiveCustomers, type Customer } from '@/api/queries/customerQueries';
import {
useCreateCreditNote,
useIssueCreditNote,
useApplyCreditNote,
useAddInvoiceLine,
useUpdateInvoiceLine,
useRemoveInvoiceLine,
useVoidInvoice,
type CreateCreditNoteInput,
type AddInvoiceLineInput,
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';
Audit v2: fix security, data integrity, compliance, bugs, encoding, UX Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:18:19 +01:00
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
// Credit note statuses (using unified Invoice model)
const statusColors: Record<string, string> = {
draft: 'default',
issued: 'processing',
partially_applied: 'warning',
fully_applied: 'success',
voided: 'error',
// Also include invoice statuses that might appear
sent: 'processing',
partially_paid: 'warning',
paid: 'success',
};
const statusLabels: Record<string, string> = {
draft: 'Kladde',
issued: 'Udstedt',
partially_applied: 'Delvist anvendt',
fully_applied: 'Fuldt anvendt',
voided: 'Annulleret',
sent: 'Sendt',
partially_paid: 'Delvist betalt',
paid: 'Betalt',
};
export default function Kreditnotaer() {
const navigate = useNavigate();
const { company } = useCompany();
const currentFiscalYear = useCurrentFiscalYear();
const [searchParams] = useSearchParams();
const customerIdFilter = searchParams.get('customer');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isLineModalOpen, setIsLineModalOpen] = useState(false);
const [isApplyModalOpen, setIsApplyModalOpen] = useState(false);
const [isVoidModalOpen, setIsVoidModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedCreditNote, setSelectedCreditNote] = 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 [applyForm] = Form.useForm();
const [voidForm] = Form.useForm();
// Fetch credit notes
const {
data: creditNotes = [],
isLoading: loading,
error,
refetch,
} = useCreditNotes(company?.id);
// Fetch customers for dropdown
const { data: customers = [] } = useActiveCustomers(company?.id);
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
// Fetch invoices for applying credit notes and for original invoice selector
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
});
const openInvoices: Invoice[] = allInvoices.filter(
(i: Invoice) => ['sent', 'partially_paid'].includes(i.status) && i.amountRemaining > 0
);
// Mutations
const createCreditNoteMutation = useCreateCreditNote();
const addLineMutation = useAddInvoiceLine();
const updateLineMutation = useUpdateInvoiceLine();
const removeLineMutation = useRemoveInvoiceLine();
const issueCreditNoteMutation = useIssueCreditNote();
const applyCreditNoteMutation = useApplyCreditNote();
const voidMutation = useVoidInvoice();
// Filter credit notes
const filteredCreditNotes = useMemo(() => {
return creditNotes.filter((cn) => {
const matchesSearch =
searchText === '' ||
cn.invoiceNumber.toLowerCase().includes(searchText.toLowerCase()) ||
cn.customerName.toLowerCase().includes(searchText.toLowerCase());
const matchesStatus = statusFilter === 'all' || cn.status === statusFilter;
const matchesCustomer = !customerIdFilter || cn.customerId === customerIdFilter;
return matchesSearch && matchesStatus && matchesCustomer;
});
}, [creditNotes, searchText, statusFilter, customerIdFilter]);
// Statistics
const stats = useMemo(() => {
const total = creditNotes.length;
const draft = creditNotes.filter((cn) => cn.status === 'draft').length;
const unapplied = creditNotes
.filter((cn) => ['issued', 'partially_applied'].includes(cn.status))
.reduce((sum, cn) => sum + cn.amountRemaining, 0);
const totalValue = creditNotes
.filter((cn) => cn.status !== 'voided')
.reduce((sum, cn) => sum + cn.amountTotal, 0);
return { total, draft, unapplied, totalValue };
}, [creditNotes]);
const handleCreateCreditNote = () => {
createForm.resetFields();
createForm.setFieldsValue({
creditNoteDate: 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: CreateCreditNoteInput = {
companyId: company.id,
fiscalYearId: currentFiscalYear.id,
customerId: values.customerId,
creditNoteDate: values.creditNoteDate?.toISOString(),
originalInvoiceId: values.originalInvoiceId || undefined,
creditReason: values.reason || undefined,
};
const result = await createCreditNoteMutation.mutateAsync(input);
showSuccess('Kreditnota oprettet');
setIsCreateModalOpen(false);
createForm.resetFields();
setSelectedCreditNote(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,
vatCode: line.vatCode,
});
setIsLineModalOpen(true);
};
const handleSubmitLine = async () => {
if (!selectedCreditNote) return;
try {
const values = await lineForm.validateFields();
if (editingLine) {
const result = await updateLineMutation.mutateAsync({
invoiceId: selectedCreditNote.id,
lineNumber: editingLine.lineNumber,
description: values.description,
quantity: values.quantity,
unitPrice: values.unitPrice,
vatCode: values.vatCode,
});
showSuccess('Linje opdateret');
setSelectedCreditNote(result);
} else {
const input: AddInvoiceLineInput = {
invoiceId: selectedCreditNote.id,
description: values.description,
quantity: values.quantity,
unitPrice: values.unitPrice,
vatCode: values.vatCode,
};
const result = await addLineMutation.mutateAsync(input);
showSuccess('Linje tilføjet');
setSelectedCreditNote(result);
}
setIsLineModalOpen(false);
setEditingLine(null);
lineForm.resetFields();
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleRemoveLine = async (lineNumber: number) => {
if (!selectedCreditNote) return;
try {
const result = await removeLineMutation.mutateAsync({
invoiceId: selectedCreditNote.id,
lineNumber,
});
showSuccess('Linje fjernet');
setSelectedCreditNote(result);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleIssueCreditNote = async () => {
if (!selectedCreditNote) return;
if (selectedCreditNote.lines.length === 0) {
showWarning('Tilføj mindst én linje før udstedelse');
return;
}
try {
const result = await issueCreditNoteMutation.mutateAsync(selectedCreditNote.id);
showSuccess('Kreditnota udstedt og bogført');
setSelectedCreditNote(result);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleApplyCreditNote = () => {
applyForm.resetFields();
applyForm.setFieldsValue({
amount: selectedCreditNote?.amountRemaining,
});
setIsApplyModalOpen(true);
};
const handleSubmitApply = async () => {
if (!selectedCreditNote) return;
try {
const values = await applyForm.validateFields();
const result = await applyCreditNoteMutation.mutateAsync({
creditNoteId: selectedCreditNote.id,
invoiceId: values.invoiceId,
amount: values.amount,
});
showSuccess('Kreditnota anvendt på faktura');
setIsApplyModalOpen(false);
applyForm.resetFields();
setSelectedCreditNote(result);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleVoidCreditNote = () => {
voidForm.resetFields();
setIsVoidModalOpen(true);
};
const handleSubmitVoid = async () => {
if (!selectedCreditNote) return;
try {
const values = await voidForm.validateFields();
const input: VoidInvoiceInput = {
invoiceId: selectedCreditNote.id,
reason: values.reason,
};
const result = await voidMutation.mutateAsync(input);
showSuccess('Kreditnota annulleret');
setIsVoidModalOpen(false);
voidForm.resetFields();
setSelectedCreditNote(result);
} catch (err) {
if (err instanceof Error) {
showError(err);
}
}
};
const handleViewCreditNote = (creditNote: Invoice) => {
setSelectedCreditNote(creditNote);
setIsDrawerOpen(true);
};
const columns: ColumnsType<Invoice> = [
{
title: 'Kreditnotanr.',
dataIndex: 'invoiceNumber',
key: 'invoiceNumber',
width: 130,
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: '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: 'Orig. faktura',
dataIndex: 'originalInvoiceNumber',
key: 'originalInvoiceNumber',
width: 120,
render: (value: string | undefined) => (value ? <Text code>{value}</Text> : '-'),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 130,
align: 'center',
filters: [
{ text: 'Kladde', value: 'draft' },
{ text: 'Udstedt', value: 'issued' },
{ text: 'Delvist anvendt', value: 'partially_applied' },
{ text: 'Fuldt anvendt', value: 'fully_applied' },
{ 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={() => handleViewCreditNote(record)}
/>
),
},
];
return (
<div>
Audit v2: fix security, data integrity, compliance, bugs, encoding, UX Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:18:19 +01:00
<PageHeader
title="Kreditnotaer"
subtitle={company?.name}
breadcrumbs={[{ title: 'Salg' }, { title: 'Kreditnotaer' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateCreditNote}>
Ny kreditnota
</Button>
}
/>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlæsning af kreditnotaer"
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="Kreditnotaer 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="Ikke anvendt"
value={stats.unapplied}
precision={2}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Samlet værdi"
value={stats.totalValue}
precision={2}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
</Row>
{/* Filters */}
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Søg kreditnota..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 250 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 170 }}
options={[
{ value: 'all', label: 'Alle status' },
{ value: 'draft', label: 'Kladde' },
{ value: 'issued', label: 'Udstedt' },
{ value: 'partially_applied', label: 'Delvist anvendt' },
{ value: 'fully_applied', label: 'Fuldt anvendt' },
{ value: 'voided', label: 'Annulleret' },
]}
/>
{customerIdFilter && (
<Tag closable onClose={() => navigate('/kreditnotaer')}>
Filtreret kunde
</Tag>
)}
</Space>
</Card>
{/* Credit Note Table */}
<Card size="small">
{loading ? (
Audit v2: fix security, data integrity, compliance, bugs, encoding, UX Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:18:19 +01:00
<Skeleton active paragraph={{ rows: 8 }} />
) : filteredCreditNotes.length > 0 ? (
<Table
dataSource={filteredCreditNotes}
columns={columns}
rowKey="id"
size="small"
pagination={{ pageSize: 20, showSizeChanger: true }}
/>
) : (
<EmptyState
variant="creditNotes"
title="Ingen kreditnotaer"
description={
searchText
? 'Ingen kreditnotaer matcher din søgning'
: 'Opret din første kreditnota'
}
primaryAction={
!searchText
? {
label: 'Opret kreditnota',
onClick: handleCreateCreditNote,
icon: <PlusOutlined />,
}
: undefined
}
/>
)}
</Card>
{/* Create Credit Note Modal */}
<Modal
title="Opret kreditnota"
open={isCreateModalOpen}
onCancel={() => setIsCreateModalOpen(false)}
onOk={handleSubmitCreate}
okText="Opret"
cancelText="Annuller"
confirmLoading={createCreditNoteMutation.isPending}
>
<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="creditNoteDate" label="Kreditnotadato">
<DatePicker style={{ width: '100%' }} format="DD-MM-YYYY" />
</Form.Item>
<Form.Item name="originalInvoiceId" label="Original faktura (valgfri)">
<Select
showSearch
allowClear
placeholder="Vælg faktura der krediteres"
optionFilterProp="children"
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={allInvoices
.filter((i: Invoice) => !i.isCreditNote && i.status !== 'voided')
.map((i: Invoice) => ({
value: i.id,
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountTotal)})`,
}))}
/>
</Form.Item>
<Form.Item name="reason" label="Årsag">
<Input.TextArea rows={2} placeholder="Årsag til kreditering" />
</Form.Item>
</Form>
</Modal>
{/* Credit Note Detail Drawer */}
<Drawer
title={
selectedCreditNote && (
<Space>
<FileTextOutlined />
<span>Kreditnota {selectedCreditNote.invoiceNumber}</span>
<Tag color={statusColors[selectedCreditNote.status] || 'default'}>
{statusLabels[selectedCreditNote.status] || selectedCreditNote.status}
</Tag>
</Space>
)
}
placement="right"
width={700}
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedCreditNote(null);
}}
extra={
selectedCreditNote && (
<Space>
{selectedCreditNote.status === 'draft' && (
<>
<Button icon={<PlusOutlined />} onClick={handleAddLine}>
Tilføj linje
</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleIssueCreditNote}
loading={issueCreditNoteMutation.isPending}
disabled={selectedCreditNote.lines.length === 0}
>
Udsted
</Button>
</>
)}
{['issued', 'partially_applied'].includes(selectedCreditNote.status) && (
<>
<Button icon={<LinkOutlined />} onClick={handleApplyCreditNote}>
Anvend faktura
</Button>
<Button danger icon={<StopOutlined />} onClick={handleVoidCreditNote}>
Annuller
</Button>
</>
)}
</Space>
)
}
>
{selectedCreditNote && (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: spacing.lg }}>
<Descriptions.Item label="Kunde" span={2}>
{selectedCreditNote.customerName}
</Descriptions.Item>
<Descriptions.Item label="Kreditnotadato">
{selectedCreditNote.invoiceDate ? formatDate(selectedCreditNote.invoiceDate) : '-'}
</Descriptions.Item>
<Descriptions.Item label="Original faktura">
{selectedCreditNote.originalInvoiceNumber || '-'}
</Descriptions.Item>
{selectedCreditNote.creditReason && (
<Descriptions.Item label="Årsag" span={2}>
{selectedCreditNote.creditReason}
</Descriptions.Item>
)}
</Descriptions>
<Title level={5}>Linjer</Title>
{selectedCreditNote.lines.length > 0 ? (
<List
size="small"
bordered
dataSource={selectedCreditNote.lines}
renderItem={(line: InvoiceLine) => (
<List.Item
actions={
selectedCreditNote.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} x {formatCurrency(line.unitPrice)}
</span>
<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 udstede kreditnotaen."
type="info"
showIcon
/>
)}
<Divider />
<Row gutter={16}>
<Col span={12} />
<Col span={12}>
<div style={{ textAlign: 'right' }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Beløb ex. moms: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountExVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Moms: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<Text strong>Total: </Text>
<Text strong style={{ fontSize: 16, color: accountingColors.credit }}>
{formatCurrency(-selectedCreditNote.amountTotal)}
</Text>
</div>
{selectedCreditNote.amountApplied > 0 && (
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Anvendt: </Text>
<Text>{formatCurrency(-selectedCreditNote.amountApplied)}</Text>
</div>
)}
{selectedCreditNote.amountRemaining > 0 &&
selectedCreditNote.status !== 'voided' && (
<div>
<Text type="secondary">Restbeløb: </Text>
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(-selectedCreditNote.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={addLineMutation.isPending || updateLineMutation.isPending}
>
<Form form={lineForm} layout="vertical">
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true, message: 'Indtast beskrivelse' }]}
>
<Input placeholder="Vare eller ydelse der krediteres" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<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={12}>
<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>
<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>
</Form>
</Modal>
{/* Apply Credit Note Modal */}
<Modal
title="Anvend kreditnota på faktura"
open={isApplyModalOpen}
onCancel={() => setIsApplyModalOpen(false)}
onOk={handleSubmitApply}
okText="Anvend"
cancelText="Annuller"
confirmLoading={applyCreditNoteMutation.isPending}
>
<Form form={applyForm} layout="vertical">
<Form.Item
name="invoiceId"
label="Faktura"
rules={[{ required: true, message: 'Vælg faktura' }]}
>
<Select
showSearch
placeholder="Vælg faktura"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={openInvoices.map((i) => ({
value: i.id,
label: `${i.invoiceNumber} - ${i.customerName} (${formatCurrency(i.amountRemaining)})`,
}))}
/>
</Form.Item>
<Form.Item
name="amount"
label="Beløb"
rules={[{ required: true, message: 'Indtast beløb' }]}
>
<InputNumber
min={0.01}
max={selectedCreditNote?.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>
</Modal>
{/* Void Modal */}
<Modal
title="Annuller kreditnota"
open={isVoidModalOpen}
onCancel={() => setIsVoidModalOpen(false)}
onOk={handleSubmitVoid}
okText="Annuller kreditnota"
okButtonProps={{ danger: true }}
cancelText="Fortryd"
confirmLoading={voidMutation.isPending}
>
<Alert
message="Advarsel"
description="At annullere kreditnotaen 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 kreditnotaen annulleres" />
</Form.Item>
</Form>
</Modal>
</div>
);
}