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>
This commit is contained in:
Nicolaj Hartmann 2026-02-05 21:35:26 +01:00
parent effb06fc44
commit 8e05171b66
49 changed files with 1537 additions and 1192 deletions

View file

@ -1,5 +1,6 @@
import { GraphQLClient } from 'graphql-request';
import { QueryClient } from '@tanstack/react-query';
import { useCompanyStore } from '@/stores/companyStore';
// GraphQL endpoint - configure based on environment
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
@ -26,8 +27,8 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: true,
},
mutations: {
// Retry mutations once
retry: 1,
// Never retry mutations - non-idempotent operations could create duplicates
retry: 0,
},
},
});
@ -38,7 +39,16 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
variables?: TVariables
): Promise<TData> {
try {
const data = await graphqlClient.request<TData>(query, variables);
// Get active company from store (outside React)
const activeCompany = useCompanyStore.getState().activeCompany;
// Build headers with company ID if available
const headers: Record<string, string> = {};
if (activeCompany?.id) {
headers['X-Company-Id'] = activeCompany.id;
}
const data = await graphqlClient.request<TData>(query, variables, headers);
return data;
} catch (error) {
// Log error for debugging

View file

@ -126,7 +126,7 @@ export async function processDocument(
throw new DocumentProcessingApiError('FILE_TOO_LARGE', 'Filen er for stor (maks 10MB)');
}
if (response.status === 503) {
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgaengelig');
throw new DocumentProcessingApiError('AI_UNAVAILABLE', 'AI-tjenesten er midlertidigt utilgængelig');
}
throw new DocumentProcessingApiError('UNKNOWN_ERROR', `Serverfejl: ${response.status}`);
}

View file

@ -102,6 +102,7 @@ function transformAccount(acc: AccountResponse): Account {
description: acc.description,
vatCode: acc.vatCodeId,
isActive: acc.isActive,
isSystemAccount: acc.isSystemAccount,
balance: 0, // Not returned from backend yet
createdAt: acc.createdAt,
updatedAt: acc.updatedAt,

View file

@ -69,7 +69,7 @@ export default function CompanyGuard({ children }: CompanyGuardProps) {
}
// Note: Users with existing companies CAN access the wizard to create more
}
}, [companies, isLoading, navigate, location.pathname]);
}, [companies, isLoading, navigate]); // Note: location.pathname intentionally omitted to prevent infinite loop
// Reset navigation ref when companies change (user created a company)
useEffect(() => {

View file

@ -108,7 +108,7 @@ export function DocumentUploadModal({
message.success('Bogfoert!');
onConfirm();
} catch (err) {
message.error('Kunne ikke bogfoere. Proev igen.');
message.error('Kunne ikke bogføre. Prøv igen.');
} finally {
setIsPosting(false);
}
@ -270,7 +270,7 @@ export function DocumentUploadModal({
Annuller
</Button>,
<Button key="draft" onClick={handleSaveAsDraft}>
Tilfoej til kladde
Tilføj til kladde
</Button>,
<Button
key="post"
@ -451,7 +451,7 @@ function ExtractedInfoSection({
render: (val?: number) => (val != null ? formatCurrency(val) : '-'),
},
{
title: 'Beloeb',
title: 'Beløb',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
@ -526,7 +526,7 @@ function ExtractedInfoSection({
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{extraction.amountExVat != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">Beloeb ekskl. moms</Text>
<Text type="secondary">Beløb ekskl. moms</Text>
<AmountText amount={extraction.amountExVat} />
</div>
)}
@ -551,7 +551,7 @@ function ExtractedInfoSection({
extraction.amountExVat != null || extraction.vatAmount != null ? 4 : 0,
}}
>
<Text strong>Beloeb inkl. moms</Text>
<Text strong>Beløb inkl. moms</Text>
<Text
strong
style={{

View file

@ -30,7 +30,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
open: {
color: 'success',
icon: <CheckCircleOutlined />,
label: 'Aben',
label: 'Åben',
},
closed: {
color: 'warning',
@ -40,7 +40,7 @@ const STATUS_CONFIG: Record<FiscalYear['status'], {
locked: {
color: 'error',
icon: <LockOutlined />,
label: 'Last',
label: 'Låst',
},
};
@ -84,16 +84,19 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
if (fiscalYearsData.length > 0) {
setFiscalYears(fiscalYearsData);
// Get current value without adding to dependencies to avoid infinite loop
const current = usePeriodStore.getState().currentFiscalYear;
// Validate currentFiscalYear belongs to this company's data
const isValid = currentFiscalYear &&
fiscalYearsData.some(fy => fy.id === currentFiscalYear.id);
const isValid = current &&
fiscalYearsData.some(fy => fy.id === current.id);
if (!isValid) {
const openYear = fiscalYearsData.find(y => y.status === 'open');
setCurrentFiscalYear(openYear || fiscalYearsData[0]);
}
}
}, [fiscalYearsData, currentFiscalYear, setFiscalYears, setCurrentFiscalYear]);
}, [fiscalYearsData, setFiscalYears, setCurrentFiscalYear]);
const handleFiscalYearChange = (yearId: string) => {
const year = fiscalYears.find((y) => y.id === yearId);
@ -146,7 +149,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
style={{ minWidth: 200 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
dropdownRender={(menu) => (
popupRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
@ -157,7 +160,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
onClick={handleCreateNew}
size="small"
>
Opret nyt regnskabsar
Opret nyt regnskabsår
</Button>
<Button
type="text"
@ -172,7 +175,7 @@ export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYear
)}
options={sortedYears.map((year) => ({
value: year.id,
label: `Regnskabsar ${year.name}`,
label: `Regnskabsår ${year.name}`,
year,
}))}
optionRender={(option) => {

View file

@ -125,31 +125,27 @@ export default function Header({ isMobile = false }: HeaderProps) {
)}
{/* Help */}
<Button
type="text"
icon={<QuestionCircleOutlined />}
aria-label="Hjælp"
title="Hjælp"
/>
{/* Notifications */}
<Button
type="text"
icon={<BellOutlined />}
aria-label="Notifikationer"
title="Notifikationer"
/>
{/* Logout */}
<Tooltip title="Log ud">
<Tooltip title="Hjælp">
<Button
type="text"
icon={<LogoutOutlined />}
onClick={logout}
aria-label="Log ud"
icon={<QuestionCircleOutlined />}
onClick={() => window.open('https://help.books.dk', '_blank')}
aria-label="Hjælp"
/>
</Tooltip>
{/* Notifications */}
<Tooltip title="Notifikationer">
<Badge count={0} size="small">
<Button
type="text"
icon={<BellOutlined />}
onClick={() => navigate('/indstillinger')}
aria-label="Notifikationer"
/>
</Badge>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{

View file

@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { Layout, Menu } from 'antd';
import {
DashboardOutlined,
@ -42,7 +43,7 @@ function getItem(
const menuItems: MenuItem[] = [
getItem('Dashboard', '/', <DashboardOutlined />),
getItem('Bogfoering', 'accounting', <BookOutlined />, [
getItem('Bogføring', 'accounting', <BookOutlined />, [
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
]),
@ -61,7 +62,7 @@ const menuItems: MenuItem[] = [
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
getItem('Lønforståelse', '/loenforstaelse', <TeamOutlined />),
getItem('Eksport', '/eksport', <ExportOutlined />),
]),
@ -99,6 +100,17 @@ interface SidebarMenuProps {
export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
const navigate = useNavigate();
const location = useLocation();
const [openKeys, setOpenKeys] = useState<string[]>(getOpenKeys(location.pathname));
// Update openKeys when location changes
useEffect(() => {
const newOpenKeys = getOpenKeys(location.pathname);
setOpenKeys((prev) => {
// Merge: keep existing open keys but ensure the current path's group is open
const merged = [...new Set([...prev, ...newOpenKeys])];
return merged;
});
}, [location.pathname]);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key.startsWith('/')) {
@ -107,6 +119,10 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
}
};
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
const selectedKeys = [location.pathname];
return (
@ -114,7 +130,8 @@ export function SidebarMenu({ onNavigate }: SidebarMenuProps) {
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={getOpenKeys(location.pathname)}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}

View file

@ -38,6 +38,8 @@ import {
import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
import { useCloseFiscalYear } from '@/api/mutations/fiscalYearMutations';
import { message } from 'antd';
const { Text, Title, Paragraph } = Typography;
@ -82,6 +84,8 @@ export default function CloseFiscalYearWizard({
lockPeriod,
} = usePeriodStore();
const closeFiscalYearMutation = useCloseFiscalYear();
// Reset wizard when opened
useEffect(() => {
if (open) {
@ -160,27 +164,41 @@ export default function CloseFiscalYearWizard({
setIsSubmitting(true);
try {
// 1. Close open periods if requested
// TODO: CRITICAL ACCOUNTING ISSUE - The closing entries preview is calculated
// in generateClosingEntries() but never actually posted to the ledger.
// Before closing the fiscal year, these closing entries MUST be posted:
// 1. Revenue accounts should be zeroed out to the result account
// 2. Expense accounts should be zeroed out to the result account
// 3. The net result should be transferred to the equity account (resultAccountId)
// Without posting these entries, the opening balances for the next year will be incorrect.
// 1. Close open periods if requested (local store)
if (closeOpenPeriods) {
for (const period of openPeriodsInYear) {
closePeriod(period.id, 'system');
}
}
// 2. Lock all periods in the year
// 2. Lock all periods in the year (local store)
for (const period of yearPeriods) {
lockPeriod(period.id, 'system');
}
// 3. Close and lock the fiscal year
// 3. Call backend mutation to close the fiscal year
await closeFiscalYearMutation.mutateAsync(fiscalYear.id);
// 4. Also update local store
closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system');
// 4. Move to complete step
// 5. Move to complete step
setCurrentStep('complete');
onSuccess?.();
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved arsafslutning: ${error.message}`);
}
console.error('Failed to close fiscal year:', error);
} finally {
setIsSubmitting(false);

View file

@ -27,6 +27,8 @@ import {
} from '@/lib/fiscalYear';
import { generateAccountingPeriods } from '@/lib/periods';
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
import { useCreateFiscalYear } from '@/api/mutations/fiscalYearMutations';
import { message } from 'antd';
const { Text } = Typography;
const { RangePicker } = DatePicker;
@ -58,6 +60,7 @@ export default function CreateFiscalYearModal({
const { activeCompany } = useCompanyStore();
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
const createFiscalYearMutation = useCreateFiscalYear();
// Calculate suggested fiscal year boundaries
useEffect(() => {
@ -114,20 +117,18 @@ export default function CreateFiscalYearModal({
const startDate = values.dateRange[0].format('YYYY-MM-DD');
const endDate = values.dateRange[1].format('YYYY-MM-DD');
// Create fiscal year object
const newFiscalYear: FiscalYear = {
id: `fy-${values.name}-${Date.now()}`,
// Call backend mutation - let the backend generate the ID
const newFiscalYear = await createFiscalYearMutation.mutateAsync({
companyId: activeCompany.id,
name: values.name,
startDate,
endDate,
status: 'open',
openingBalancePosted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
// Generate accounting periods and add required fields
// Also update local store as a cache layer
addFiscalYear(newFiscalYear);
// Generate accounting periods locally for the UI
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
const now = new Date().toISOString();
const newPeriods = generatedPeriods.map((p, idx) => ({
@ -136,9 +137,6 @@ export default function CreateFiscalYearModal({
createdAt: now,
updatedAt: now,
}));
// Add to store
addFiscalYear(newFiscalYear);
setPeriods([...periods, ...newPeriods]);
// Set as current if this is the first or most recent
@ -153,6 +151,9 @@ export default function CreateFiscalYearModal({
onSuccess?.(newFiscalYear);
onClose();
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved oprettelse: ${error.message}`);
}
console.error('Failed to create fiscal year:', error);
} finally {
setIsSubmitting(false);
@ -180,7 +181,7 @@ export default function CreateFiscalYearModal({
loading: isSubmitting,
}}
width={520}
destroyOnClose
destroyOnHidden
>
{!activeCompany && (
<Alert

View file

@ -289,7 +289,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
lines.push({
accountId: `vat-input-${vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
accountName: 'Indgående moms',
description: `Moms: ${description}`,
debit: vatAmount,
credit: 0,
@ -347,7 +347,7 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
lines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
accountName: 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
@ -431,7 +431,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
generatedLines.push({
accountId: `vat-input-${splitLine.vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
accountName: 'Indgående moms',
description: `Moms: ${description}`,
debit: lineVat,
credit: 0,
@ -506,7 +506,7 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
generatedLines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
accountName: 'Udgående moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,

View file

@ -16,7 +16,7 @@ import type { VATPeriodicitet } from '@/types/periods';
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
S25: {
code: 'S25',
nameDanish: 'Udgaaende moms 25%',
nameDanish: 'Udgående moms 25%',
nameEnglish: 'Output VAT 25%',
rate: 0.25,
type: 'output',
@ -30,7 +30,7 @@ export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
},
K25: {
code: 'K25',
nameDanish: 'Indgaaende moms 25%',
nameDanish: 'Indgående moms 25%',
nameEnglish: 'Input VAT 25%',
rate: 0.25,
type: 'input',
@ -230,8 +230,8 @@ export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
* Default VAT accounts for automatic double-entry
*/
export const VAT_ACCOUNTS = {
inputVAT: '5610', // Indgaaende moms (fradrag)
outputVAT: '5710', // Udgaaende moms (skyld)
inputVAT: '5610', // Indgående moms (fradrag)
outputVAT: '5710', // Udgående moms (skyld)
euVAT: '5620', // EU-moms (erhvervelsesmoms)
} as const;

View file

@ -20,14 +20,13 @@ import {
DashboardOutlined,
} from '@ant-design/icons';
import { useUser } from '@/stores/authStore';
import { useCanAdmin } from '@/stores/companyStore';
import { useMutation, useQuery } from '@tanstack/react-query';
import { graphqlClient } from '@/api/client';
import { gql } from 'graphql-request';
const { Title, Text, Paragraph } = Typography;
// Admin email that has access
const ADMIN_EMAIL = 'nhh@softwarehuset.com';
// Derive backend base URL from GraphQL endpoint
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
@ -59,8 +58,8 @@ export default function Admin() {
const [form] = Form.useForm();
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
// Check if user is admin
const isAdmin = user?.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase();
// Check if user has Owner role for the active company
const isAdmin = useCanAdmin();
// Fetch available read model types
const { data: readModelTypes, isLoading: typesLoading } = useQuery({

View file

@ -38,8 +38,10 @@ import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { BankTransaction } from '@/types/accounting';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Type for ledger entries (API not implemented yet)
@ -160,7 +162,7 @@ export default function Bankafstemning() {
ledgerTransactionId: ledgerEntry.id,
matchType: 'existing',
});
message.success('Match tilfojet');
message.success('Match tilføjet');
}
};
@ -177,8 +179,9 @@ export default function Bankafstemning() {
const handleSubmitCreate = async () => {
try {
const values = await form.validateFields();
console.log('Creating entry:', values);
// TODO: Backend mutation for creating a journal entry from bank transaction is needed.
// This should create a JournalEntryDraft and then post it, linking it to the bank transaction.
if (selectedBankTx) {
addPendingMatch({
bankTransactionId: selectedBankTx.id,
@ -190,11 +193,13 @@ export default function Bankafstemning() {
});
}
message.success('Postering oprettet og matchet');
message.success('Postering tilfojet til afventende matches');
setIsCreateModalOpen(false);
setSelectedBankTx(null);
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
@ -203,9 +208,10 @@ export default function Bankafstemning() {
message.warning('Ingen matches at gemme');
return;
}
// TODO: Send to GraphQL mutation
console.log('Saving matches:', pendingMatches);
message.success(`${pendingMatches.length} afstemninger gemt`);
// TODO: Backend mutation for saving reconciliation matches is not yet implemented.
// The mutation should accept a list of bank transaction IDs matched to ledger entries,
// mark them as reconciled, and create journal entries for new transactions.
message.info('Denne funktion er under udvikling. Afstemninger kan endnu ikke gemmes til backend.');
};
const handleApplySuggestion = (suggestion: MatchSuggestion) => {
@ -241,42 +247,35 @@ export default function Bankafstemning() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Bankafstemning
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button
icon={<UndoOutlined />}
onClick={clearAllSelections}
disabled={
selectedBankTransactions.length === 0 &&
selectedLedgerTransactions.length === 0
}
>
Nulstil valg
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleSaveAll}
disabled={pendingMatches.length === 0}
>
Gem afstemninger ({pendingMatches.length})
</Button>
</Space>
</div>
<PageHeader
title="Bankafstemning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Bank', path: '/bankafstemning' }, { title: 'Bankafstemning' }]}
extra={
<Space>
<Button
icon={<UndoOutlined />}
onClick={clearAllSelections}
disabled={
selectedBankTransactions.length === 0 &&
selectedLedgerTransactions.length === 0
}
>
Nulstil valg
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleSaveAll}
disabled={pendingMatches.length === 0}
>
Gem afstemninger ({pendingMatches.length})
</Button>
</Space>
}
/>
<DemoDataDisclaimer message="Bankafstemning er delvist implementeret. Gem-funktionen er under udvikling." />
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
@ -314,7 +313,7 @@ export default function Bankafstemning() {
<Col span={8}>
<Card size="small">
<Statistic
title="Bogforing (uafstemt)"
title="Bogføring (uafstemt)"
value={ledgerTotal}
precision={2}
formatter={(value) => formatCurrency(value as number)}
@ -374,7 +373,7 @@ export default function Bankafstemning() {
disabled={!canMatch}
>
Match valgte ({selectedBankTransactions.length} bank,{' '}
{selectedLedgerTransactions.length} bogforing)
{selectedLedgerTransactions.length} bogføring)
</Button>
</div>
@ -496,7 +495,7 @@ export default function Bankafstemning() {
<Card
title={
<Space>
<Text strong>Bogforingsposter</Text>
<Text strong>Bogføringsposter</Text>
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
</Space>
}
@ -505,7 +504,7 @@ export default function Bankafstemning() {
>
{ledgerEntries.length === 0 ? (
<Empty
description="Ingen uafstemte bogforingsposter (API ikke implementeret endnu)"
description="Ingen uafstemte bogføringsposter (API ikke implementeret endnu)"
style={{ padding: 24 }}
/>
) : (
@ -674,22 +673,22 @@ export default function Bankafstemning() {
rules={[{ required: true }]}
>
<Select
placeholder="Vaelg konto"
placeholder="Vælg konto"
options={[
{ value: '6100', label: '6100 - Husleje' },
{ value: '6800', label: '6800 - Kontorartikler' },
{ value: '5000', label: '5000 - Varekob' },
{ value: '5000', label: '5000 - Varekøb' },
{ value: '4000', label: '4000 - Salg' },
]}
/>
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vaelg momskode"
placeholder="Vælg momskode"
allowClear
options={[
{ value: 'K25', label: 'K25 - Indgaaende moms 25%' },
{ value: 'S25', label: 'S25 - Udgaaende moms 25%' },
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
{ value: 'NONE', label: 'Ingen moms' },
]}
/>

View file

@ -1,8 +1,6 @@
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
import {
BankOutlined,
RiseOutlined,
FallOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
@ -18,8 +16,10 @@ import { useInvoices } from '@/api/queries/invoiceQueries';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
// Types for chart data
interface CashFlowDataPoint {
@ -47,9 +47,13 @@ export default function Dashboard() {
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
// Define date interval
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
const periodStart = currentFiscalYear?.startDate
? dayjs(currentFiscalYear.startDate).format('YYYY-MM-DD')
: dayjs().startOf('year').format('YYYY-MM-DD');
const periodEnd = currentFiscalYear?.endDate
? dayjs(currentFiscalYear.endDate).format('YYYY-MM-DD')
: dayjs().endOf('year').format('YYYY-MM-DD');
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
activeCompany?.id,
@ -207,7 +211,7 @@ export default function Dashboard() {
const revenueExpenseConfig = {
data: cashFlowData.flatMap((d) => [
{ month: d.month, type: 'Indtaegter', value: d.inflow },
{ month: d.month, type: 'Indtægter', value: d.inflow },
{ month: d.month, type: 'Udgifter', value: d.outflow },
]),
isGroup: true,
@ -225,15 +229,13 @@ export default function Dashboard() {
return (
<div>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
Dashboard
</Title>
<Text type="secondary">
{company?.name} - {formatDate(new Date().toISOString(), 'MMMM YYYY')}
</Text>
</div>
<PageHeader
title="Dashboard"
subtitle={company?.name ? `${company.name} - ${formatDate(new Date().toISOString(), 'MMMM YYYY')}` : undefined}
breadcrumbs={[{ title: 'Dashboard' }]}
/>
<DemoDataDisclaimer message="Dashboard viser beregnede data fra kontoplanen. Pengestrøms- og udgiftsgrafer er endnu ikke tilgængelige." />
{/* KPI Cards */}
<Row gutter={[16, 16]}>
@ -249,13 +251,9 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag
color={metrics.cashChange >= 0 ? 'green' : 'red'}
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
>
{metrics.cashChange >= 0 ? '+' : ''}
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
Baseret kontosaldi i regnskabsåret
</Text>
</div>
</Card>
</Col>
@ -296,10 +294,9 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
{metrics.apChange >= 0 ? '+' : ''}
{(metrics.apChange * 100).toFixed(1)}% denne maaned
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
Baseret kontosaldi i regnskabsåret
</Text>
</div>
</Card>
</Col>
@ -315,32 +312,33 @@ export default function Dashboard() {
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color="blue">Naeste frist: 1. marts</Tag>
<a href="/momsindberetning">Se momsindberetning</a>
</div>
</Card>
</Col>
</Row>
{/* Charts Row */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* Cash Flow Chart */}
<Col xs={24} lg={12}>
<Card title="Pengestroemme" size="small">
<Card title="Pengestrømme" size="small">
{cashFlowData.length > 0 ? (
<Line {...cashFlowConfig} />
) : (
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
<Empty description="Ingen pengestrømsdata tilgængelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
{/* Revenue vs Expenses */}
<Col xs={24} lg={12}>
<Card title="Indtaegter vs. Udgifter" size="small">
<Card title="Indtægter vs. Udgifter" size="small">
{cashFlowData.length > 0 ? (
<Column {...revenueExpenseConfig} />
) : (
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
<Empty description="Ingen historiske data tilgængelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
@ -354,7 +352,7 @@ export default function Dashboard() {
{expenseBreakdown.length > 0 ? (
<Pie {...expenseConfig} />
) : (
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
<Empty description="Ingen udgiftsdata tilgængelig" style={{ height: 200 }} />
)}
</Card>
</Col>
@ -440,7 +438,9 @@ export default function Dashboard() {
<Col>
<Space>
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
<Text>Momsindberetning forfalder om 14 dage</Text>
<a href="/momsindberetning">
<Text>Se momsindberetning</Text>
</a>
</Space>
</Col>
{metrics.overdueInvoices > 0 && (

View file

@ -59,6 +59,7 @@ import { formatCurrency, formatDate } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { accountingColors } from '@/styles/theme';
import { AmountText } from '@/components/shared/AmountText';
import { PageHeader } from '@/components/shared/PageHeader';
import { EmptyState } from '@/components/shared/EmptyState';
import type { ColumnsType } from 'antd/es/table';
@ -457,25 +458,16 @@ export default function Fakturaer() {
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>
<PageHeader
title="Fakturaer"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Fakturaer' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateInvoice}>
Ny fakturakladde
</Button>
}
/>
{/* Error State */}
{error && (

View file

@ -32,10 +32,13 @@ import { useCompanyStore } from '@/stores/companyStore';
import { useActiveAccounts } from '@/api/queries/accountQueries';
import { useJournalEntryDrafts } from '@/api/queries/draftQueries';
import { formatCurrency } from '@/lib/formatters';
import { PageHeader } from '@/components/shared/PageHeader';
import { validateDoubleEntry } from '@/lib/accounting';
import type { TransactionLine, JournalEntryDraft } from '@/types/accounting';
import { useCreateJournalEntryDraft, useUpdateJournalEntryDraft, useDiscardJournalEntryDraft } from '@/api/mutations/draftMutations';
import { usePeriodStore } from '@/stores/periodStore';
const { Title, Text } = Typography;
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Display type for journal entry drafts
@ -62,6 +65,13 @@ export default function Kassekladde() {
{ debit: 0, credit: 0 },
]);
const { currentFiscalYear } = usePeriodStore();
// Mutation hooks
const createDraftMutation = useCreateJournalEntryDraft();
const updateDraftMutation = useUpdateJournalEntryDraft();
const discardDraftMutation = useDiscardJournalEntryDraft();
// Fetch accounts and drafts from API
const { data: accounts = [], isLoading: accountsLoading } = useActiveAccounts(activeCompany?.id);
const { data: drafts = [], isLoading: draftsLoading } = useJournalEntryDrafts(activeCompany?.id);
@ -124,7 +134,7 @@ export default function Kassekladde() {
return <Tag color="red">Annulleret</Tag>;
}
return value ? (
<Tag color="green">Bogfort</Tag>
<Tag color="green">Bogført</Tag>
) : (
<Tag color="orange">Kladde</Tag>
);
@ -189,17 +199,56 @@ export default function Kassekladde() {
setIsModalOpen(true);
break;
case 'copy':
message.success(`Bilag ${record.transactionNumber} kopieret`);
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
break;
}
(async () => {
try {
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: `Kopi af ${record.description}`,
description: record.description,
fiscalYearId: currentFiscalYear?.id,
});
// Copy lines to the new draft
if (record.lines && record.lines.length > 0) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: record.lines.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId,
debitAmount: l.debitAmount || 0,
creditAmount: l.creditAmount || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success(`Bilag ${record.transactionNumber} kopieret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved kopiering: ${error.message}`);
}
}
})();
break;
case 'void':
Modal.confirm({
title: 'Annuller bilag',
content: `Er du sikker pa at du vil annullere bilag ${record.transactionNumber}?`,
content: `Er du sikker på at du vil annullere bilag ${record.transactionNumber}?`,
okText: 'Annuller bilag',
okType: 'danger',
cancelText: 'Fortryd',
onOk: () => {
message.success(`Bilag ${record.transactionNumber} annulleret`);
onOk: async () => {
try {
await discardDraftMutation.mutateAsync(record.id);
message.success(`Bilag ${record.transactionNumber} annulleret`);
} catch (error) {
if (error instanceof Error) {
message.error(`Fejl ved annullering: ${error.message}`);
}
}
},
});
break;
@ -238,18 +287,72 @@ export default function Kassekladde() {
const validation = validateDoubleEntry(lines as TransactionLine[]);
if (!validation.valid) {
message.error(
`Debet (${formatCurrency(validation.totalDebit)}) skal vaere lig kredit (${formatCurrency(validation.totalCredit)})`
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
);
return;
}
console.log('Submitting:', { ...values, lines });
message.success('Bilag oprettet');
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
return;
}
if (editingDraft) {
// Update existing draft
await updateDraftMutation.mutateAsync({
id: editingDraft.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
message.success('Bilag opdateret');
} else {
// Create new draft
const draft = await createDraftMutation.mutateAsync({
companyId: activeCompany.id,
name: values.description,
documentDate: values.date?.format('YYYY-MM-DD'),
description: values.description,
fiscalYearId: currentFiscalYear?.id,
});
// Update the draft with lines
if (lines.some(l => l.accountId)) {
await updateDraftMutation.mutateAsync({
id: draft.id,
lines: lines
.filter(l => l.accountId)
.map((l, idx) => ({
lineNumber: idx + 1,
accountId: l.accountId!,
debitAmount: l.debit || 0,
creditAmount: l.credit || 0,
description: l.description,
vatCode: l.vatCode,
})),
});
}
message.success('Bilag oprettet');
}
setIsModalOpen(false);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
@ -258,21 +361,11 @@ export default function Kassekladde() {
if (isLoading) {
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kassekladde
</Title>
<Text type="secondary">{activeCompany?.name}</Text>
</div>
</div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
/>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
@ -280,32 +373,23 @@ export default function Kassekladde() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kassekladde
</Title>
<Text type="secondary">{activeCompany?.name}</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingDraft(null);
setIsModalOpen(true);
}}
>
Nyt bilag
</Button>
</div>
<PageHeader
title="Kassekladde"
subtitle={activeCompany?.name}
breadcrumbs={[{ title: 'Bogføring', path: '/kontooversigt' }, { title: 'Kassekladde' }]}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingDraft(null);
setIsModalOpen(true);
}}
>
Nyt bilag
</Button>
}
/>
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
@ -329,7 +413,7 @@ export default function Kassekladde() {
style={{ width: 120 }}
allowClear
options={[
{ value: 'posted', label: 'Bogfort' },
{ value: 'posted', label: 'Bogført' },
{ value: 'draft', label: 'Kladde' },
{ value: 'discarded', label: 'Annulleret' },
]}
@ -373,7 +457,7 @@ export default function Kassekladde() {
<Form.Item
name="date"
label="Dato"
rules={[{ required: true, message: 'Vaelg dato' }]}
rules={[{ required: true, message: 'Vælg dato' }]}
initialValue={dayjs()}
>
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
@ -409,7 +493,7 @@ export default function Kassekladde() {
<td style={{ padding: 4 }}>
<Select
style={{ width: '100%' }}
placeholder="Vaelg konto"
placeholder="Vælg konto"
showSearch
optionFilterProp="label"
value={line.accountId}
@ -478,7 +562,7 @@ export default function Kassekladde() {
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
<td style={{ padding: 8 }}>
<Button type="dashed" size="small" onClick={handleAddLine}>
+ Tilfoej linje
+ Tilføj linje
</Button>
</td>
<td

View file

@ -1,46 +1,45 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Tree,
Table,
Space,
Tag,
Modal,
Drawer,
Form,
Input,
Select,
Tabs,
Statistic,
message,
Grid,
Skeleton,
Empty,
Switch,
Divider,
Descriptions,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
FolderOutlined,
FileOutlined,
SearchOutlined,
MoreOutlined,
HistoryOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useCompanyStore } from '@/stores/companyStore';
import { usePeriodStore } from '@/stores/periodStore';
import { useAccounts, useAccountBalances } from '@/api/queries/accountQueries';
import { formatCurrency } from '@/lib/formatters';
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
import { getAccountTypeName } from '@/lib/accounting';
import { accountingColors } from '@/styles/theme';
import { spacing } from '@/styles/designTokens';
import { PageHeader } from '@/components/shared/PageHeader';
import { EmptyState } from '@/components/shared/EmptyState';
import type { Account, AccountType } from '@/types/accounting';
import { useCreateAccount } from '@/api/mutations/accountMutations';
const { Text } = Typography;
const { useBreakpoint } = Grid;
const accountTypes: AccountType[] = [
'asset',
@ -54,17 +53,23 @@ const accountTypes: AccountType[] = [
'extraordinary',
];
interface AccountWithBalance extends Account {
balance: number;
}
export default function Kontooversigt() {
const navigate = useNavigate();
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
const screens = useBreakpoint();
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [selectedAccount, setSelectedAccount] = useState<AccountWithBalance | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [form] = Form.useForm();
const isMobile = !screens.md;
// Mutation hooks
const createAccountMutation = useCreateAccount();
// Fetch accounts and balances from API
const { data: accounts = [], isLoading: accountsLoading } = useAccounts(activeCompany?.id);
@ -78,161 +83,176 @@ export default function Kontooversigt() {
const isLoading = accountsLoading || balancesLoading;
// Combine accounts with balances
const accountsWithBalances = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
// Build tree data from accounts
const buildTreeData = (): DataNode[] => {
return accountTypes.map((type) => {
const range = getAccountNumberRange(type);
const typeAccounts = accountsWithBalances.filter((acc) => acc.type === type);
const typeBalance = typeAccounts.reduce((sum, acc) => sum + acc.balance, 0);
return {
key: type,
title: (
<Space>
<Text strong>{getAccountTypeName(type)}</Text>
<Text type="secondary">({range.min}-{range.max})</Text>
<Text
className="tabular-nums"
style={{
color: typeBalance >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(Math.abs(typeBalance))}
</Text>
</Space>
),
icon: <FolderOutlined />,
children: typeAccounts
.filter((acc) =>
searchText === '' ||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
acc.accountNumber.includes(searchText)
)
.map((acc) => ({
key: acc.id,
title: (
<Space>
<Text code>{acc.accountNumber}</Text>
<Text>{acc.name}</Text>
{!acc.isActive && <Tag color="red">Inaktiv</Tag>}
<Text
className="tabular-nums"
style={{
color: acc.balance >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(Math.abs(acc.balance))}
</Text>
</Space>
),
icon: <FileOutlined />,
isLeaf: true,
})),
};
// Combine accounts with balances and filter
const tableData = useMemo(() => {
const combined = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
};
const handleSelectAccount = (selectedKeys: React.Key[]) => {
const key = selectedKeys[0];
if (key && !accountTypes.includes(key as AccountType)) {
const account = accountsWithBalances.find((acc) => acc.id === key);
setSelectedAccount(account || null);
}
return combined
.filter(acc => showInactive || acc.isActive)
.filter(acc =>
searchText === '' ||
acc.name.toLowerCase().includes(searchText.toLowerCase()) ||
acc.accountNumber.includes(searchText)
)
.sort((a, b) => a.accountNumber.localeCompare(b.accountNumber));
}, [accounts, balances, searchText, showInactive]);
// Calculate totals for KPI cards
const kpiData = useMemo(() => {
const data = accounts.map(acc => {
const balance = balances.find(b => b.id === acc.id);
return { ...acc, balance: balance?.netChange ?? 0 };
});
return {
assets: data.filter(a => a.type === 'asset').reduce((sum, a) => sum + a.balance, 0),
liabilities: data.filter(a => ['liability', 'equity'].includes(a.type)).reduce((sum, a) => sum + Math.abs(a.balance), 0),
revenue: data.filter(a => a.type === 'revenue').reduce((sum, a) => sum + Math.abs(a.balance), 0),
expenses: data.filter(a => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type)).reduce((sum, a) => sum + a.balance, 0),
};
}, [accounts, balances]);
const handleRowClick = (record: AccountWithBalance) => {
setSelectedAccount(record);
setIsEditMode(false);
setIsDrawerOpen(true);
};
const handleCreateAccount = () => {
setEditingAccount(null);
setSelectedAccount(null);
form.resetFields();
setIsModalOpen(true);
setIsEditMode(true);
setIsDrawerOpen(true);
};
const handleEditAccount = (account: Account) => {
setEditingAccount(account);
form.setFieldsValue(account);
setIsModalOpen(true);
const handleEditAccount = () => {
if (selectedAccount) {
form.setFieldsValue(selectedAccount);
setIsEditMode(true);
}
};
const handleCloseDrawer = () => {
setIsDrawerOpen(false);
setIsEditMode(false);
setSelectedAccount(null);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
console.log('Submitting account:', values);
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
setIsModalOpen(false);
if (!activeCompany) {
message.error('Ingen virksomhed valgt');
return;
}
if (selectedAccount) {
// TODO: Backend does not yet have an updateAccount mutation.
// For now, show a message indicating this is not yet supported.
message.warning('Redigering af konti er endnu ikke understottet i backend');
} else {
// Create new account
await createAccountMutation.mutateAsync({
companyId: activeCompany.id,
accountNumber: values.accountNumber,
name: values.name,
accountType: values.type,
description: values.description,
vatCodeId: values.vatCode,
});
message.success('Konto oprettet');
}
handleCloseDrawer();
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl: ${error.message}`);
}
}
};
// Calculate totals from actual data
const totalAssets = accountsWithBalances
.filter((a) => a.type === 'asset')
.reduce((sum, a) => sum + a.balance, 0);
const totalLiabilities = accountsWithBalances
.filter((a) => ['liability', 'equity'].includes(a.type))
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalRevenue = accountsWithBalances
.filter((a) => a.type === 'revenue')
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalExpenses = accountsWithBalances
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
.reduce((sum, a) => sum + a.balance, 0);
if (isLoading) {
return (
<div>
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
const columns: ColumnsType<AccountWithBalance> = [
{
title: 'Nr.',
dataIndex: 'accountNumber',
key: 'accountNumber',
width: 100,
render: (text) => <Text code>{text}</Text>,
sorter: (a, b) => a.accountNumber.localeCompare(b.accountNumber),
},
{
title: 'Navn',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Text strong>{text}</Text>
{!record.isActive && <Tag color="default" bordered={false}>Inaktiv</Tag>}
</Space>
),
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 150,
filters: accountTypes.map(type => ({ text: getAccountTypeName(type), value: type })),
onFilter: (value, record) => record.type === value,
render: (type) => <Tag>{getAccountTypeName(type)}</Tag>,
},
{
title: 'Moms',
dataIndex: 'vatCode',
key: 'vatCode',
width: 100,
render: (code) => code ? <Tag color="blue">{code}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'Saldo',
dataIndex: 'balance',
key: 'balance',
align: 'right',
width: 150,
render: (value) => (
<Text
strong
style={{
color: value >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(value)}
</Text>
),
sorter: (a, b) => a.balance - b.balance,
},
{
key: 'action',
width: 50,
render: (_, record) => (
<Button
type="text"
icon={<MoreOutlined />}
onClick={(e) => {
e.stopPropagation();
handleRowClick(record);
}}
/>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
);
}
if (accounts.length === 0) {
return (
<div>
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateAccount}
aria-label="Opret ny konto"
>
Ny konto
</Button>
}
/>
<Empty description="Ingen konti fundet. Opret en ny konto for at komme i gang." />
</div>
);
}
),
},
];
return (
<div>
{/* Header */}
<PageHeader
title="Kontooversigt"
subtitle={activeCompany?.name}
breadcrumbs={[
{ title: 'Bogforing', path: '/bogforing' },
{ title: 'Bogføring', path: '/bogforing' },
{ title: 'Kontooversigt' },
]}
extra={
@ -240,280 +260,262 @@ export default function Kontooversigt() {
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateAccount}
aria-label="Opret ny konto"
>
Ny konto
</Button>
}
/>
{/* Summary Cards */}
<Row gutter={[spacing.lg, spacing.lg]} style={{ marginBottom: spacing.lg }}>
{/* KPI Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Aktiver total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
<Statistic
title="Aktiver"
value={totalAssets}
precision={2}
suffix="kr."
value={kpiData.assets}
precision={0}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Passiver total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
<Statistic
title="Passiver"
value={totalLiabilities}
precision={2}
suffix="kr."
value={kpiData.liabilities}
precision={0}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Omsaetning total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.credit}` }}>
<Statistic
title="Omsaetning"
value={totalRevenue}
precision={2}
suffix="kr."
title="Omsætning"
value={kpiData.revenue}
precision={0}
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small" role="region" aria-label="Omkostninger total">
<Card bordered={false} size="small" style={{ borderTop: `3px solid ${accountingColors.debit}` }}>
<Statistic
title="Omkostninger"
value={totalExpenses}
precision={2}
suffix="kr."
value={kpiData.expenses}
precision={0}
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
formatter={(val) => formatCurrency(val as number)}
/>
</Card>
</Col>
</Row>
{/* Main Content */}
<Row gutter={spacing.lg}>
{/* Account Tree */}
<Col xs={24} lg={10}>
<Card title="Kontoplan" size="small">
{/* Search moved outside extra for better mobile UX */}
<Input
placeholder="Sog efter konto eller kontonummer..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ marginBottom: spacing.md }}
allowClear
aria-label="Sog i kontoplan"
/>
<Tree
showIcon
defaultExpandAll
treeData={buildTreeData()}
onSelect={handleSelectAccount}
selectedKeys={selectedAccount ? [selectedAccount.id] : []}
style={{
maxHeight: isMobile ? 300 : 450,
overflow: 'auto',
}}
aria-label="Kontoplan hierarki"
/>
</Card>
</Col>
{/* Account Details */}
<Col xs={24} lg={14}>
{selectedAccount ? (
<Card
title={
<Space>
<Text code>{selectedAccount.accountNumber}</Text>
<Text strong>{selectedAccount.name}</Text>
{!selectedAccount.isActive && (
<Tag color="red">Inaktiv</Tag>
)}
</Space>
}
size="small"
extra={
<Button
icon={<EditOutlined />}
onClick={() => handleEditAccount(selectedAccount)}
aria-label={`Rediger konto ${selectedAccount.accountNumber}`}
>
Rediger
</Button>
}
role="region"
aria-label={`Detaljer for konto ${selectedAccount.accountNumber}`}
>
<Tabs
items={[
{
key: 'transactions',
label: 'Bevaegelser',
children: (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Statistic
title="Saldo"
value={selectedAccount.balance}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color:
selectedAccount.balance >= 0
? accountingColors.credit
: accountingColors.debit,
}}
/>
</div>
<Empty description="Ingen bevaegelser" />
</div>
),
},
{
key: 'info',
label: 'Kontooplysninger',
children: (
<div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Text type="secondary">Kontonummer</Text>
<div>
<Text strong>{selectedAccount.accountNumber}</Text>
</div>
</Col>
<Col span={12}>
<Text type="secondary">Kontotype</Text>
<div>
<Tag>{getAccountTypeName(selectedAccount.type)}</Tag>
</div>
</Col>
<Col span={12}>
<Text type="secondary">Status</Text>
<div>
{selectedAccount.isActive ? (
<Tag color="green">Aktiv</Tag>
) : (
<Tag color="red">Inaktiv</Tag>
)}
</div>
</Col>
<Col span={12}>
<Text type="secondary">Momskode</Text>
<div>
<Text>{selectedAccount.vatCode || 'Ingen'}</Text>
</div>
</Col>
</Row>
</div>
),
},
]}
/>
</Card>
) : (
<Card size="small">
<EmptyState
variant="accounts"
icon={<FileOutlined style={{ fontSize: 48 }} />}
title="Ingen konto valgt"
description="Vaelg en konto i kontoplanen til venstre for at se detaljer og bevaegelser."
compact
/>
</Card>
)}
</Col>
</Row>
{/* Create/Edit Account Modal */}
<Modal
title={editingAccount ? 'Rediger konto' : 'Opret konto'}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
onOk={handleSubmit}
okText="Gem"
cancelText="Annuller"
<Card
bordered={false}
bodyStyle={{ padding: 0 }}
title={
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Søg på navn eller nummer..."
style={{ width: 300 }}
allowClear
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
}
extra={
<Space>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
name="accountNumber"
label="Kontonummer"
rules={[
{ required: true, message: 'Indtast kontonummer' },
{
pattern: /^\d{4}$/,
message: 'Kontonummer skal vaere 4 cifre',
},
]}
>
<Input placeholder="F.eks. 1000" maxLength={4} />
</Form.Item>
<Table
columns={columns}
dataSource={tableData}
rowKey="id"
loading={isLoading}
pagination={{ pageSize: 50, showSizeChanger: true }}
size="middle"
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: 'pointer' },
})}
/>
</Card>
<Form.Item
name="name"
label="Kontonavn"
rules={[{ required: true, message: 'Indtast kontonavn' }]}
>
<Input placeholder="F.eks. Bankkonto" />
</Form.Item>
{/* Details/Edit Drawer */}
<Drawer
title={
isEditMode
? (selectedAccount ? 'Rediger konto' : 'Ny konto')
: (
<Space>
{selectedAccount?.name}
<Tag>{selectedAccount?.accountNumber}</Tag>
</Space>
)
}
width={500}
open={isDrawerOpen}
onClose={handleCloseDrawer}
extra={
!isEditMode && selectedAccount && (
<Button type="primary" icon={<EditOutlined />} onClick={handleEditAccount}>
Rediger
</Button>
)
}
footer={
isEditMode && (
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => isEditMode && selectedAccount ? setIsEditMode(false) : handleCloseDrawer()}>
Annuller
</Button>
<Button type="primary" onClick={handleSubmit}>
Gem konto
</Button>
</Space>
</div>
)
}
>
{isEditMode ? (
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="accountNumber"
label="Kontonummer"
rules={[
{ required: true, message: 'Påkrævet' },
{ pattern: /^\d{4}$/, message: 'Skal være 4 cifre' },
]}
>
<Input maxLength={4} placeholder="1234" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="type"
label="Kontotype"
rules={[{ required: true, message: 'Påkrævet' }]}
>
<Select options={accountTypes.map(t => ({ label: getAccountTypeName(t), value: t }))} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="type"
label="Kontotype"
rules={[{ required: true, message: 'Vaelg kontotype' }]}
>
<Select
placeholder="Vaelg type"
options={accountTypes.map((type) => ({
value: type,
label: getAccountTypeName(type),
}))}
/>
</Form.Item>
<Form.Item
name="name"
label="Kontonavn"
rules={[{ required: true, message: 'Påkrævet' }]}
>
<Input placeholder="F.eks. Salg af varer" />
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vaelg momskode"
allowClear
options={[
{ value: 'S25', label: 'S25 - Udgaende moms 25%' },
{ value: 'K25', label: 'K25 - Indgaende moms 25%' },
{ value: 'E0', label: 'E0 - EU-varekob 0%' },
{ value: 'U0', label: 'U0 - Eksport 0%' },
]}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="vatCode" label="Momskode">
<Select
allowClear
placeholder="Vælg..."
options={[
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
{ value: 'E0', label: 'E0 - EU-salg' },
{ value: 'U0', label: 'U0 - Eksport' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="isActive" label="Status" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="Aktiv" unCheckedChildren="Inaktiv" />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
</Form.Item>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={4} placeholder="Interne noter til denne konto..." />
</Form.Item>
</Form>
) : selectedAccount ? (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Balance Summary */}
<Card size="small" bordered={false} style={{ background: '#f5f5f5' }}>
<Statistic
title="Nuværende Saldo"
value={selectedAccount.balance}
precision={2}
suffix="kr."
valueStyle={{
color: selectedAccount.balance >= 0 ? accountingColors.credit : accountingColors.debit,
fontSize: 24,
}}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
Beregnet for indeværende regnskabsår
</Text>
</Card>
<Form.Item name="isActive" label="Status" initialValue={true}>
<Select
options={[
{ value: true, label: 'Aktiv' },
{ value: false, label: 'Inaktiv' },
]}
/>
</Form.Item>
</Form>
</Modal>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Kontonummer">{selectedAccount.accountNumber}</Descriptions.Item>
<Descriptions.Item label="Kontotype">{getAccountTypeName(selectedAccount.type)}</Descriptions.Item>
<Descriptions.Item label="Moms">{selectedAccount.vatCode || <Text type="secondary">Ingen</Text>}</Descriptions.Item>
<Descriptions.Item label="Status">
{selectedAccount.isActive ? <Tag color="success">Aktiv</Tag> : <Tag color="default">Inaktiv</Tag>}
</Descriptions.Item>
{selectedAccount.description && (
<Descriptions.Item label="Beskrivelse">
{selectedAccount.description}
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left" plain><HistoryOutlined /> Seneste Bevægelser</Divider>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Text type="secondary">Ingen posteringer i den valgte periode.</Text>
<br />
<Button type="link" size="small" onClick={() => navigate('/kassekladde')}> til kassekladde</Button>
</div>
<Divider plain />
<Space direction="vertical" style={{ width: '100%' }}>
<AlertInfo
message="Systemkonto"
description="Denne konto bruges automatisk af systemet til specifikke posteringer."
show={!!selectedAccount.isSystemAccount}
/>
</Space>
</Space>
) : null}
</Drawer>
</div>
);
}
// Helper component for alerts
function AlertInfo({ message, description, show }: { message: string, description: string, show: boolean }) {
if (!show) return null;
return (
<div style={{ background: '#e6f7ff', border: '1px solid #91d5ff', padding: '8px 12px', borderRadius: 4 }}>
<Space align="start">
<InfoCircleOutlined style={{ color: '#1890ff', marginTop: 4 }} />
<div>
<Text strong style={{ color: '#1890ff' }}>{message}</Text>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>{description}</div>
</div>
</Space>
</div>
);
}

View file

@ -124,9 +124,9 @@ export default function Kreditnotaer() {
// Fetch customers for dropdown
const { data: customers = [] } = useActiveCustomers(company?.id);
// Fetch invoices for applying credit notes (only when modal is open)
// Fetch invoices for applying credit notes and for original invoice selector
const { data: allInvoices = [] } = useInvoices(company?.id, undefined, {
enabled: !!company?.id && isApplyModalOpen,
enabled: !!company?.id && (isApplyModalOpen || isCreateModalOpen),
});
const openInvoices: Invoice[] = allInvoices.filter(
@ -623,6 +623,15 @@ export default function Kreditnotaer() {
allowClear
placeholder="Vælg faktura der krediteres"
optionFilterProp="children"
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">

View file

@ -11,6 +11,7 @@ import {
Form,
Input,
Select,
Switch,
Spin,
Alert,
Drawer,
@ -48,6 +49,7 @@ import { formatDate, validateCVRModulus11 } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
const { Title, Text } = Typography;
@ -310,27 +312,18 @@ export default function Kunder() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kunder
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<ShortcutTooltip shortcutId="newCustomer">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Ny kunde
</Button>
</ShortcutTooltip>
</div>
<PageHeader
title="Kunder"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Kunder' }]}
extra={
<ShortcutTooltip shortcutId="newCustomer">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Ny kunde
</Button>
</ShortcutTooltip>
}
/>
{/* Error State */}
{error && (
@ -391,15 +384,10 @@ export default function Kunder() {
style={{ width: 250 }}
allowClear
/>
<Select
value={showInactive ? 'all' : 'active'}
onChange={(value) => setShowInactive(value === 'all')}
style={{ width: 150 }}
options={[
{ value: 'active', label: 'Kun aktive' },
{ value: 'all', label: 'Alle kunder' },
]}
/>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
</Card>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Typography,
Card,
@ -15,24 +15,26 @@ import {
Alert,
Modal,
Descriptions,
message,
Empty,
Skeleton,
} from 'antd';
import {
DownloadOutlined,
SendOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { Pie } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency, formatDate, formatPeriod } from '@/lib/formatters';
import { useCompanyStore } from '@/stores/companyStore';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatPeriod } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
import { PageHeader } from '@/components/shared/PageHeader';
const { Title, Text } = Typography;
const { Text } = Typography;
// Danish VAT boxes (Rubrikker)
// Danish VAT boxes (Rubrikker) - mapped from backend VatReport
interface VATBox {
boxNumber: number;
nameDanish: string;
@ -42,134 +44,90 @@ interface VATBox {
basis?: number;
}
const mockVATReport: VATBox[] = [
{
boxNumber: 1,
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
amount: 62500,
basis: 250000,
},
{
boxNumber: 2,
nameDanish: 'Moms af varekøb i udlandet (EU)',
nameEnglish: 'VAT on goods from EU',
description: 'Erhvervelsesmoms ved køb af varer fra andre EU-lande',
amount: 5000,
basis: 20000,
},
{
boxNumber: 3,
nameDanish: 'Moms af ydelseskøb i udlandet',
nameEnglish: 'VAT on services from abroad',
description: 'Moms ved køb af ydelser fra udlandet med omvendt betalingspligt',
amount: 2500,
basis: 10000,
},
{
boxNumber: 4,
nameDanish: 'Købsmoms',
nameEnglish: 'Input VAT',
description: 'Fradragsberettiget moms af køb',
amount: 35000,
basis: 140000,
},
{
boxNumber: 5,
nameDanish: 'Olie- og flaskegasafgift',
nameEnglish: 'Oil and gas duty',
description: 'Godtgørelse af olie- og flaskegasafgift',
amount: 0,
},
{
boxNumber: 6,
nameDanish: 'Elafgift',
nameEnglish: 'Electricity duty',
description: 'Godtgørelse af elafgift',
amount: 1200,
},
{
boxNumber: 7,
nameDanish: 'Naturgas- og bygasafgift',
nameEnglish: 'Natural gas duty',
description: 'Godtgørelse af naturgas- og bygasafgift',
amount: 0,
},
{
boxNumber: 8,
nameDanish: 'Kulafgift',
nameEnglish: 'Coal duty',
description: 'Godtgørelse af kulafgift',
amount: 0,
},
{
boxNumber: 9,
nameDanish: 'CO2-afgift',
nameEnglish: 'CO2 duty',
description: 'Godtgørelse af CO2-afgift',
amount: 300,
},
];
// Historical submissions
const mockSubmissions = [
{
id: '1',
period: '2024-10',
submittedAt: '2024-11-28',
status: 'accepted',
netVAT: 28500,
referenceNumber: 'SKAT-2024-123456',
},
{
id: '2',
period: '2024-07',
submittedAt: '2024-08-30',
status: 'accepted',
netVAT: 32100,
referenceNumber: 'SKAT-2024-789012',
},
{
id: '3',
period: '2024-04',
submittedAt: '2024-05-29',
status: 'accepted',
netVAT: -5600,
referenceNumber: 'SKAT-2024-345678',
},
];
export default function Momsindberetning() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
dayjs().subtract(1, 'month').startOf('month')
);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
// Calculate totals
const outputVAT = mockVATReport
.filter((box) => [1, 2, 3].includes(box.boxNumber))
.reduce((sum, box) => sum + box.amount, 0);
// Calculate period dates based on selection
const periodStart = useMemo(() => {
if (periodType === 'quarterly') {
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
}
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
const inputVAT = mockVATReport
.filter((box) => box.boxNumber === 4)
.reduce((sum, box) => sum + box.amount, 0);
const periodEnd = useMemo(() => {
if (periodType === 'quarterly') {
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
}
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]);
const energyDuties = mockVATReport
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
.reduce((sum, box) => sum + box.amount, 0);
// Fetch VAT report from backend
const { data: vatReport, isLoading, error } = useVatReport(
activeCompany?.id,
periodStart,
periodEnd
);
const netVAT = outputVAT - inputVAT - energyDuties;
// Map backend VatReport to UI's rubrik display
const vatBoxes: VATBox[] = useMemo(() => {
if (!vatReport) return [];
return [
{
boxNumber: 1,
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
amount: vatReport.boxA,
basis: vatReport.basis1,
},
{
boxNumber: 2,
nameDanish: 'Moms af varekob i udlandet (EU)',
nameEnglish: 'VAT on goods from EU',
description: 'Erhvervelsesmoms ved kob af varer fra andre EU-lande',
amount: vatReport.boxC,
basis: vatReport.basis3,
},
{
boxNumber: 3,
nameDanish: 'Moms af ydelseskob i udlandet',
nameEnglish: 'VAT on services from abroad',
description: 'Moms ved kob af ydelser fra udlandet med omvendt betalingspligt',
amount: vatReport.boxD,
basis: vatReport.basis4,
},
{
boxNumber: 4,
nameDanish: 'Kobsmoms',
nameEnglish: 'Input VAT',
description: 'Fradragsberettiget moms af kob',
amount: vatReport.boxB,
basis: undefined, // Backend doesn't provide a specific basis for input VAT
},
];
}, [vatReport]);
// Calculate totals from real data
const outputVAT = vatReport?.totalOutputVat ?? 0;
const inputVAT = vatReport?.totalInputVat ?? 0;
const netVAT = vatReport?.netVat ?? 0;
// Pie chart config
const pieData = [
{ type: 'Salgsmoms', value: mockVATReport[0].amount },
{ type: 'EU-moms', value: mockVATReport[1].amount + mockVATReport[2].amount },
{ type: 'Købsmoms (fradrag)', value: inputVAT },
{ type: 'Energiafgifter (fradrag)', value: energyDuties },
];
const pieData = useMemo(() => {
if (!vatReport) return [];
return [
{ type: 'Salgsmoms', value: vatReport.boxA },
{ type: 'EU-moms', value: (vatReport.boxC || 0) + (vatReport.boxD || 0) },
{ type: 'Kobsmoms (fradrag)', value: inputVAT },
].filter(d => d.value > 0);
}, [vatReport, inputVAT]);
const pieConfig = {
data: pieData,
@ -243,96 +201,49 @@ export default function Momsindberetning() {
},
];
const handleSubmit = () => {
Modal.confirm({
title: 'Indsend momsangivelse',
icon: <ExclamationCircleOutlined />,
content: (
<div>
<p>Du er ved at indsende momsangivelse for:</p>
<p>
<Text strong>Periode:</Text> {formatPeriod(selectedPeriod.toDate())}
</p>
<p>
<Text strong>Moms til betaling:</Text>{' '}
<Text
style={{
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(Math.abs(netVAT))}
{netVAT < 0 ? ' (tilgode)' : ''}
</Text>
</p>
<Alert
message="Denne handling kan ikke fortrydes"
type="warning"
showIcon
style={{ marginTop: 16 }}
/>
</div>
),
okText: 'Indsend til SKAT',
cancelText: 'Annuller',
onOk: () => {
message.success('Momsangivelse indsendt til SKAT');
},
});
};
const getStatusTag = (status: string) => {
switch (status) {
case 'accepted':
return (
<Tag color="green" icon={<CheckCircleOutlined />}>
Godkendt
</Tag>
);
case 'pending':
return (
<Tag color="blue" icon={<ClockCircleOutlined />}>
Afventer
</Tag>
);
case 'rejected':
return (
<Tag color="red" icon={<ExclamationCircleOutlined />}>
Afvist
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
};
// Loading state
if (isLoading) {
return (
<div>
<PageHeader
title="Momsindberetning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
/>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Momsindberetning
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button icon={<DownloadOutlined />}>Eksporter</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={() => setIsPreviewOpen(true)}
>
Forhåndsvis
</Button>
</Space>
</div>
<PageHeader
title="Momsindberetning"
subtitle={company?.name}
breadcrumbs={[{ title: 'Rapportering' }, { title: 'Momsindberetning' }]}
extra={
<Space>
<Button icon={<DownloadOutlined />}>Eksporter</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={() => setIsPreviewOpen(true)}
disabled={!vatReport}
>
Forhåndsvis
</Button>
</Space>
}
/>
{/* SKAT submission notice */}
<Alert
type="warning"
message="Indberetning skal ske manuelt på skat.dk"
description="Automatisk indberetning til SKAT er endnu ikke implementeret. Brug disse tal til at udfylde momsangivelsen på skat.dk manuelt."
showIcon
style={{ marginBottom: 16 }}
/>
{/* Period Selection */}
<Card size="small" style={{ marginBottom: 16 }}>
@ -343,7 +254,7 @@ export default function Momsindberetning() {
onChange={setPeriodType}
style={{ width: 120 }}
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'monthly', label: 'Maanedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
]}
/>
@ -356,12 +267,26 @@ export default function Momsindberetning() {
<Tag color="blue">
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
</Tag>
{vatReport && (
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
)}
</Space>
</Card>
{/* Error state */}
{error && (
<Alert
type="error"
message="Fejl ved indlaesning af momsdata"
description={error.message}
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title="Udgående moms"
@ -372,7 +297,7 @@ export default function Momsindberetning() {
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title="Indgående moms (fradrag)"
@ -383,18 +308,7 @@ export default function Momsindberetning() {
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Energiafgifter (fradrag)"
value={energyDuties}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.credit }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={8}>
<Card size="small">
<Statistic
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
@ -413,87 +327,57 @@ export default function Momsindberetning() {
<Row gutter={16}>
<Col xs={24} lg={16}>
<Card title="Momsangivelse - Rubrikker" size="small">
<Table
dataSource={mockVATReport}
columns={columns}
rowKey="boxNumber"
pagination={false}
size="small"
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3}>
<Text strong>Moms til betaling / tilgode</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text
strong
className="tabular-nums"
style={{
fontSize: 16,
color:
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{netVAT >= 0 ? '' : '-'}
{formatCurrency(Math.abs(netVAT))}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
{vatBoxes.length > 0 ? (
<Table
dataSource={vatBoxes}
columns={columns}
rowKey="boxNumber"
pagination={false}
size="small"
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3}>
<Text strong>Moms til betaling / tilgode</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text
strong
className="tabular-nums"
style={{
fontSize: 16,
color:
netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{netVAT >= 0 ? '' : '-'}
{formatCurrency(Math.abs(netVAT))}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
) : (
<Empty description="Ingen momsdata for den valgte periode" />
)}
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
<Pie {...pieConfig} />
{pieData.length > 0 ? (
<Pie {...pieConfig} />
) : (
<Empty description="Ingen data at vise" style={{ height: 200 }} />
)}
</Card>
<Card title="Tidligere indberetninger" size="small">
{mockSubmissions.map((sub) => (
<div
key={sub.id}
style={{
padding: '8px 0',
borderBottom: '1px solid #f0f0f0',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<Text strong>
{dayjs(sub.period, 'YYYY-MM').format('MMMM YYYY')}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Indsendt {formatDate(sub.submittedAt)}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
{getStatusTag(sub.status)}
<br />
<Text
className="tabular-nums"
style={{
color:
sub.netVAT >= 0
? accountingColors.debit
: accountingColors.credit,
}}
>
{formatCurrency(sub.netVAT)}
</Text>
</div>
</div>
</div>
))}
<DemoDataDisclaimer message="Indberetningshistorik er endnu ikke tilgængelig" />
<Text type="secondary">
Tidligere indberetninger vil blive vist her nar SKAT-integration er implementeret.
</Text>
</Card>
</Col>
</Row>
@ -512,18 +396,25 @@ export default function Momsindberetning() {
Download PDF
</Button>,
<Button
key="submit"
key="skat-link"
type="primary"
icon={<SendOutlined />}
onClick={() => {
window.open('https://skat.dk', '_blank');
setIsPreviewOpen(false);
handleSubmit();
}}
>
Indsend til SKAT
Ga til skat.dk
</Button>,
]}
>
<Alert
type="info"
message="Brug disse tal til at udfylde momsangivelsen på skat.dk"
showIcon
style={{ marginBottom: 16 }}
/>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
@ -537,22 +428,24 @@ export default function Momsindberetning() {
<Divider />
<Table
dataSource={mockVATReport}
columns={[
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
{ dataIndex: 'nameDanish', title: 'Felt' },
{
dataIndex: 'amount',
title: 'Beløb',
align: 'right',
render: (v) => formatCurrency(v),
},
]}
rowKey="boxNumber"
pagination={false}
size="small"
/>
{vatBoxes.length > 0 && (
<Table
dataSource={vatBoxes}
columns={[
{ dataIndex: 'boxNumber', title: 'Rubrik', width: 80 },
{ dataIndex: 'nameDanish', title: 'Felt' },
{
dataIndex: 'amount',
title: 'Belob',
align: 'right',
render: (v: number) => formatCurrency(v),
},
]}
rowKey="boxNumber"
pagination={false}
size="small"
/>
)}
<Divider />

View file

@ -58,6 +58,7 @@ import { spacing } from '@/styles/designTokens';
import { accountingColors } from '@/styles/theme';
import { AmountText } from '@/components/shared/AmountText';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
import type { Order, OrderLine, OrderStatus } from '@/types/order';
import { ORDER_STATUS_LABELS, ORDER_STATUS_COLORS } from '@/types/order';
@ -140,7 +141,7 @@ export default function Ordrer() {
const handleSubmitCreate = async () => {
if (!company || !currentFiscalYear) {
showError('Virksomhed eller regnskabsaar ikke valgt');
showError('Virksomhed eller regnskabsår ikke valgt');
return;
}
try {
@ -226,12 +227,12 @@ export default function Ordrer() {
const handleConfirmOrder = async () => {
if (!selectedOrder) return;
if (selectedOrder.lines.length === 0) {
showWarning('Tilfoej mindst en linje foer bekraeftelse');
showWarning('Tilføj mindst en linje før bekræftelse');
return;
}
try {
await confirmOrderMutation.mutateAsync(selectedOrder.id);
showSuccess('Ordre bekraeftet');
showSuccess('Ordre bekræftet');
// Refresh would happen via query invalidation
} catch (err) {
if (err instanceof Error) {
@ -276,7 +277,7 @@ export default function Ordrer() {
const handleSubmitConvert = async () => {
if (!selectedOrder || selectedLinesToInvoice.length === 0) {
showWarning('Vaelg mindst en linje at fakturere');
showWarning('Vælg mindst en linje at fakturere');
return;
}
try {
@ -346,7 +347,7 @@ export default function Ordrer() {
render: (value: string | undefined) => (value ? formatDate(value) : '-'),
},
{
title: 'Beloeb',
title: 'Beløb',
dataIndex: 'amountTotal',
key: 'amountTotal',
width: 120,
@ -371,7 +372,7 @@ export default function Ordrer() {
align: 'center',
filters: [
{ text: 'Kladde', value: 'draft' },
{ text: 'Bekraeftet', value: 'confirmed' },
{ text: 'Bekræftet', value: 'confirmed' },
{ text: 'Delvist faktureret', value: 'partially_invoiced' },
{ text: 'Fuldt faktureret', value: 'fully_invoiced' },
{ text: 'Annulleret', value: 'cancelled' },
@ -399,37 +400,28 @@ export default function Ordrer() {
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Ordrer
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
Ny ordre
</Button>
</div>
<PageHeader
title="Ordrer"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Ordrer' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateOrder}>
Ny ordre
</Button>
}
/>
{/* Error State */}
{error && (
<Alert
message="Fejl ved indlaesning af ordrer"
message="Fejl ved indlæsning af ordrer"
description={error.message}
type="error"
showIcon
style={{ marginBottom: spacing.lg }}
action={
<Button size="small" onClick={() => refetch()}>
Proev igen
Prøv igen
</Button>
}
/>
@ -454,7 +446,7 @@ export default function Ordrer() {
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Bekraeftede"
title="Bekræftede"
value={stats.confirmed}
valueStyle={{ color: accountingColors.credit }}
/>
@ -463,7 +455,7 @@ export default function Ordrer() {
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Samlet vaerdi"
title="Samlet værdi"
value={stats.totalValue}
precision={2}
valueStyle={{ color: accountingColors.credit }}
@ -477,7 +469,7 @@ export default function Ordrer() {
<Card size="small" style={{ marginBottom: spacing.lg }}>
<Space wrap>
<Input
placeholder="Soeg ordre..."
placeholder="Søg ordre..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
@ -491,7 +483,7 @@ export default function Ordrer() {
options={[
{ value: 'all', label: 'Alle status' },
{ value: 'draft', label: 'Kladde' },
{ value: 'confirmed', label: 'Bekraeftet' },
{ value: 'confirmed', label: 'Bekræftet' },
{ value: 'partially_invoiced', label: 'Delvist faktureret' },
{ value: 'fully_invoiced', label: 'Fuldt faktureret' },
{ value: 'cancelled', label: 'Annulleret' },
@ -503,7 +495,7 @@ export default function Ordrer() {
{/* Order Table */}
<Card size="small">
{loading ? (
<Spin tip="Indlaeser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<Spin tip="Indlæser ordrer..." style={{ display: 'block', textAlign: 'center', padding: spacing.xl }}>
<div style={{ minHeight: 200 }} />
</Spin>
) : filteredOrders.length > 0 ? (
@ -518,7 +510,7 @@ export default function Ordrer() {
<EmptyState
variant="default"
title="Ingen ordrer"
description={searchText ? 'Ingen ordrer matcher din soegning' : 'Opret din foerste ordre'}
description={searchText ? 'Ingen ordrer matcher din søgning' : 'Opret din første ordre'}
primaryAction={
!searchText
? {
@ -546,11 +538,11 @@ export default function Ordrer() {
<Form.Item
name="customerId"
label="Kunde"
rules={[{ required: true, message: 'Vaelg kunde' }]}
rules={[{ required: true, message: 'Vælg kunde' }]}
>
<Select
showSearch
placeholder="Vaelg kunde"
placeholder="Vælg kunde"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
@ -576,7 +568,7 @@ export default function Ordrer() {
<Form.Item name="reference" label="Reference">
<Input placeholder="Projektnavn, tilbudsnr., etc." />
</Form.Item>
<Form.Item name="notes" label="Bemaerkninger">
<Form.Item name="notes" label="Bemærkninger">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
@ -612,7 +604,7 @@ export default function Ordrer() {
onClick={handleOpenAddLineModal}
loading={addOrderLineMutation.isPending}
>
Tilfoej linje
Tilføj linje
</Button>
<Button
type="primary"
@ -621,13 +613,13 @@ export default function Ordrer() {
loading={confirmOrderMutation.isPending}
disabled={selectedOrder.lines.length === 0}
>
Bekraeft
Bekræft
</Button>
</>
)}
{canShowConvertToInvoice(selectedOrder) && (
<Tooltip
title={selectedOrder.status === 'draft' ? 'Bekraeft ordren foerst' : undefined}
title={selectedOrder.status === 'draft' ? 'Bekræft ordren først' : undefined}
>
<Button
type="primary"
@ -720,7 +712,7 @@ export default function Ordrer() {
) : (
<Alert
message="Ingen linjer endnu"
description="Tilfoej linjer for at kunne bekraefte ordren."
description="Tilføj linjer for at kunne bekræfte ordren."
type="info"
showIcon
/>
@ -732,13 +724,13 @@ export default function Ordrer() {
<Col span={12}>
{selectedOrder.notes && (
<>
<Text type="secondary">Bemaerkninger:</Text>
<Text type="secondary">Bemærkninger:</Text>
<p>{selectedOrder.notes}</p>
</>
)}
{selectedOrder.cancelledReason && (
<>
<Text type="secondary">Annulleringsaarsag:</Text>
<Text type="secondary">Annulleringsårsag:</Text>
<p style={{ color: 'red' }}>{selectedOrder.cancelledReason}</p>
</>
)}
@ -746,7 +738,7 @@ export default function Ordrer() {
<Col span={12}>
<div style={{ textAlign: 'right' }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">Beloeb ex. moms: </Text>
<Text type="secondary">Beløb ex. moms: </Text>
<Text>{formatCurrency(selectedOrder.amountExVat)}</Text>
</div>
<div style={{ marginBottom: 4 }}>
@ -794,7 +786,7 @@ export default function Ordrer() {
>
<Alert
message="Advarsel"
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uaendrede."
description="At annullere ordren kan ikke fortrydes. Eventuelle delfaktureringer forbliver uændrede."
type="warning"
showIcon
style={{ marginBottom: spacing.lg }}
@ -802,8 +794,8 @@ export default function Ordrer() {
<Form form={cancelForm} layout="vertical">
<Form.Item
name="reason"
label="Aarsag til annullering"
rules={[{ required: true, message: 'Angiv aarsag' }]}
label="Årsag til annullering"
rules={[{ required: true, message: 'Angiv årsag' }]}
>
<Input.TextArea rows={3} placeholder="Beskriv hvorfor ordren annulleres" />
</Form.Item>
@ -812,14 +804,14 @@ export default function Ordrer() {
{/* Add Line Modal */}
<Modal
title="Tilfoej linje"
title="Tilføj linje"
open={isAddLineModalOpen}
onCancel={() => {
setIsAddLineModalOpen(false);
setSelectedProductId(null);
}}
onOk={handleSubmitAddLine}
okText="Tilfoej"
okText="Tilføj"
cancelText="Annuller"
confirmLoading={addOrderLineMutation.isPending}
width={550}
@ -840,7 +832,7 @@ export default function Ordrer() {
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="product">Vaelg produkt</Radio.Button>
<Radio.Button value="product">Vælg produkt</Radio.Button>
<Radio.Button value="freetext">Fritekst</Radio.Button>
</Radio.Group>
</Form.Item>
@ -850,11 +842,11 @@ export default function Ordrer() {
label="Produkt"
required
validateStatus={addLineMode === 'product' && !selectedProductId ? 'error' : undefined}
help={addLineMode === 'product' && !selectedProductId ? 'Vaelg et produkt' : undefined}
help={addLineMode === 'product' && !selectedProductId ? 'Vælg et produkt' : undefined}
>
<Select
showSearch
placeholder="Soeg efter produkt..."
placeholder="Søg efter produkt..."
optionFilterProp="children"
value={selectedProductId}
onChange={handleProductSelect}
@ -922,7 +914,7 @@ export default function Ordrer() {
<Form.Item
name="vatCode"
label="Momskode"
rules={[{ required: true, message: 'Vaelg momskode' }]}
rules={[{ required: true, message: 'Vælg momskode' }]}
>
<Select
disabled={addLineMode === 'product' && !!selectedProductId}
@ -952,8 +944,8 @@ export default function Ordrer() {
width={600}
>
<Alert
message="Vaelg linjer til fakturering"
description="Vaelg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
message="Vælg linjer til fakturering"
description="Vælg hvilke ordrelinjer der skal inkluderes i fakturaen. Du kan fakturere delvist og oprette flere fakturaer senere."
type="info"
showIcon
style={{ marginBottom: spacing.lg }}

View file

@ -12,7 +12,6 @@ import {
InputNumber,
Select,
AutoComplete,
Spin,
Alert,
Drawer,
Descriptions,
@ -20,6 +19,8 @@ import {
Row,
Col,
Statistic,
Switch,
Skeleton,
} from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling';
import {
@ -45,9 +46,10 @@ import { formatDate, formatCurrency } from '@/lib/formatters';
import { spacing } from '@/styles/designTokens';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import type { ColumnsType } from 'antd/es/table';
const { Title, Text } = Typography;
const { Text } = Typography;
// VAT code options
const vatCodeOptions = [
@ -310,33 +312,57 @@ export default function Produkter() {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: spacing.xl }}>
<Spin size="large" />
<div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
/>
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
<Col xs={24} sm={8}><Card><Skeleton active paragraph={{ rows: 2 }} /></Card></Col>
</Row>
<Card><Skeleton active paragraph={{ rows: 8 }} /></Card>
</div>
);
}
if (error) {
return (
<Alert
message="Fejl ved indlæsning af produkter"
description={error.message}
type="error"
showIcon
/>
<div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
/>
<Alert
message="Fejl ved indlæsning af produkter"
description={error.message}
type="error"
showIcon
action={
<Button size="small" onClick={() => window.location.reload()}>
Prøv igen
</Button>
}
/>
</div>
);
}
return (
<div>
<div style={{ marginBottom: spacing.lg, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
Produkter
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Opret produkt
</Button>
</div>
<PageHeader
title="Produkter"
subtitle={company?.name}
breadcrumbs={[{ title: 'Fakturering', path: '/fakturaer' }, { title: 'Produkter' }]}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
Opret produkt
</Button>
}
/>
{/* Statistics */}
<Row gutter={spacing.md} style={{ marginBottom: spacing.lg }}>
@ -373,12 +399,10 @@ export default function Produkter() {
allowClear
style={{ width: 300 }}
/>
<Button
type={showInactive ? 'primary' : 'default'}
onClick={() => setShowInactive(!showInactive)}
>
{showInactive ? 'Skjul inaktive' : 'Vis inaktive'}
</Button>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>Vis inaktive</Text>
<Switch size="small" checked={showInactive} onChange={setShowInactive} />
</Space>
</Space>
</Card>

View file

@ -12,7 +12,7 @@ import {
Divider,
message,
Space,
Tag,
Empty,
} from 'antd';
import {
SaveOutlined,
@ -22,6 +22,8 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import { useCompany } from '@/hooks/useCompany';
import { useUpdateCompany } from '@/api/mutations/companyMutations';
import { DemoDataDisclaimer } from '@/components/shared/DemoDataDisclaimer';
const { Title, Text } = Typography;
@ -29,24 +31,45 @@ export default function Settings() {
const { company } = useCompany();
const [companyForm] = Form.useForm();
const [preferencesForm] = Form.useForm();
const updateCompanyMutation = useUpdateCompany();
const handleSaveCompany = async () => {
try {
const values = await companyForm.validateFields();
console.log('Saving company:', values);
if (!company?.id) {
message.error('Ingen virksomhed valgt');
return;
}
await updateCompanyMutation.mutateAsync({
id: company.id,
input: {
name: values.name,
cvr: values.cvr,
address: values.address,
city: values.city,
postalCode: values.postalCode,
},
});
message.success('Virksomhedsoplysninger gemt');
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl ved gemning: ${error.message}`);
}
}
};
const handleSavePreferences = async () => {
try {
const values = await preferencesForm.validateFields();
console.log('Saving preferences:', values);
message.success('Præferencer gemt');
await preferencesForm.validateFields();
// TODO: Backend does not yet have a preferences mutation.
// Preferences like VAT period, auto-reconcile, etc. need a dedicated backend endpoint.
message.info('Præferencer er endnu ikke forbundet til backend');
} catch (error) {
console.error('Validation failed:', error);
if (error instanceof Error) {
message.error(`Fejl ved gemning: ${error.message}`);
}
}
};
@ -282,56 +305,19 @@ export default function Settings() {
<Title level={5} style={{ margin: 0 }}>
Tilknyttede bankkonti
</Title>
<Button type="primary">Tilføj bankkonto</Button>
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>Tilføj bankkonto</Button>
</div>
<Divider />
{/* Mock bank accounts */}
{[
{
id: '1',
bankName: 'Danske Bank',
accountName: 'Erhvervskonto',
accountNumber: '1234-5678901234',
ledgerAccount: '1000 - Bank',
isActive: true,
},
{
id: '2',
bankName: 'Nordea',
accountName: 'Opsparingskonto',
accountNumber: '9876-5432109876',
ledgerAccount: '1010 - Bank opsparing',
isActive: true,
},
].map((account) => (
<Card key={account.id} size="small">
<Row align="middle" justify="space-between">
<Col>
<Space direction="vertical" size={0}>
<Space>
<Text strong>{account.bankName}</Text>
<Tag color="blue">{account.accountName}</Tag>
{account.isActive && <Tag color="green">Aktiv</Tag>}
</Space>
<Text type="secondary">{account.accountNumber}</Text>
<Text type="secondary">
Bogføringskonto: {account.ledgerAccount}
</Text>
</Space>
</Col>
<Col>
<Space>
<Button size="small">Rediger</Button>
<Button size="small" danger>
Fjern
</Button>
</Space>
</Col>
</Row>
</Card>
))}
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Ingen bankkonti tilknyttet endnu"
>
<Button type="primary" onClick={() => message.info('Bankforbindelse tilføjes under Indstillinger > Integrationer (kommer snart)')}>
Tilføj bankkonto
</Button>
</Empty>
</Space>
</Card>
),
@ -345,6 +331,7 @@ export default function Settings() {
),
children: (
<Card>
<DemoDataDisclaimer message="Brugerstyring er under udvikling" />
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
@ -356,50 +343,15 @@ export default function Settings() {
<Title level={5} style={{ margin: 0 }}>
Brugere med adgang
</Title>
<Button type="primary">Inviter bruger</Button>
<Button type="primary" disabled>Inviter bruger</Button>
</div>
<Divider />
{/* Mock users */}
{[
{
id: '1',
name: 'Admin Bruger',
email: 'admin@example.com',
role: 'Administrator',
lastLogin: '2025-01-17',
},
{
id: '2',
name: 'Bogholder',
email: 'bogholder@example.com',
role: 'Bogholder',
lastLogin: '2025-01-16',
},
].map((user) => (
<Card key={user.id} size="small">
<Row align="middle" justify="space-between">
<Col>
<Space direction="vertical" size={0}>
<Text strong>{user.name}</Text>
<Text type="secondary">{user.email}</Text>
</Space>
</Col>
<Col>
<Space>
<Tag color={user.role === 'Administrator' ? 'gold' : 'blue'}>
{user.role}
</Tag>
<Text type="secondary">
Sidste login: {user.lastLogin}
</Text>
<Button size="small">Rediger</Button>
</Space>
</Col>
</Row>
</Card>
))}
<Text type="secondary">
Brugere med adgang til denne virksomhed vil blive vist her,
når funktionen er implementeret.
</Text>
</Space>
</Card>
),

View file

@ -1,18 +1,18 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Company, CompanyRole } from '@/types/accounting';
import type { CompanyRole, CompanyWithRole } from '@/types/accounting';
interface CompanyState {
// Current active company
activeCompany: Company | null;
// List of available companies
companies: Company[];
// Current active company (includes role from myCompanies query)
activeCompany: CompanyWithRole | null;
// List of available companies (includes role from myCompanies query)
companies: CompanyWithRole[];
// Loading state
isLoading: boolean;
// Actions
setActiveCompany: (company: Company) => void;
setCompanies: (companies: Company[]) => void;
setActiveCompany: (company: CompanyWithRole) => void;
setCompanies: (companies: CompanyWithRole[]) => void;
setLoading: (loading: boolean) => void;
clearActiveCompany: () => void;
}
@ -53,11 +53,11 @@ export const useCompanies = () =>
useCompanyStore((state) => state.companies);
// Get the current user's role for the active company
// Returns 'owner' as default for now - in production this would come from the server
// Returns the role from the myCompanies query data stored on the active company
export const useActiveCompanyRole = (): CompanyRole => {
// Placeholder: In a real implementation, this would check the user's role
// for the currently active company from the server/auth context
return 'owner';
const activeCompany = useCompanyStore((state) => state.activeCompany);
// Return the actual role from the CompanyWithRole data, default to 'viewer' if not set
return activeCompany?.role ?? 'viewer';
};
// Helper functions for user roles
@ -88,9 +88,8 @@ export function getRoleColor(role: CompanyRole): string {
}
// Hook to check if current user can administer the company
// This is a placeholder - in a real app, this would check the user's role
// Checks if the user has Owner role for the active company
export function useCanAdmin(): boolean {
// For now, return true to allow all users to manage access
// In production, this should check the current user's role
return true;
const role = useActiveCompanyRole();
return role === 'owner';
}

View file

@ -23,6 +23,7 @@ export interface Account {
type: AccountType;
parentId?: string;
isActive: boolean;
isSystemAccount?: boolean;
description?: string;
vatCode?: string;
balance: number;

View file

@ -22,8 +22,8 @@ export type VATCode =
* VAT code type classification
*/
export type VATCodeType =
| 'output' // Udgaaende moms (salg)
| 'input' // Indgaaende moms (koeb)
| 'output' // Udgående moms (salg)
| 'input' // Indgående moms (køb)
| 'reverse_charge' // Omvendt betalingspligt
| 'exempt' // Momsfritaget
| 'none'; // Ingen moms

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/documentprocessing.ts","./src/api/mutations/accountmutations.ts","./src/api/mutations/bankconnectionmutations.ts","./src/api/mutations/companymutations.ts","./src/api/mutations/customermutations.ts","./src/api/mutations/draftmutations.ts","./src/api/mutations/fiscalyearmutations.ts","./src/api/mutations/invoicemutations.ts","./src/api/mutations/ordermutations.ts","./src/api/mutations/productmutations.ts","./src/api/mutations/saftmutations.ts","./src/api/queries/accountqueries.ts","./src/api/queries/bankconnectionqueries.ts","./src/api/queries/banktransactionqueries.ts","./src/api/queries/companyqueries.ts","./src/api/queries/customerqueries.ts","./src/api/queries/draftqueries.ts","./src/api/queries/fiscalyearqueries.ts","./src/api/queries/invoicequeries.ts","./src/api/queries/orderqueries.ts","./src/api/queries/productqueries.ts","./src/api/queries/vatqueries.ts","./src/components/auth/companyguard.tsx","./src/components/auth/protectedroute.tsx","./src/components/bank-reconciliation/documentuploadmodal.tsx","./src/components/company/useraccessmanager.tsx","./src/components/kassekladde/balanceimpactpanel.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/settings/bankconnectionstab.tsx","./src/components/shared/amounttext.tsx","./src/components/shared/attachmentupload.tsx","./src/components/shared/commandpalette.tsx","./src/components/shared/confirmationmodal.tsx","./src/components/shared/demodatadisclaimer.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/fullpagedropzone.tsx","./src/components/shared/hotkeyprovider.tsx","./src/components/shared/isodatepicker.tsx","./src/components/shared/pageheader.tsx","./src/components/shared/periodfilter.tsx","./src/components/shared/shortcuttooltip.tsx","./src/components/shared/shortcutshelpmodal.tsx","./src/components/shared/skeletonloader.tsx","./src/components/shared/statisticcard.tsx","./src/components/shared/statusbadge.tsx","./src/components/shared/index.ts","./src/components/tables/datatable.tsx","./src/hooks/useautosave.ts","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/usepagehotkeys.ts","./src/hooks/useperiod.ts","./src/hooks/useresponsivemodal.ts","./src/lib/accounting.ts","./src/lib/errorhandling.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/keyboardshortcuts.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/admin.tsx","./src/pages/bankafstemning.tsx","./src/pages/companysetupwizard.tsx","./src/pages/dashboard.tsx","./src/pages/eksport.tsx","./src/pages/fakturaer.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/kreditnotaer.tsx","./src/pages/kunder.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/ordrer.tsx","./src/pages/produkter.tsx","./src/pages/settings.tsx","./src/pages/usersettings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/hotkeystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/uistore.ts","./src/styles/designtokens.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/order.ts","./src/types/periods.ts","./src/types/product.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}