Initial commit: Books accounting system with EventFlow CQRS

Backend (.NET 10):
- EventFlow CQRS/Event Sourcing with PostgreSQL
- GraphQL.NET API with mutations and queries
- Custom ReadModelSqlGenerator for snake_case PostgreSQL columns
- Hangfire for background job processing
- Integration tests with isolated test databases

Frontend (React/Vite):
- Initial project structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-18 02:52:30 +01:00
commit 66f6fa138d
126 changed files with 24741 additions and 0 deletions

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bogfoering - Regnskabssystem</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6459
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "books",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.2.1",
"@ant-design/icons": "^5.5.1",
"@tanstack/react-query": "^5.62.7",
"antd": "^5.22.3",
"dayjs": "^1.11.13",
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}

18
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,18 @@
import { BrowserRouter } from 'react-router-dom';
import { App as AntApp } from 'antd';
import AppRoutes from './routes';
import AppLayout from './components/layout/AppLayout';
function App() {
return (
<AntApp>
<BrowserRouter>
<AppLayout>
<AppRoutes />
</AppLayout>
</BrowserRouter>
</AntApp>
);
}
export default App;

View file

@ -0,0 +1,73 @@
import { GraphQLClient } from 'graphql-request';
import { QueryClient } from '@tanstack/react-query';
// GraphQL endpoint - configure based on environment
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
// Create GraphQL client
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
headers: {
// Add auth headers here when authentication is implemented
// 'Authorization': `Bearer ${token}`,
},
});
// Configure headers dynamically (for auth tokens, etc.)
export const setAuthHeader = (token: string) => {
graphqlClient.setHeader('Authorization', `Bearer ${token}`);
};
export const removeAuthHeader = () => {
graphqlClient.setHeader('Authorization', '');
};
// Create TanStack Query client with default options
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache data for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 30 minutes
gcTime: 30 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Retry delay with exponential backoff
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Refetch on window focus for fresh data
refetchOnWindowFocus: true,
},
mutations: {
// Retry mutations once
retry: 1,
},
},
});
// Helper function to make GraphQL requests with error handling
export async function fetchGraphQL<TData, TVariables extends Record<string, unknown>>(
query: string,
variables?: TVariables
): Promise<TData> {
try {
const data = await graphqlClient.request<TData>(query, variables);
return data;
} catch (error) {
// Log error for debugging
console.error('GraphQL Error:', error);
// Re-throw with more context
if (error instanceof Error) {
throw new Error(`GraphQL request failed: ${error.message}`);
}
throw error;
}
}
// Type-safe query helper
export function createQueryKey(base: string, params?: Record<string, unknown>): string[] {
const key = [base];
if (params) {
key.push(JSON.stringify(params));
}
return key;
}

View file

@ -0,0 +1,40 @@
import { Layout } from 'antd';
import { ReactNode } from 'react';
import Sidebar from './Sidebar';
import Header from './Header';
import { useUIStore } from '@/stores/uiStore';
const { Content } = Layout;
interface AppLayoutProps {
children: ReactNode;
}
export default function AppLayout({ children }: AppLayoutProps) {
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sidebar />
<Layout
style={{
marginLeft: sidebarCollapsed ? 80 : 220,
transition: 'margin-left 0.2s',
}}
>
<Header />
<Content
style={{
margin: '16px',
padding: '16px',
background: '#fff',
borderRadius: 8,
minHeight: 'calc(100vh - 64px - 32px)',
}}
>
{children}
</Content>
</Layout>
</Layout>
);
}

View file

@ -0,0 +1,94 @@
import { Select, Space, Typography, Tag } from 'antd';
import { ShopOutlined } from '@ant-design/icons';
import { useCompanyStore } from '@/stores/companyStore';
import { formatCVR } from '@/lib/formatters';
import type { Company } from '@/types/accounting';
const { Text } = Typography;
// Mock data - will be replaced with API call
const mockCompanies: Company[] = [
{
id: '1',
name: 'Demo Virksomhed ApS',
cvr: '12345678',
address: 'Hovedgaden 1',
city: 'Koebenhavn',
postalCode: '1000',
country: 'DK',
fiscalYearStart: 1,
currency: 'DKK',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: '2',
name: 'Anden Virksomhed A/S',
cvr: '87654321',
address: 'Sidegaden 2',
city: 'Aarhus',
postalCode: '8000',
country: 'DK',
fiscalYearStart: 7,
currency: 'DKK',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
export default function CompanySwitcher() {
const { activeCompany, setActiveCompany, setCompanies } = useCompanyStore();
// Initialize with mock data if needed
if (useCompanyStore.getState().companies.length === 0) {
setCompanies(mockCompanies);
if (!activeCompany) {
setActiveCompany(mockCompanies[0]);
}
}
const companies = useCompanyStore((state) => state.companies);
const handleCompanyChange = (companyId: string) => {
const company = companies.find((c) => c.id === companyId);
if (company) {
setActiveCompany(company);
}
};
return (
<Space>
<ShopOutlined style={{ fontSize: 18, color: '#1677ff' }} />
<Select
value={activeCompany?.id}
onChange={handleCompanyChange}
style={{ minWidth: 280 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
options={companies.map((company) => ({
value: company.id,
label: company.name,
company,
}))}
optionRender={(option) => {
const company = option.data.company as Company;
return (
<Space direction="vertical" size={0} style={{ padding: '4px 0' }}>
<Space>
<Text strong>{company.name}</Text>
{company.id === activeCompany?.id && (
<Tag color="blue" style={{ marginLeft: 8 }}>
Aktiv
</Tag>
)}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
CVR: {formatCVR(company.cvr)}
</Text>
</Space>
);
}}
/>
</Space>
);
}

View file

@ -0,0 +1,242 @@
// FiscalYearSelector - Dropdown for selecting active fiscal year (regnskabsår)
import { useState, useEffect } from 'react';
import { Select, Space, Typography, Tag, Divider, Button } from 'antd';
import {
CalendarOutlined,
PlusOutlined,
SettingOutlined,
CheckCircleOutlined,
MinusCircleOutlined,
LockOutlined,
} from '@ant-design/icons';
import { usePeriodStore } from '@/stores/periodStore';
import type { FiscalYear } from '@/types/periods';
import { formatDateShort } from '@/lib/formatters';
import CreateFiscalYearModal from '@/components/modals/CreateFiscalYearModal';
const { Text } = Typography;
// Mock data - will be replaced with API call
const mockFiscalYears: FiscalYear[] = [
{
id: 'fy-2025',
companyId: '1',
name: '2025',
startDate: '2025-01-01',
endDate: '2025-12-31',
status: 'open',
openingBalancePosted: true,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
{
id: 'fy-2024',
companyId: '1',
name: '2024',
startDate: '2024-01-01',
endDate: '2024-12-31',
status: 'closed',
closingDate: '2025-01-15',
closedBy: 'user-1',
openingBalancePosted: true,
createdAt: '2024-01-01',
updatedAt: '2025-01-15',
},
{
id: 'fy-2023',
companyId: '1',
name: '2023',
startDate: '2023-01-01',
endDate: '2023-12-31',
status: 'locked',
closingDate: '2024-01-20',
closedBy: 'user-1',
openingBalancePosted: true,
createdAt: '2023-01-01',
updatedAt: '2024-01-20',
},
];
/**
* Status badge configuration
*/
const STATUS_CONFIG: Record<FiscalYear['status'], {
color: string;
icon: React.ReactNode;
label: string;
}> = {
open: {
color: 'success',
icon: <CheckCircleOutlined />,
label: 'Åben',
},
closed: {
color: 'warning',
icon: <MinusCircleOutlined />,
label: 'Lukket',
},
locked: {
color: 'error',
icon: <LockOutlined />,
label: 'Låst',
},
};
interface FiscalYearSelectorProps {
onCreateNew?: () => void;
onManage?: () => void;
}
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
const {
fiscalYears,
currentFiscalYear,
setFiscalYears,
setCurrentFiscalYear,
} = usePeriodStore();
const [createModalOpen, setCreateModalOpen] = useState(false);
// Initialize with mock data if needed (will be replaced with API call)
useEffect(() => {
if (fiscalYears.length === 0) {
setFiscalYears(mockFiscalYears);
}
}, [fiscalYears.length, setFiscalYears]);
// Set default fiscal year if none selected
useEffect(() => {
if (fiscalYears.length > 0 && !currentFiscalYear) {
// Default to most recent open year, or first year
const openYear = fiscalYears.find(y => y.status === 'open');
setCurrentFiscalYear(openYear || fiscalYears[0]);
}
}, [fiscalYears, currentFiscalYear, setCurrentFiscalYear]);
const handleFiscalYearChange = (yearId: string) => {
const year = fiscalYears.find((y) => y.id === yearId);
if (year) {
setCurrentFiscalYear(year);
}
};
const handleCreateNew = () => {
if (onCreateNew) {
onCreateNew();
} else {
setCreateModalOpen(true);
}
};
const handleCloseCreateModal = () => {
setCreateModalOpen(false);
};
const handleCreateSuccess = (newYear: FiscalYear) => {
setCurrentFiscalYear(newYear);
setCreateModalOpen(false);
};
const handleManage = () => {
if (onManage) {
onManage();
} else {
// Navigate to settings page
console.log('Navigate to fiscal year settings');
}
};
// Sort fiscal years by start date descending (newest first)
const sortedYears = [...fiscalYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
return (
<Space>
<CalendarOutlined style={{ fontSize: 16, color: '#8c8c8c' }} />
<Select
value={currentFiscalYear?.id}
onChange={handleFiscalYearChange}
style={{ minWidth: 200 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Space style={{ padding: '0 8px 8px' }}>
<Button
type="text"
icon={<PlusOutlined />}
onClick={handleCreateNew}
size="small"
>
Opret nyt regnskabsår
</Button>
<Button
type="text"
icon={<SettingOutlined />}
onClick={handleManage}
size="small"
>
Administrer
</Button>
</Space>
</>
)}
options={sortedYears.map((year) => ({
value: year.id,
label: `Regnskabsår ${year.name}`,
year,
}))}
optionRender={(option) => {
const year = option.data.year;
// Type guard - ensure year exists and has required properties
if (!year || typeof year !== 'object' || !('status' in year)) {
return null;
}
const fiscalYear = year as FiscalYear;
const statusConfig = STATUS_CONFIG[fiscalYear.status];
return (
<Space
direction="vertical"
size={0}
style={{ padding: '4px 0', width: '100%' }}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{fiscalYear.name}</Text>
<Tag
color={statusConfig.color}
icon={statusConfig.icon}
style={{ marginLeft: 8 }}
>
{statusConfig.label}
</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(fiscalYear.startDate)} - {formatDateShort(fiscalYear.endDate)}
</Text>
</Space>
);
}}
/>
{currentFiscalYear && (
<Tag
color={STATUS_CONFIG[currentFiscalYear.status].color}
icon={STATUS_CONFIG[currentFiscalYear.status].icon}
>
{STATUS_CONFIG[currentFiscalYear.status].label}
</Tag>
)}
{/* Create Fiscal Year Modal */}
<CreateFiscalYearModal
open={createModalOpen}
onClose={handleCloseCreateModal}
onSuccess={handleCreateSuccess}
/>
</Space>
);
}

View file

@ -0,0 +1,113 @@
import { Layout, Space, Button, Dropdown, Avatar, Typography, Divider } from 'antd';
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
BellOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import CompanySwitcher from './CompanySwitcher';
import FiscalYearSelector from './FiscalYearSelector';
const { Header: AntHeader } = Layout;
const { Text } = Typography;
export default function Header() {
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: 'Min profil',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: 'Indstillinger',
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Log ud',
danger: true,
},
];
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'logout':
// Handle logout
console.log('Logout clicked');
break;
case 'settings':
// Navigate to settings
break;
case 'profile':
// Navigate to profile
break;
}
};
return (
<AntHeader
style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
{/* Left side - Company Switcher and Fiscal Year Selector */}
<Space split={<Divider type="vertical" style={{ height: 24, margin: '0 8px' }} />}>
<CompanySwitcher />
<FiscalYearSelector />
</Space>
{/* Right side - User actions */}
<Space size="middle">
{/* Help */}
<Button
type="text"
icon={<QuestionCircleOutlined />}
title="Hjaelp"
/>
{/* Notifications */}
<Button
type="text"
icon={<BellOutlined />}
title="Notifikationer"
/>
{/* User Menu */}
<Dropdown
menu={{
items: userMenuItems,
onClick: handleUserMenuClick,
}}
placement="bottomRight"
trigger={['click']}
>
<Space style={{ cursor: 'pointer' }}>
<Avatar
size="small"
icon={<UserOutlined />}
style={{ backgroundColor: '#1677ff' }}
/>
<Text style={{ maxWidth: 120 }} ellipsis>
Bruger
</Text>
</Space>
</Dropdown>
</Space>
</AntHeader>
);
}

View file

@ -0,0 +1,135 @@
import { Layout, Menu } from 'antd';
import {
DashboardOutlined,
BookOutlined,
BankOutlined,
AccountBookOutlined,
PercentageOutlined,
TeamOutlined,
SettingOutlined,
FileTextOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useUIStore } from '@/stores/uiStore';
import type { MenuProps } from 'antd';
const { Sider } = Layout;
type MenuItem = Required<MenuProps>['items'][number];
function getItem(
label: React.ReactNode,
key: string,
icon?: React.ReactNode,
children?: MenuItem[]
): MenuItem {
return {
key,
icon,
children,
label,
} as MenuItem;
}
const menuItems: MenuItem[] = [
getItem('Dashboard', '/', <DashboardOutlined />),
getItem('Bogfoering', 'accounting', <BookOutlined />, [
getItem('Hurtig Bogfoering', '/hurtig-bogforing', <ThunderboltOutlined />),
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
]),
getItem('Bank', 'bank', <BankOutlined />, [
getItem('Bankafstemning', '/bankafstemning', <BankOutlined />),
]),
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
]),
getItem('Indstillinger', '/indstillinger', <SettingOutlined />),
];
export default function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useUIStore();
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key.startsWith('/')) {
navigate(key);
}
};
// Determine selected keys based on current path
const selectedKeys = [location.pathname];
// Determine open keys for submenus
const getOpenKeys = () => {
const path = location.pathname;
if (path === '/kassekladde' || path === '/kontooversigt' || path === '/hurtig-bogforing') {
return ['accounting'];
}
if (path === '/bankafstemning') {
return ['bank'];
}
if (path === '/momsindberetning' || path === '/loenforstaelse') {
return ['reporting'];
}
return [];
};
return (
<Sider
collapsible
collapsed={sidebarCollapsed}
onCollapse={toggleSidebar}
width={220}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 100,
}}
>
{/* Logo */}
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<span
style={{
color: '#fff',
fontSize: sidebarCollapsed ? 16 : 18,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{sidebarCollapsed ? 'B' : 'Bogfoering'}
</span>
</div>
{/* Navigation Menu */}
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}
/>
</Sider>
);
}

View file

@ -0,0 +1,521 @@
// CloseFiscalYearWizard - Multi-step wizard for year-end closing (årsafslutning)
import { useState, useEffect, useMemo } from 'react';
import {
Modal,
Steps,
Button,
Alert,
Typography,
Table,
Statistic,
Row,
Col,
Card,
Select,
Divider,
Checkbox,
Result,
Tag,
Form,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
LockOutlined,
ArrowRightOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { usePeriodStore } from '@/stores/periodStore';
import {
calculateClosingSummary,
validateFiscalYearClose,
generateClosingEntries,
calculateClosingBalances,
type FiscalYearClosingSummary,
type GeneratedClosingEntry,
} from '@/lib/fiscalYear';
import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
const { Text, Title, Paragraph } = Typography;
interface CloseFiscalYearWizardProps {
open: boolean;
fiscalYear: FiscalYear;
accounts: Account[];
transactions: Transaction[];
onClose: () => void;
onSuccess?: () => void;
}
type WizardStep = 'validation' | 'summary' | 'transfer' | 'confirm' | 'complete';
// Default "Overført resultat" account (equity)
const DEFAULT_RESULT_ACCOUNT = {
id: 'acc-3900',
accountNumber: '3900',
name: 'Overført resultat',
};
export default function CloseFiscalYearWizard({
open,
fiscalYear,
accounts,
transactions,
onClose,
onSuccess,
}: CloseFiscalYearWizardProps) {
const [currentStep, setCurrentStep] = useState<WizardStep>('validation');
const [isSubmitting, setIsSubmitting] = useState(false);
const [resultAccountId, setResultAccountId] = useState<string>(DEFAULT_RESULT_ACCOUNT.id);
const [createNextYear, setCreateNextYear] = useState(true);
const [closeOpenPeriods, setCloseOpenPeriods] = useState(true);
const [confirmLock, setConfirmLock] = useState(false);
const {
periods,
closeFiscalYear,
lockFiscalYear,
closePeriod,
lockPeriod,
} = usePeriodStore();
// Reset wizard when opened
useEffect(() => {
if (open) {
setCurrentStep('validation');
setIsSubmitting(false);
setConfirmLock(false);
}
}, [open]);
// Calculate closing balances
const closingBalances = useMemo(
() => calculateClosingBalances(fiscalYear, accounts, transactions),
[fiscalYear, accounts, transactions]
);
// Calculate summary
const summary: FiscalYearClosingSummary = useMemo(
() => calculateClosingSummary(fiscalYear, periods, closingBalances, transactions),
[fiscalYear, periods, closingBalances, transactions]
);
// Validate fiscal year close
const validation = useMemo(
() => validateFiscalYearClose(fiscalYear, periods),
[fiscalYear, periods]
);
// Generate closing entries preview
const closingEntries: GeneratedClosingEntry[] = useMemo(() => {
const resultAccount = accounts.find((a) => a.id === resultAccountId) || {
id: resultAccountId,
accountNumber: DEFAULT_RESULT_ACCOUNT.accountNumber,
name: DEFAULT_RESULT_ACCOUNT.name,
};
return generateClosingEntries(
fiscalYear,
closingBalances,
resultAccount.id,
resultAccount.accountNumber,
resultAccount.name
);
}, [fiscalYear, closingBalances, resultAccountId, accounts]);
// Equity accounts for result transfer
const equityAccounts = useMemo(
() => accounts.filter((a) => a.type === 'equity'),
[accounts]
);
// Year periods
const yearPeriods = useMemo(
() => periods.filter((p) => p.fiscalYearId === fiscalYear.id),
[periods, fiscalYear.id]
);
const openPeriodsInYear = yearPeriods.filter((p) => p.status === 'open');
const handleNext = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex < steps.length - 1) {
setCurrentStep(steps[currentIndex + 1]);
}
};
const handlePrevious = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex > 0) {
setCurrentStep(steps[currentIndex - 1]);
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
// 1. Close open periods if requested
if (closeOpenPeriods) {
for (const period of openPeriodsInYear) {
closePeriod(period.id, 'system');
}
}
// 2. Lock all periods in the year
for (const period of yearPeriods) {
lockPeriod(period.id, 'system');
}
// 3. Close and lock the fiscal year
closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system');
// 4. Move to complete step
setCurrentStep('complete');
onSuccess?.();
} catch (error) {
console.error('Failed to close fiscal year:', error);
} finally {
setIsSubmitting(false);
}
};
const getStepNumber = (step: WizardStep): number => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
return steps.indexOf(step);
};
const renderValidationStep = () => (
<div>
<Title level={5}>Validering af regnskabsår {fiscalYear.name}</Title>
<Paragraph type="secondary">
Før årsafslutning kontrolleres regnskabsåret for eventuelle problemer.
</Paragraph>
{validation.errors.length > 0 && (
<Alert
type="error"
message="Fejl fundet"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.errors.map((err, idx) => (
<li key={idx}>{err.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{validation.warnings.length > 0 && (
<Alert
type="warning"
icon={<WarningOutlined />}
message="Advarsler"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.warnings.map((warn, idx) => (
<li key={idx}>{warn.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{summary.unreconciledCount > 0 && (
<Alert
type="info"
icon={<ExclamationCircleOutlined />}
message={`${summary.unreconciledCount} transaktioner er ikke afstemt`}
description="Det anbefales at afstemme alle transaktioner før årsafslutning."
style={{ marginBottom: 16 }}
/>
)}
{openPeriodsInYear.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={closeOpenPeriods}
onChange={(e) => setCloseOpenPeriods(e.target.checked)}
>
<Text>
Luk automatisk {openPeriodsInYear.length} åbne periode(r) ved årsafslutning
</Text>
</Checkbox>
</Card>
)}
{validation.isValid && validation.warnings.length === 0 && (
<Alert
type="success"
icon={<CheckCircleOutlined />}
message="Regnskabsåret er klar til afslutning"
description="Ingen fejl eller advarsler fundet."
/>
)}
</div>
);
const renderSummaryStep = () => (
<div>
<Title level={5}>Resultatoversigt for {fiscalYear.name}</Title>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card>
<Statistic
title="Samlet indtægt"
value={summary.totalRevenue}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Samlet udgift"
value={summary.totalExpenses}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Årsresultat"
value={summary.netResult}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: summary.netResult >= 0 ? '#3f8600' : '#cf1322' }}
prefix={summary.netResult >= 0 ? '+' : ''}
/>
</Card>
</Col>
</Row>
<Divider />
<Text type="secondary">
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
overført til egenkapitalen ved årsafslutning.
</Text>
</div>
);
const renderTransferStep = () => (
<div>
<Title level={5}>Resultatoverførsel</Title>
<Paragraph type="secondary">
Vælg hvilken egenkapitalkonto årsresultatet skal overføres til.
</Paragraph>
<Form.Item label="Egenkapitalkonto for resultatoverførsel">
<Select
value={resultAccountId}
onChange={setResultAccountId}
style={{ width: '100%' }}
options={[
...equityAccounts.map((account) => ({
value: account.id,
label: `${account.accountNumber} - ${account.name}`,
})),
{
value: DEFAULT_RESULT_ACCOUNT.id,
label: `${DEFAULT_RESULT_ACCOUNT.accountNumber} - ${DEFAULT_RESULT_ACCOUNT.name} (Standard)`,
},
]}
/>
</Form.Item>
<Alert
type="info"
message="Resultatoverførsel"
description={
<Text>
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} den valgte konto.
</Text>
}
/>
</div>
);
const renderConfirmStep = () => (
<div>
<Title level={5}>Bekræft årsafslutning</Title>
<Alert
type="warning"
icon={<LockOutlined />}
message="Permanent handling"
description="Når regnskabsåret er låst, kan det ikke genåbnes. Alle perioder vil også blive låst."
style={{ marginBottom: 16 }}
/>
<Card title="Lukkeposter der oprettes" size="small" style={{ marginBottom: 16 }}>
<Table
dataSource={closingEntries.map((entry, idx) => ({ ...entry, key: idx }))}
columns={[
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag>
{type === 'revenue-close' ? 'Luk indtægter' : 'Luk udgifter'}
</Tag>
),
},
{
title: 'Beskrivelse',
dataIndex: 'descriptionDanish',
key: 'description',
},
{
title: 'Beløb',
dataIndex: 'totalAmount',
key: 'amount',
align: 'right',
render: (amount: number) => formatCurrency(amount),
},
]}
pagination={false}
size="small"
/>
</Card>
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={createNextYear}
onChange={(e) => setCreateNextYear(e.target.checked)}
>
<Text>Opret automatisk næste regnskabsår</Text>
</Checkbox>
</Card>
<Alert
type="error"
icon={<ExclamationCircleOutlined />}
message="Bekræft låsning"
description={
<Checkbox
checked={confirmLock}
onChange={(e) => setConfirmLock(e.target.checked)}
style={{ marginTop: 8 }}
>
<Text strong>
Jeg bekræfter at jeg vil låse regnskabsår {fiscalYear.name} permanent.
Dette kan ikke fortrydes.
</Text>
</Checkbox>
}
/>
</div>
);
const renderCompleteStep = () => (
<Result
status="success"
icon={<CheckCircleOutlined />}
title="Årsafslutning gennemført"
subTitle={`Regnskabsår ${fiscalYear.name} er nu lukket og låst.`}
extra={[
<Button type="primary" key="close" onClick={onClose}>
Luk
</Button>,
]}
/>
);
const renderStepContent = () => {
switch (currentStep) {
case 'validation':
return renderValidationStep();
case 'summary':
return renderSummaryStep();
case 'transfer':
return renderTransferStep();
case 'confirm':
return renderConfirmStep();
case 'complete':
return renderCompleteStep();
default:
return null;
}
};
const canProceed = currentStep === 'validation' ? validation.isValid : true;
const isLastStep = currentStep === 'confirm';
const isComplete = currentStep === 'complete';
return (
<Modal
title={`Årsafslutning - ${fiscalYear.name}`}
open={open}
onCancel={onClose}
width={700}
footer={
isComplete
? null
: [
<Button key="cancel" onClick={onClose}>
Annuller
</Button>,
currentStep !== 'validation' && (
<Button key="prev" onClick={handlePrevious}>
Tilbage
</Button>
),
isLastStep ? (
<Button
key="submit"
type="primary"
danger
icon={<LockOutlined />}
loading={isSubmitting}
disabled={!confirmLock}
onClick={handleSubmit}
>
Gennemfør årsafslutning
</Button>
) : (
<Button
key="next"
type="primary"
onClick={handleNext}
disabled={!canProceed}
icon={<ArrowRightOutlined />}
>
Næste
</Button>
),
].filter(Boolean)
}
destroyOnClose
>
<Steps
current={getStepNumber(currentStep)}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: 'Validering' },
{ title: 'Oversigt' },
{ title: 'Overførsel' },
{ title: 'Bekræft' },
{ title: 'Færdig' },
]}
/>
{renderStepContent()}
</Modal>
);
}

View file

@ -0,0 +1,308 @@
// CreateFiscalYearModal - Modal for creating a new fiscal year (regnskabsår)
import { useState, useEffect } from 'react';
import {
Modal,
Form,
DatePicker,
Input,
Select,
Alert,
Space,
Typography,
Divider,
} from 'antd';
import {
CalendarOutlined,
InfoCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { usePeriodStore } from '@/stores/periodStore';
import { useCompanyStore } from '@/stores/companyStore';
import {
getFiscalYearBoundaries,
validateFiscalYearCreation,
} from '@/lib/fiscalYear';
import { generateAccountingPeriods } from '@/lib/periods';
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
const { Text } = Typography;
const { RangePicker } = DatePicker;
interface CreateFiscalYearModalProps {
open: boolean;
onClose: () => void;
onSuccess?: (fiscalYear: FiscalYear) => void;
suggestedDate?: string; // Pre-populate based on trigger context
autoCreate?: boolean; // If true, show confirmation mode for auto-creation
}
interface FormValues {
dateRange: [Dayjs, Dayjs];
name: string;
periodFrequency: PeriodFrequency;
}
export default function CreateFiscalYearModal({
open,
onClose,
onSuccess,
suggestedDate,
autoCreate = false,
}: CreateFiscalYearModalProps) {
const [form] = Form.useForm<FormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationResult, setValidationResult] = useState<ReturnType<typeof validateFiscalYearCreation> | null>(null);
const { activeCompany } = useCompanyStore();
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
// Calculate suggested fiscal year boundaries
useEffect(() => {
if (open && activeCompany) {
const dateToUse = suggestedDate || dayjs().format('YYYY-MM-DD');
const boundaries = getFiscalYearBoundaries(
dateToUse,
activeCompany.fiscalYearStart
);
form.setFieldsValue({
dateRange: [dayjs(boundaries.startDate), dayjs(boundaries.endDate)],
name: boundaries.name,
periodFrequency: 'monthly',
});
// Validate immediately
handleValidation(boundaries.startDate, boundaries.endDate);
}
}, [open, activeCompany, suggestedDate, form]);
const handleValidation = (startDate: string, endDate: string) => {
const result = validateFiscalYearCreation(
{ startDate, endDate },
fiscalYears
);
setValidationResult(result);
};
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
if (dates && dates[0] && dates[1]) {
const startDate = dates[0].format('YYYY-MM-DD');
const endDate = dates[1].format('YYYY-MM-DD');
// Auto-update name
const startYear = dates[0].year();
const endYear = dates[1].year();
const name = startYear === endYear ? `${startYear}` : `${startYear}/${endYear}`;
form.setFieldValue('name', name);
handleValidation(startDate, endDate);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
if (!activeCompany) {
throw new Error('No active company');
}
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()}`,
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
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
const now = new Date().toISOString();
const newPeriods = generatedPeriods.map((p, idx) => ({
...p,
id: `period-${newFiscalYear.id}-${idx + 1}`,
createdAt: now,
updatedAt: now,
}));
// Add to store
addFiscalYear(newFiscalYear);
setPeriods([...periods, ...newPeriods]);
// Set as current if this is the first or most recent
const allYears = [...fiscalYears, newFiscalYear];
const sortedYears = allYears.sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
if (sortedYears[0].id === newFiscalYear.id) {
setCurrentFiscalYear(newFiscalYear);
}
onSuccess?.(newFiscalYear);
onClose();
} catch (error) {
console.error('Failed to create fiscal year:', error);
} finally {
setIsSubmitting(false);
}
};
const hasErrors = validationResult?.errors && validationResult.errors.length > 0;
const hasWarnings = validationResult?.warnings && validationResult.warnings.length > 0;
return (
<Modal
title={
<Space>
<CalendarOutlined />
{autoCreate ? 'Opret nyt regnskabsår?' : 'Opret nyt regnskabsår'}
</Space>
}
open={open}
onCancel={onClose}
onOk={handleSubmit}
okText={autoCreate ? 'Ja, opret regnskabsår' : 'Opret regnskabsår'}
cancelText="Annuller"
okButtonProps={{
disabled: hasErrors || isSubmitting || !activeCompany,
loading: isSubmitting,
}}
width={520}
destroyOnClose
>
{!activeCompany && (
<Alert
type="error"
message="Ingen virksomhed valgt"
description="Du skal vælge en virksomhed før du kan oprette et regnskabsår."
style={{ marginBottom: 16 }}
/>
)}
{autoCreate && suggestedDate && (
<Alert
type="info"
icon={<InfoCircleOutlined />}
message="Dato udenfor eksisterende regnskabsår"
description={
<Text>
Datoen <strong>{dayjs(suggestedDate).format('D. MMMM YYYY')}</strong> falder
udenfor eksisterende regnskabsår. Vil du oprette et nyt regnskabsår?
</Text>
}
style={{ marginBottom: 16 }}
/>
)}
<Form
form={form}
layout="vertical"
initialValues={{
periodFrequency: 'monthly',
}}
>
<Form.Item
name="dateRange"
label="Periode"
rules={[{ required: true, message: 'Vælg start- og slutdato' }]}
>
<RangePicker
style={{ width: '100%' }}
format="DD-MM-YYYY"
onChange={handleDateRangeChange}
/>
</Form.Item>
<Form.Item
name="name"
label="Navn"
rules={[{ required: true, message: 'Indtast navn på regnskabsåret' }]}
>
<Input
placeholder="f.eks. 2025 eller 2024/2025"
prefix={<CalendarOutlined />}
/>
</Form.Item>
<Form.Item
name="periodFrequency"
label="Regnskabsperioder"
tooltip="Hvor ofte skal regnskabsperioder oprettes?"
>
<Select
options={[
{ value: 'monthly', label: 'Månedlig (12 perioder)' },
{ value: 'quarterly', label: 'Kvartalsvis (4 perioder)' },
{ value: 'half-yearly', label: 'Halvårlig (2 perioder)' },
{ value: 'yearly', label: 'Årlig (1 periode)' },
]}
/>
</Form.Item>
{/* Validation feedback */}
{hasErrors && (
<Alert
type="error"
message="Kan ikke oprette regnskabsår"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validationResult?.errors.map((err, idx) => (
<li key={idx}>{err.messageDanish}</li>
))}
</ul>
}
style={{ marginTop: 16 }}
/>
)}
{hasWarnings && !hasErrors && (
<Alert
type="warning"
icon={<WarningOutlined />}
message="Advarsler"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validationResult?.warnings.map((warn, idx) => (
<li key={idx}>{warn.messageDanish}</li>
))}
</ul>
}
style={{ marginTop: 16 }}
/>
)}
{validationResult?.isValid && !hasWarnings && (
<Alert
type="success"
message="Klar til oprettelse"
description="Regnskabsåret kan oprettes uden problemer."
style={{ marginTop: 16 }}
/>
)}
</Form>
{activeCompany && activeCompany.fiscalYearStart !== 1 && (
<>
<Divider style={{ margin: '16px 0' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
Din virksomhed har skævt regnskabsår (starter i måned {activeCompany.fiscalYearStart}).
Datoerne er automatisk justeret baseret dette.
</Text>
</>
)}
</Modal>
);
}

View file

@ -0,0 +1,220 @@
// AccountQuickPicker - Quick account selection with favorites
import { useState, useMemo } from 'react';
import { Select, Button, Space, Tag, Typography, Divider, Input } from 'antd';
import { StarOutlined, StarFilled, SearchOutlined } from '@ant-design/icons';
import type { Account } from '@/types/accounting';
import type { VATCode } from '@/types/vat';
import { VAT_CODE_CONFIG } from '@/lib/vatCodes';
import { useTopFavorites, useSimpleBookingStore } from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface AccountOption {
id: string;
accountNumber: string;
name: string;
type: string;
}
interface AccountQuickPickerProps {
accounts: AccountOption[];
value?: string;
onChange: (accountId: string, account: AccountOption) => void;
isExpense?: boolean;
favoriteLimit?: number;
placeholder?: string;
}
export function AccountQuickPicker({
accounts,
value,
onChange,
isExpense = true,
favoriteLimit = 6,
placeholder = 'Soeg efter konto...',
}: AccountQuickPickerProps) {
const [searchValue, setSearchValue] = useState('');
const topFavorites = useTopFavorites(favoriteLimit);
const { addFavoriteAccount, removeFavoriteAccount, incrementFavoriteUsage, favoriteAccounts } = useSimpleBookingStore();
// Filter accounts based on search
const filteredAccounts = useMemo(() => {
if (!searchValue) return accounts;
const search = searchValue.toLowerCase();
return accounts.filter(
(account) =>
account.accountNumber.toLowerCase().includes(search) ||
account.name.toLowerCase().includes(search)
);
}, [accounts, searchValue]);
// Check if an account is a favorite
const isFavorite = (accountId: string) =>
favoriteAccounts.some((f) => f.accountId === accountId);
// Handle account selection
const handleSelect = (accountId: string) => {
const account = accounts.find((a) => a.id === accountId);
if (account) {
onChange(accountId, account);
// Increment favorite usage if it's a favorite
if (isFavorite(accountId)) {
incrementFavoriteUsage(accountId);
}
}
};
// Handle favorite toggle
const handleToggleFavorite = (account: AccountOption, event: React.MouseEvent) => {
event.stopPropagation();
if (isFavorite(account.id)) {
removeFavoriteAccount(account.id);
} else {
addFavoriteAccount({
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
defaultVATCode: isExpense ? 'K25' : 'S25',
});
}
};
// Get quick buttons for favorites
const quickButtons = topFavorites.map((fav) => {
const account = accounts.find((a) => a.id === fav.accountId);
if (!account) return null;
return (
<Button
key={fav.id}
size="small"
type={value === fav.accountId ? 'primary' : 'default'}
onClick={() => handleSelect(fav.accountId)}
style={{ marginRight: 4, marginBottom: 4 }}
>
{account.accountNumber} {account.name.substring(0, 12)}
{account.name.length > 12 ? '...' : ''}
</Button>
);
}).filter(Boolean);
// Select options
const selectOptions = filteredAccounts.map((account) => ({
value: account.id,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<Text strong style={{ marginRight: 8 }}>{account.accountNumber}</Text>
{account.name}
</span>
<span onClick={(e) => handleToggleFavorite(account, e)} style={{ cursor: 'pointer' }}>
{isFavorite(account.id) ? (
<StarFilled style={{ color: '#faad14' }} />
) : (
<StarOutlined style={{ color: '#d9d9d9' }} />
)}
</span>
</div>
),
searchValue: `${account.accountNumber} ${account.name}`,
}));
return (
<div className="account-quick-picker">
{/* Quick favorite buttons */}
{quickButtons.length > 0 && (
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Hurtige valg:
</Text>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{quickButtons}
</div>
</div>
)}
{/* Full search select */}
<Select
showSearch
style={{ width: '100%' }}
placeholder={placeholder}
value={value}
onChange={handleSelect}
onSearch={setSearchValue}
filterOption={false}
options={selectOptions}
optionFilterProp="searchValue"
suffixIcon={<SearchOutlined />}
notFoundContent={
<div style={{ padding: '8px', textAlign: 'center' }}>
<Text type="secondary">Ingen konti fundet</Text>
</div>
}
/>
</div>
);
}
interface VATCodePickerProps {
value?: VATCode;
onChange: (code: VATCode) => void;
isExpense?: boolean;
showDescription?: boolean;
}
export function VATCodePicker({
value,
onChange,
isExpense = true,
showDescription = false,
}: VATCodePickerProps) {
// Get relevant VAT codes based on transaction type
const relevantCodes = useMemo(() => {
if (isExpense) {
// Input VAT codes for expenses
return ['K25', 'EU_VARE', 'EU_YDELSE', 'NONE'] as VATCode[];
} else {
// Output VAT codes for income
return ['S25', 'MOMSFRI', 'EKSPORT', 'NONE'] as VATCode[];
}
}, [isExpense]);
const options = relevantCodes.map((code) => {
const config = VAT_CODE_CONFIG[code];
return {
value: code,
label: (
<div>
<Text strong style={{ marginRight: 8 }}>{code}</Text>
{config.nameDanish}
{config.rate > 0 && (
<Tag size="small" style={{ marginLeft: 8 }}>
{(config.rate * 100).toFixed(0)}%
</Tag>
)}
</div>
),
};
});
const selectedConfig = value ? VAT_CODE_CONFIG[value] : null;
return (
<div className="vat-code-picker">
<Select
style={{ width: '100%' }}
placeholder="Vaelg momskode"
value={value}
onChange={onChange}
options={options}
/>
{showDescription && selectedConfig && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
{selectedConfig.description}
</Text>
)}
</div>
);
}
export default AccountQuickPicker;

View file

@ -0,0 +1,165 @@
// BankTransactionCard - Card component for displaying a bank transaction
import { Card, Button, Space, Tag, Typography, Tooltip } from 'antd';
import { CheckOutlined, SplitCellsOutlined, BankOutlined } from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { PendingBankTransaction } from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface BankTransactionCardProps {
transaction: PendingBankTransaction;
onBook: (transaction: PendingBankTransaction) => void;
onSplit: (transaction: PendingBankTransaction) => void;
isSelected?: boolean;
disabled?: boolean;
}
export function BankTransactionCard({
transaction,
onBook,
onSplit,
isSelected = false,
disabled = false,
}: BankTransactionCardProps) {
const isExpense = transaction.amount < 0;
const amountColor = isExpense ? accountingColors.debit : accountingColors.credit;
return (
<Card
size="small"
className={`bank-transaction-card ${isSelected ? 'selected' : ''} ${transaction.isBooked ? 'booked' : ''}`}
style={{
marginBottom: 8,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
opacity: transaction.isBooked ? 0.6 : 1,
background: isSelected ? '#f0f5ff' : transaction.isBooked ? '#fafafa' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
{/* Left: Amount and description */}
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4 }}>
<Text
strong
style={{
fontSize: 16,
color: amountColor,
}}
>
{formatCurrency(transaction.amount)}
</Text>
</div>
<Text
style={{
display: 'block',
fontSize: 13,
color: '#262626',
lineHeight: 1.4,
}}
>
{transaction.description}
</Text>
<div style={{ marginTop: 6 }}>
<Space size={4}>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(transaction.date)}
</Text>
{transaction.counterparty && (
<>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{transaction.counterparty}
</Text>
</>
)}
</Space>
</div>
</div>
{/* Right: Actions */}
<div style={{ marginLeft: 16 }}>
{transaction.isBooked ? (
<Tag color="success" icon={<CheckOutlined />}>
Bogfoert
</Tag>
) : (
<Space>
<Tooltip title="Bogfoer">
<Button
type="primary"
size="small"
onClick={() => onBook(transaction)}
disabled={disabled}
>
Bogfoer
</Button>
</Tooltip>
<Tooltip title="Opdel paa flere konti">
<Button
size="small"
icon={<SplitCellsOutlined />}
onClick={() => onSplit(transaction)}
disabled={disabled}
>
Opdel...
</Button>
</Tooltip>
</Space>
)}
</div>
</div>
</Card>
);
}
interface BankTransactionListProps {
transactions: PendingBankTransaction[];
onBook: (transaction: PendingBankTransaction) => void;
onSplit: (transaction: PendingBankTransaction) => void;
showBooked?: boolean;
selectedId?: string;
disabled?: boolean;
}
export function BankTransactionList({
transactions,
onBook,
onSplit,
showBooked = false,
selectedId,
disabled = false,
}: BankTransactionListProps) {
const filteredTransactions = showBooked
? transactions
: transactions.filter((tx) => !tx.isBooked);
if (filteredTransactions.length === 0) {
return (
<Card style={{ textAlign: 'center', padding: '24px 0' }}>
<BankOutlined style={{ fontSize: 32, color: '#bfbfbf', marginBottom: 8 }} />
<div>
<Text type="secondary">Ingen uboerte banktransaktioner</Text>
</div>
</Card>
);
}
return (
<div className="bank-transaction-list">
{filteredTransactions.map((transaction) => (
<BankTransactionCard
key={transaction.id}
transaction={transaction}
onBook={onBook}
onSplit={onSplit}
isSelected={transaction.id === selectedId}
disabled={disabled}
/>
))}
</div>
);
}
export default BankTransactionCard;

View file

@ -0,0 +1,298 @@
// QuickBookModal - Modal for simple one-account booking
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Modal,
Form,
Input,
Card,
Table,
Typography,
Alert,
Divider,
Tag,
} from 'antd';
import { CheckCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { generateSimpleDoubleEntry, type SimpleBookingInput } from '@/lib/accounting';
import type { VATCode } from '@/types/vat';
import type { Account } from '@/types/accounting';
import { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
import {
useBookingPreview,
useIsBookingSaving,
useSimpleBookingStore,
} from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface QuickBookModalProps {
accounts: Account[];
onSubmit: (transaction: ReturnType<typeof generateSimpleDoubleEntry>) => Promise<void>;
}
export function QuickBookModal({ accounts, onSubmit }: QuickBookModalProps) {
const { modal, closeModal, setPreview } = useSimpleBookingStore();
const preview = useBookingPreview();
const isSaving = useIsBookingSaving();
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>();
const [selectedVATCode, setSelectedVATCode] = useState<VATCode>('K25');
const [description, setDescription] = useState('');
const bankTransaction = modal.bankTransaction;
const isOpen = modal.isOpen && modal.type === 'simple';
const isExpense = bankTransaction ? bankTransaction.amount < 0 : true;
// Track previous open state to detect modal opening
const wasOpenRef = useRef(false);
const lastTransactionIdRef = useRef<string | null>(null);
// Reset form only when modal opens or transaction changes
useEffect(() => {
const isNewlyOpened = isOpen && !wasOpenRef.current;
const isNewTransaction = bankTransaction && bankTransaction.id !== lastTransactionIdRef.current;
if (isOpen && bankTransaction && (isNewlyOpened || isNewTransaction)) {
// Calculate isExpense inside effect to avoid dependency
const expense = bankTransaction.amount < 0;
setSelectedAccountId(undefined);
setSelectedVATCode(expense ? 'K25' : 'S25');
setDescription(bankTransaction.description);
setPreview(null);
lastTransactionIdRef.current = bankTransaction.id;
}
wasOpenRef.current = isOpen;
}, [isOpen, bankTransaction, setPreview]);
// Generate preview when inputs change
useEffect(() => {
if (!bankTransaction || !selectedAccountId) {
setPreview(null);
return;
}
const account = accounts.find((a) => a.id === selectedAccountId);
if (!account) {
setPreview(null);
return;
}
const input: SimpleBookingInput = {
bankTransaction: {
id: bankTransaction.id,
date: bankTransaction.date,
amount: bankTransaction.amount,
description: description || bankTransaction.description,
counterparty: bankTransaction.counterparty,
bankAccountId: bankTransaction.bankAccountId,
bankAccountNumber: bankTransaction.bankAccountNumber,
},
contraAccountId: account.id,
contraAccountNumber: account.accountNumber,
contraAccountName: account.name,
vatCode: selectedVATCode,
description: description || undefined,
};
const result = generateSimpleDoubleEntry(input);
setPreview(result);
}, [bankTransaction, selectedAccountId, selectedVATCode, description, accounts, setPreview]);
// Account options for picker
const accountOptions = useMemo(
() =>
accounts.map((a) => ({
id: a.id,
accountNumber: a.accountNumber,
name: a.name,
type: a.type,
})),
[accounts]
);
const handleAccountChange = (accountId: string) => {
setSelectedAccountId(accountId);
};
const handleSubmit = async () => {
if (!preview || !preview.isValid) return;
await onSubmit(preview);
closeModal();
};
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: { accountNumber: string; accountName: string }) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.debit }}>{formatCurrency(value)}</Text>
) : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.credit }}>{formatCurrency(value)}</Text>
) : null,
},
];
if (!bankTransaction) return null;
return (
<Modal
title="Bogfoer transaktion"
open={isOpen}
onCancel={closeModal}
onOk={handleSubmit}
okText="Bogfoer"
cancelText="Annuller"
okButtonProps={{
disabled: !preview?.isValid || isSaving,
loading: isSaving,
}}
width={600}
destroyOnClose
>
{/* Bank transaction summary */}
<Card
size="small"
style={{
marginBottom: 16,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
}}
>
<div>
<Text
strong
style={{
fontSize: 18,
color: isExpense ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(bankTransaction.amount)}
</Text>
</div>
<Text>{bankTransaction.description}</Text>
<div style={{ marginTop: 4 }}>
<Text type="secondary">{formatDateShort(bankTransaction.date)}</Text>
{bankTransaction.counterparty && (
<>
<Text type="secondary"> </Text>
<Text type="secondary">{bankTransaction.counterparty}</Text>
</>
)}
</div>
</Card>
{/* Account selection */}
<Form layout="vertical">
<Form.Item label="Vaelg konto" required>
<AccountQuickPicker
accounts={accountOptions}
value={selectedAccountId}
onChange={handleAccountChange}
isExpense={isExpense}
placeholder="Soeg efter konto..."
/>
</Form.Item>
<Form.Item label="Momskode" required>
<VATCodePicker
value={selectedVATCode}
onChange={setSelectedVATCode}
isExpense={isExpense}
showDescription
/>
</Form.Item>
<Form.Item label="Beskrivelse (valgfri)">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={bankTransaction.description}
/>
</Form.Item>
</Form>
{/* Preview */}
{preview && (
<>
<Divider style={{ margin: '16px 0' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ marginRight: 8 }}>Forhaandsvisning:</Text>
{preview.isValid ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
Balancerer
</Tag>
) : (
<Tag color="error" icon={<WarningOutlined />}>
Fejl
</Tag>
)}
</div>
{!preview.isValid && preview.validationMessage && (
<Alert
type="error"
message={preview.validationMessage}
style={{ marginBottom: 8 }}
/>
)}
<Table
dataSource={preview.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => {
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: accountingColors.debit }}>
{formatCurrency(totalDebit)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(totalCredit)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
</div>
</>
)}
</Modal>
);
}
export default QuickBookModal;

View file

@ -0,0 +1,472 @@
// SplitBookModal - Modal for splitting one bank transaction to multiple accounts
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Card,
Table,
Space,
Typography,
Alert,
Divider,
Tag,
Button,
Row,
Col,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
PlusOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { generateSplitDoubleEntry, type SplitBookingInput, type SplitBookingLine } from '@/lib/accounting';
import type { VATCode } from '@/types/vat';
import type { Account } from '@/types/accounting';
import { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
import {
useBookingPreview,
useIsBookingSaving,
useSimpleBookingStore,
} from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface SplitBookModalProps {
accounts: Account[];
onSubmit: (transaction: ReturnType<typeof generateSplitDoubleEntry>) => Promise<void>;
}
export function SplitBookModal({ accounts, onSubmit }: SplitBookModalProps) {
const {
modal,
closeModal,
setPreview,
splitState,
addSplitLine,
removeSplitLine,
clearSplitLines,
} = useSimpleBookingStore();
const preview = useBookingPreview();
const isSaving = useIsBookingSaving();
// New line form state
const [newLineAccountId, setNewLineAccountId] = useState<string | undefined>();
const [newLineAmount, setNewLineAmount] = useState<number | null>(null);
const [newLineVATCode, setNewLineVATCode] = useState<VATCode>('K25');
const [newLineDescription, setNewLineDescription] = useState('');
const bankTransaction = modal.bankTransaction;
const isOpen = modal.isOpen && modal.type === 'split';
const isExpense = bankTransaction ? bankTransaction.amount < 0 : true;
// Track previous open state to detect modal opening
const wasOpenRef = useRef(false);
const lastTransactionIdRef = useRef<string | null>(null);
// Reset form only when modal opens or transaction changes
useEffect(() => {
const isNewlyOpened = isOpen && !wasOpenRef.current;
const isNewTransaction = bankTransaction && bankTransaction.id !== lastTransactionIdRef.current;
if (isOpen && bankTransaction && (isNewlyOpened || isNewTransaction)) {
// Calculate isExpense inside effect to avoid dependency
const expense = bankTransaction.amount < 0;
clearSplitLines();
setNewLineAccountId(undefined);
setNewLineAmount(null);
setNewLineVATCode(expense ? 'K25' : 'S25');
setNewLineDescription('');
setPreview(null);
lastTransactionIdRef.current = bankTransaction.id;
}
wasOpenRef.current = isOpen;
}, [isOpen, bankTransaction, clearSplitLines, setPreview]);
// Generate preview when lines change
useEffect(() => {
if (!bankTransaction || splitState.lines.length === 0) {
setPreview(null);
return;
}
const input: SplitBookingInput = {
bankTransaction: {
id: bankTransaction.id,
date: bankTransaction.date,
amount: bankTransaction.amount,
description: bankTransaction.description,
counterparty: bankTransaction.counterparty,
bankAccountId: bankTransaction.bankAccountId,
bankAccountNumber: bankTransaction.bankAccountNumber,
},
lines: splitState.lines,
};
const result = generateSplitDoubleEntry(input);
setPreview(result);
}, [bankTransaction, splitState.lines, setPreview]);
// Account options for picker
const accountOptions = useMemo(
() =>
accounts.map((a) => ({
id: a.id,
accountNumber: a.accountNumber,
name: a.name,
type: a.type,
})),
[accounts]
);
// Add a new split line
const handleAddLine = () => {
if (!newLineAccountId || !newLineAmount) return;
const account = accounts.find((a) => a.id === newLineAccountId);
if (!account) return;
const newLine: SplitBookingLine = {
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
amount: newLineAmount,
vatCode: newLineVATCode,
description: newLineDescription || undefined,
};
addSplitLine(newLine);
// Reset form for next line
setNewLineAccountId(undefined);
setNewLineAmount(null);
setNewLineDescription('');
};
// Fill remaining amount
const handleFillRemaining = () => {
setNewLineAmount(splitState.remainingAmount);
};
const handleSubmit = async () => {
if (!preview || !preview.isValid) return;
await onSubmit(preview);
closeModal();
};
// Preview table columns
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: { accountNumber: string; accountName: string }) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.debit }}>{formatCurrency(value)}</Text>
) : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.credit }}>{formatCurrency(value)}</Text>
) : null,
},
];
// Split lines table columns
const splitLinesColumns = [
{
title: '#',
key: 'index',
width: 40,
render: (_: unknown, __: unknown, index: number) => index + 1,
},
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: SplitBookingLine) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Beloeb',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
render: (value: number) => formatCurrency(value),
},
{
title: 'Moms',
dataIndex: 'vatCode',
key: 'vatCode',
render: (code: VATCode) => <Tag>{code}</Tag>,
},
{
title: '',
key: 'actions',
width: 50,
render: (_: unknown, __: unknown, index: number) => (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeSplitLine(index)}
/>
),
},
];
if (!bankTransaction) return null;
const canAddLine =
newLineAccountId &&
newLineAmount &&
newLineAmount > 0 &&
newLineAmount <= splitState.remainingAmount + 0.01;
const canSubmit =
preview?.isValid && splitState.lines.length > 0 && splitState.remainingAmount < 0.01;
return (
<Modal
title="Opdel transaktion"
open={isOpen}
onCancel={closeModal}
onOk={handleSubmit}
okText="Bogfoer opdeling"
cancelText="Annuller"
okButtonProps={{
disabled: !canSubmit || isSaving,
loading: isSaving,
}}
width={800}
destroyOnClose
>
{/* Bank transaction summary */}
<Card
size="small"
style={{
marginBottom: 16,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
}}
>
<Row justify="space-between" align="middle">
<Col>
<Text>Banktransaktion:</Text>
<Text
strong
style={{
fontSize: 18,
marginLeft: 8,
color: isExpense ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(bankTransaction.amount)}
</Text>
<Text style={{ marginLeft: 16 }}>{bankTransaction.description}</Text>
</Col>
<Col>
<Text type="secondary">{formatDateShort(bankTransaction.date)}</Text>
</Col>
</Row>
</Card>
{/* Remaining amount indicator */}
<Alert
type={splitState.remainingAmount < 0.01 ? 'success' : 'warning'}
message={
<span>
Restbeloeb:{' '}
<Text strong style={{ color: splitState.remainingAmount < 0.01 ? accountingColors.credit : accountingColors.warning }}>
{formatCurrency(splitState.remainingAmount)}
</Text>
{splitState.remainingAmount < 0.01 && ' - Fuld fordeling'}
</span>
}
style={{ marginBottom: 16 }}
/>
{/* Existing split lines */}
{splitState.lines.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Fordeling:
</Text>
<Table
dataSource={splitState.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={splitLinesColumns}
pagination={false}
size="small"
/>
</div>
)}
{/* Add new line form */}
{splitState.remainingAmount > 0.01 && (
<Card size="small" title="Tilfoej linje" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={10}>
<Form.Item label="Konto" style={{ marginBottom: 8 }}>
<AccountQuickPicker
accounts={accountOptions}
value={newLineAccountId}
onChange={setNewLineAccountId}
isExpense={isExpense}
placeholder="Vaelg konto..."
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="Beloeb" style={{ marginBottom: 8 }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
value={newLineAmount}
onChange={(value) => setNewLineAmount(value)}
min={0.01}
max={splitState.remainingAmount}
precision={2}
placeholder="0,00"
addonAfter="kr"
/>
<Button onClick={handleFillRemaining} title="Udfyld resten">
Rest
</Button>
</Space.Compact>
</Form.Item>
</Col>
<Col span={5}>
<Form.Item label="Moms" style={{ marginBottom: 8 }}>
<VATCodePicker
value={newLineVATCode}
onChange={setNewLineVATCode}
isExpense={isExpense}
/>
</Form.Item>
</Col>
<Col span={3}>
<Form.Item label=" " style={{ marginBottom: 8 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddLine}
disabled={!canAddLine}
>
Tilfoej
</Button>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item label="Beskrivelse (valgfri)" style={{ marginBottom: 0 }}>
<Input
value={newLineDescription}
onChange={(e) => setNewLineDescription(e.target.value)}
placeholder={bankTransaction.description}
/>
</Form.Item>
</Col>
</Row>
</Card>
)}
{/* Preview */}
{preview && preview.lines.length > 0 && (
<>
<Divider style={{ margin: '16px 0' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ marginRight: 8 }}>
Forhaandsvisning af bilag:
</Text>
{preview.isValid ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
Balancerer
</Tag>
) : (
<Tag color="error" icon={<WarningOutlined />}>
Fejl
</Tag>
)}
</div>
{!preview.isValid && preview.validationMessage && (
<Alert
type="error"
message={preview.validationMessage}
style={{ marginBottom: 8 }}
/>
)}
<Table
dataSource={preview.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => {
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: accountingColors.debit }}>
{formatCurrency(totalDebit)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(totalCredit)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Text type="secondary">
Debet = Kredit{' '}
{preview.isValid ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : (
<WarningOutlined style={{ color: '#ff4d4f' }} />
)}
</Text>
</div>
</div>
</>
)}
</Modal>
);
}
export default SplitBookModal;

View file

@ -0,0 +1,6 @@
// Simple Booking Components - Export barrel
export { BankTransactionCard, BankTransactionList } from './BankTransactionCard';
export { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
export { QuickBookModal } from './QuickBookModal';
export { SplitBookModal } from './SplitBookModal';

View file

@ -0,0 +1,302 @@
import { Table, Button, Space, Tooltip, Empty, Typography } from 'antd';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import type { TableColumnType, TableProps } from 'antd';
import { useDataTable } from '@/hooks/useDataTable';
import { formatCurrency, formatDate, formatNumber } from '@/lib/formatters';
import { getAmountClass } from '@/lib/formatters';
import * as XLSX from 'xlsx';
import { useCallback, useMemo } from 'react';
const { Text } = Typography;
export interface DataTableColumn<T> extends Omit<TableColumnType<T>, 'render' | 'dataIndex'> {
dataIndex: string | string[];
title: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: unknown, record: T, index: number) => React.ReactNode;
columnType?: 'text' | 'number' | 'currency' | 'date' | 'boolean' | 'actions';
decimalPlaces?: number;
showSign?: boolean;
exportable?: boolean;
}
interface DataTableProps<T extends { id: string }> {
// Data source - either from hook or direct data
queryKey?: string[];
query?: string;
variables?: Record<string, unknown>;
dataPath?: string;
totalPath?: string;
// Or direct data
data?: T[];
loading?: boolean;
// Columns
columns: DataTableColumn<T>[];
// Pagination
pageSize?: number;
pageSizeOptions?: number[];
// Features
rowSelection?: 'single' | 'multiple' | false;
selectedRowKeys?: string[];
onSelectionChange?: (selectedRowKeys: string[], selectedRows: T[]) => void;
onRowClick?: (record: T) => void;
exportable?: boolean;
exportFilename?: string;
refreshable?: boolean;
// Row styling
rowClassName?: (record: T, index: number) => string;
// Actions
toolbarActions?: React.ReactNode;
toolbarTitle?: string;
// Empty state
emptyText?: string;
// Scroll
scroll?: TableProps<T>['scroll'];
}
export default function DataTable<T extends { id: string }>({
queryKey,
query,
variables,
dataPath,
totalPath,
data: directData,
loading: directLoading,
columns,
pageSize = 20,
rowSelection = false,
selectedRowKeys: externalSelectedKeys,
onSelectionChange,
onRowClick,
exportable = true,
exportFilename = 'export',
refreshable = true,
rowClassName,
toolbarActions,
toolbarTitle,
emptyText = 'Ingen data fundet',
scroll,
}: DataTableProps<T>) {
// Use hook if query is provided, otherwise use direct data
const hookEnabled = !!(queryKey && query && dataPath && totalPath);
const {
data: hookData,
total,
isLoading: hookLoading,
pagination,
handleTableChange,
refetch,
} = useDataTable<T>({
queryKey: queryKey || [],
query: query || '',
variables,
dataPath: dataPath || '',
totalPath: totalPath || '',
pageSize,
enabled: hookEnabled,
});
const data = hookEnabled ? hookData : (directData || []);
const isLoading = hookEnabled ? hookLoading : (directLoading || false);
// Process columns with default renders based on columnType
const processedColumns = useMemo(() => {
return columns.map((col) => {
const processedCol: TableColumnType<T> = {
...col,
sorter: col.sortable,
};
// Add default render based on columnType if no custom render
if (!col.render && col.columnType) {
switch (col.columnType) {
case 'currency':
processedCol.render = (value: unknown) => {
const numValue = typeof value === 'number' ? value : 0;
return (
<span className={`currency tabular-nums ${getAmountClass(numValue)}`}>
{formatCurrency(numValue, {
showSign: col.showSign,
decimalPlaces: col.decimalPlaces,
})}
</span>
);
};
processedCol.align = 'right';
break;
case 'number':
processedCol.render = (value: unknown) => {
const numValue = typeof value === 'number' ? value : 0;
return (
<span className="tabular-nums">
{formatNumber(numValue, col.decimalPlaces)}
</span>
);
};
processedCol.align = 'right';
break;
case 'date':
processedCol.render = (value: unknown) => {
if (!value) return '-';
return formatDate(value as string);
};
break;
case 'boolean':
processedCol.render = (value: unknown) => {
return value ? 'Ja' : 'Nej';
};
break;
}
} else if (col.render) {
processedCol.render = col.render;
}
return processedCol;
});
}, [columns]);
// Row selection config
const rowSelectionConfig = useMemo(() => {
if (!rowSelection) return undefined;
return {
type: rowSelection === 'single' ? ('radio' as const) : ('checkbox' as const),
selectedRowKeys: externalSelectedKeys,
onChange: (keys: React.Key[], rows: T[]) => {
onSelectionChange?.(keys as string[], rows);
},
};
}, [rowSelection, externalSelectedKeys, onSelectionChange]);
// Export to Excel
const handleExport = useCallback(() => {
const exportData = data.map((record) => {
const row: Record<string, unknown> = {};
columns.forEach((col) => {
if (col.exportable !== false) {
const value = Array.isArray(col.dataIndex)
? col.dataIndex.reduce(
(obj: unknown, k) =>
obj && typeof obj === 'object' ? (obj as Record<string, unknown>)[k] : undefined,
record
)
: record[col.dataIndex as keyof T];
// Format value for export
if (col.columnType === 'currency' && typeof value === 'number') {
row[col.title] = value;
} else if (col.columnType === 'date' && value) {
row[col.title] = formatDate(value as string);
} else {
row[col.title] = value;
}
}
});
return row;
});
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
const filename = `${exportFilename}_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename);
}, [data, columns, exportFilename]);
// Row click handler
const onRow = useCallback(
(record: T) => ({
onClick: () => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
}),
[onRowClick]
);
return (
<div>
{/* Toolbar */}
{(toolbarTitle || toolbarActions || exportable || refreshable) && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
{toolbarTitle && (
<Text strong style={{ fontSize: 16 }}>
{toolbarTitle}
</Text>
)}
</div>
<Space>
{toolbarActions}
{refreshable && hookEnabled && (
<Tooltip title="Genindlaes">
<Button
icon={<ReloadOutlined />}
onClick={() => refetch()}
loading={isLoading}
/>
</Tooltip>
)}
{exportable && data.length > 0 && (
<Tooltip title="Eksporter til Excel">
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
Eksporter
</Button>
</Tooltip>
)}
</Space>
</div>
)}
{/* Table */}
<Table<T>
columns={processedColumns}
dataSource={data}
rowKey="id"
loading={isLoading}
pagination={hookEnabled ? pagination : { pageSize, showSizeChanger: true }}
onChange={hookEnabled ? handleTableChange : undefined}
rowSelection={rowSelectionConfig}
onRow={onRow}
rowClassName={rowClassName}
scroll={scroll || { x: 'max-content' }}
size="small"
bordered
locale={{
emptyText: <Empty description={emptyText} />,
}}
style={{ width: '100%' }}
/>
{/* Footer with total count */}
{hookEnabled && total > 0 && (
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Text type="secondary">
Total: {total.toLocaleString('da-DK')} poster
</Text>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
import { useCompanyStore } from '@/stores/companyStore';
import { useCallback } from 'react';
import type { Company } from '@/types/accounting';
/**
* Hook for accessing and managing the current company context
*/
export function useCompany() {
const activeCompany = useCompanyStore((state) => state.activeCompany);
const companies = useCompanyStore((state) => state.companies);
const isLoading = useCompanyStore((state) => state.isLoading);
const setActiveCompany = useCompanyStore((state) => state.setActiveCompany);
/**
* Get the current company ID or throw if not set
*/
const requireCompanyId = useCallback((): string => {
if (!activeCompany) {
throw new Error('No company selected. Please select a company first.');
}
return activeCompany.id;
}, [activeCompany]);
/**
* Check if a company is selected
*/
const hasCompany = !!activeCompany;
/**
* Switch to a different company
*/
const switchCompany = useCallback(
(companyId: string) => {
const company = companies.find((c) => c.id === companyId);
if (company) {
setActiveCompany(company);
}
},
[companies, setActiveCompany]
);
/**
* Get company by ID
*/
const getCompanyById = useCallback(
(id: string): Company | undefined => {
return companies.find((c) => c.id === id);
},
[companies]
);
return {
// Current company
company: activeCompany,
companyId: activeCompany?.id,
companyName: activeCompany?.name,
// All companies
companies,
// State
isLoading,
hasCompany,
// Actions
requireCompanyId,
switchCompany,
getCompanyById,
};
}

View file

@ -0,0 +1,176 @@
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { graphqlClient } from '@/api/client';
import type { SorterResult, TablePaginationConfig } from 'antd/es/table/interface';
import type { FilterValue } from 'antd/es/table/interface';
interface UseDataTableOptions {
queryKey: string[];
query: string;
variables?: Record<string, unknown>;
dataPath: string;
totalPath: string;
pageSize?: number;
enabled?: boolean;
}
interface UseDataTableReturn<T> {
data: T[];
total: number;
isLoading: boolean;
isError: boolean;
error: Error | null;
pagination: TablePaginationConfig;
handleTableChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[]
) => void;
refetch: () => void;
currentPage: number;
currentPageSize: number;
sortField: string | undefined;
sortOrder: 'asc' | 'desc' | undefined;
filters: Record<string, unknown>;
setFilters: (filters: Record<string, unknown>) => void;
}
function getNestedValue(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, part: string) => {
if (acc && typeof acc === 'object' && part in acc) {
return (acc as Record<string, unknown>)[part];
}
return undefined;
}, obj);
}
export function useDataTable<T extends { id: string }>({
queryKey,
query,
variables = {},
dataPath,
totalPath,
pageSize = 20,
enabled = true,
}: UseDataTableOptions): UseDataTableReturn<T> {
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [sortField, setSortField] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
// Build query variables with pagination, sorting, and filtering
const queryVariables = useMemo(() => {
const vars: Record<string, unknown> = {
...variables,
first: currentPageSize,
after: currentPage > 1 ? String((currentPage - 1) * currentPageSize) : undefined,
};
if (sortField && sortOrder) {
vars.sort = {
field: sortField,
direction: sortOrder.toUpperCase(),
};
}
if (Object.keys(filters).length > 0) {
vars.filter = filters;
}
return vars;
}, [variables, currentPage, currentPageSize, sortField, sortOrder, filters]);
// Execute GraphQL query
const { data: response, isLoading, isError, error, refetch } = useQuery({
queryKey: [...queryKey, queryVariables],
queryFn: async () => {
return graphqlClient.request(query, queryVariables);
},
enabled,
});
// Extract data and total from response
const data = useMemo(() => {
if (!response) return [];
const items = getNestedValue(response, dataPath);
return Array.isArray(items) ? items : [];
}, [response, dataPath]) as T[];
const total = useMemo(() => {
if (!response) return 0;
const count = getNestedValue(response, totalPath);
return typeof count === 'number' ? count : 0;
}, [response, totalPath]);
// Pagination config for Ant Design Table
const pagination: TablePaginationConfig = useMemo(
() => ({
current: currentPage,
pageSize: currentPageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total, range) =>
`${range[0]}-${range[1]} af ${total} poster`,
}),
[currentPage, currentPageSize, total]
);
// Handle table changes (pagination, sorting, filtering)
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
tableFilters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[]
) => {
// Handle pagination
if (newPagination.current !== currentPage) {
setCurrentPage(newPagination.current || 1);
}
if (newPagination.pageSize !== currentPageSize) {
setCurrentPageSize(newPagination.pageSize || pageSize);
setCurrentPage(1); // Reset to first page on page size change
}
// Handle sorting
const singleSorter = Array.isArray(sorter) ? sorter[0] : sorter;
if (singleSorter.field && singleSorter.order) {
setSortField(String(singleSorter.field));
setSortOrder(singleSorter.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortField(undefined);
setSortOrder(undefined);
}
// Handle filters from table columns
const newFilters: Record<string, unknown> = {};
Object.entries(tableFilters).forEach(([key, value]) => {
if (value !== null && value.length > 0) {
newFilters[key] = value.length === 1 ? value[0] : value;
}
});
// Merge with existing external filters
setFilters((prev) => ({ ...prev, ...newFilters }));
},
[currentPage, currentPageSize, pageSize]
);
return {
data,
total,
isLoading,
isError,
error: error as Error | null,
pagination,
handleTableChange,
refetch,
currentPage,
currentPageSize,
sortField,
sortOrder,
filters,
setFilters,
};
}

View file

@ -0,0 +1,345 @@
// Period Hook - React hook for period context and validation
import { useMemo, useCallback } from 'react';
import { usePeriodStore } from '@/stores/periodStore';
import {
getPeriodForDate,
getPreviousPeriod,
getSamePeriodPreviousYear,
getYearToDateRange,
canPostToDate,
validatePeriodClose,
} from '@/lib/periods';
import type { AccountingPeriod, FiscalYear, PeriodStatus } from '@/types/periods';
import type { Transaction } from '@/types/accounting';
/**
* Hook for accessing period context in components
*/
export function usePeriodContext() {
const {
currentFiscalYear,
currentPeriod,
selectedPeriod,
selectedVATPeriod,
comparisonPeriod,
comparisonType,
periods,
fiscalYears,
vatPeriods,
isLoading,
} = usePeriodStore();
const effectivePeriod = selectedPeriod || currentPeriod;
return {
// Current context
currentFiscalYear,
currentPeriod,
selectedPeriod,
effectivePeriod,
selectedVATPeriod,
comparisonPeriod,
comparisonType,
// Lists
periods,
fiscalYears,
vatPeriods,
// Loading
isLoading,
};
}
/**
* Hook for period selection and navigation
*/
export function usePeriodSelector() {
const {
periods,
fiscalYears,
selectedPeriod,
setSelectedPeriod,
setComparisonPeriod,
clearComparison,
} = usePeriodStore();
const selectPeriod = useCallback(
(periodId: string) => {
const period = periods.find((p) => p.id === periodId);
if (period) {
setSelectedPeriod(period);
}
},
[periods, setSelectedPeriod]
);
const selectPreviousPeriod = useCallback(() => {
if (!selectedPeriod) return;
const previous = getPreviousPeriod(selectedPeriod, periods);
if (previous) {
setSelectedPeriod(previous);
}
}, [selectedPeriod, periods, setSelectedPeriod]);
const selectNextPeriod = useCallback(() => {
if (!selectedPeriod) return;
const currentIndex = periods.findIndex((p) => p.id === selectedPeriod.id);
if (currentIndex >= 0 && currentIndex < periods.length - 1) {
setSelectedPeriod(periods[currentIndex + 1]);
}
}, [selectedPeriod, periods, setSelectedPeriod]);
const enableComparison = useCallback(
(type: 'previous-period' | 'previous-year' | 'custom', customPeriod?: AccountingPeriod) => {
if (!selectedPeriod) return;
let comparisonPeriodData: AccountingPeriod | undefined;
if (type === 'previous-period') {
comparisonPeriodData = getPreviousPeriod(selectedPeriod, periods);
} else if (type === 'previous-year') {
comparisonPeriodData = getSamePeriodPreviousYear(selectedPeriod, periods);
} else if (type === 'custom' && customPeriod) {
comparisonPeriodData = customPeriod;
}
if (comparisonPeriodData) {
setComparisonPeriod(comparisonPeriodData, type);
}
},
[selectedPeriod, periods, setComparisonPeriod]
);
const disableComparison = useCallback(() => {
clearComparison();
}, [clearComparison]);
// Get periods grouped by fiscal year
const periodsByYear = useMemo(() => {
const grouped: Record<string, AccountingPeriod[]> = {};
for (const period of periods) {
if (!grouped[period.fiscalYearId]) {
grouped[period.fiscalYearId] = [];
}
grouped[period.fiscalYearId].push(period);
}
return grouped;
}, [periods]);
// Get open periods only
const openPeriods = useMemo(
() => periods.filter((p) => p.status === 'open'),
[periods]
);
return {
// State
selectedPeriod,
periods,
fiscalYears,
periodsByYear,
openPeriods,
// Actions
selectPeriod,
selectPreviousPeriod,
selectNextPeriod,
enableComparison,
disableComparison,
};
}
/**
* Hook for posting validation
*/
export function usePostingValidation() {
const { periods, periodSettings } = usePeriodStore();
const validatePostingDate = useCallback(
(date: string) => {
if (!periodSettings) {
// Default to strict validation if no settings
return canPostToDate(date, periods, {
preventPostingToClosedPeriods: true,
preventPostingToFuturePeriods: true,
});
}
return canPostToDate(date, periods, {
preventPostingToClosedPeriods: periodSettings.preventPostingToClosedPeriods,
preventPostingToFuturePeriods: periodSettings.preventPostingToFuturePeriods,
});
},
[periods, periodSettings]
);
const getPeriodStatus = useCallback(
(date: string): PeriodStatus | 'no-period' => {
const period = getPeriodForDate(date, periods);
return period?.status || 'no-period';
},
[periods]
);
const isDatePostable = useCallback(
(date: string): boolean => {
return validatePostingDate(date).allowed;
},
[validatePostingDate]
);
return {
validatePostingDate,
getPeriodStatus,
isDatePostable,
};
}
/**
* Hook for period management (closing, locking, etc.)
*/
export function usePeriodManagement() {
const {
periods,
closePeriod,
reopenPeriod,
lockPeriod,
updatePeriod,
} = usePeriodStore();
const canClosePeriod = useCallback(
(periodId: string, transactions: Transaction[]): { canClose: boolean; errors: string[]; warnings: string[] } => {
const period = periods.find((p) => p.id === periodId);
if (!period) {
return {
canClose: false,
errors: ['Periode ikke fundet'],
warnings: [],
};
}
const validation = validatePeriodClose(period, transactions, {
requireAllReconciled: true,
});
return {
canClose: validation.isValid,
errors: validation.errors.map((e) => e.messageDanish),
warnings: validation.warnings.map((w) => w.messageDanish),
};
},
[periods]
);
const closeAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
closePeriod(periodId, userId);
},
[closePeriod]
);
const reopenAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
reopenPeriod(periodId, userId);
},
[reopenPeriod]
);
const lockAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
lockPeriod(periodId, userId);
},
[lockPeriod]
);
const getPeriodActions = useCallback(
(periodId: string) => {
const period = periods.find((p) => p.id === periodId);
if (!period) return { canClose: false, canReopen: false, canLock: false };
return {
canClose: period.status === 'open',
canReopen: period.status === 'closed',
canLock: period.status === 'closed',
};
},
[periods]
);
return {
canClosePeriod,
closeAccountingPeriod,
reopenAccountingPeriod,
lockAccountingPeriod,
getPeriodActions,
};
}
/**
* Hook for year-to-date calculations
*/
export function useYearToDate() {
const { currentFiscalYear, selectedPeriod, periods } = usePeriodStore();
const ytdRange = useMemo(() => {
if (!currentFiscalYear || !selectedPeriod) return null;
return getYearToDateRange(selectedPeriod, currentFiscalYear);
}, [currentFiscalYear, selectedPeriod]);
const ytdPeriods = useMemo(() => {
if (!currentFiscalYear || !selectedPeriod) return [];
return periods.filter((p) => {
if (p.fiscalYearId !== currentFiscalYear.id) return false;
return p.periodNumber <= selectedPeriod.periodNumber;
});
}, [currentFiscalYear, selectedPeriod, periods]);
return {
ytdRange,
ytdPeriods,
fiscalYear: currentFiscalYear,
};
}
/**
* Combined hook for common period operations
*/
export function usePeriod() {
const context = usePeriodContext();
const selector = usePeriodSelector();
const validation = usePostingValidation();
const management = usePeriodManagement();
const ytd = useYearToDate();
return {
// Context
...context,
// Selector
selectPeriod: selector.selectPeriod,
selectPreviousPeriod: selector.selectPreviousPeriod,
selectNextPeriod: selector.selectNextPeriod,
enableComparison: selector.enableComparison,
disableComparison: selector.disableComparison,
periodsByYear: selector.periodsByYear,
openPeriods: selector.openPeriods,
// Validation
validatePostingDate: validation.validatePostingDate,
getPeriodStatus: validation.getPeriodStatus,
isDatePostable: validation.isDatePostable,
// Management
canClosePeriod: management.canClosePeriod,
closeAccountingPeriod: management.closeAccountingPeriod,
reopenAccountingPeriod: management.reopenAccountingPeriod,
lockAccountingPeriod: management.lockAccountingPeriod,
getPeriodActions: management.getPeriodActions,
// YTD
ytdRange: ytd.ytdRange,
ytdPeriods: ytd.ytdPeriods,
};
}

View file

@ -0,0 +1,586 @@
import type { TransactionLine, AccountType } from '@/types/accounting';
/**
* Validate that total debits equal total credits (double-entry principle)
*/
export function validateDoubleEntry(lines: TransactionLine[]): {
valid: boolean;
totalDebit: number;
totalCredit: number;
difference: number;
} {
const totalDebit = lines.reduce((sum, line) => sum + (line.debit || 0), 0);
const totalCredit = lines.reduce((sum, line) => sum + (line.credit || 0), 0);
const difference = Math.abs(totalDebit - totalCredit);
// Allow for small floating point differences (< 0.01)
const valid = difference < 0.01;
return {
valid,
totalDebit,
totalCredit,
difference,
};
}
/**
* Calculate line balance (debit - credit)
*/
export function calculateLineBalance(line: TransactionLine): number {
return (line.debit || 0) - (line.credit || 0);
}
/**
* Get account type from account number (Danish standard)
*/
export function getAccountTypeFromNumber(accountNumber: string): AccountType {
const num = parseInt(accountNumber, 10);
if (num >= 1000 && num < 2000) return 'asset';
if (num >= 2000 && num < 3000) return 'liability';
if (num >= 3000 && num < 4000) return 'equity';
if (num >= 4000 && num < 5000) return 'revenue';
if (num >= 5000 && num < 6000) return 'cogs';
if (num >= 6000 && num < 7000) return 'expense';
if (num >= 7000 && num < 8000) return 'personnel';
if (num >= 8000 && num < 9000) return 'financial';
if (num >= 9000 && num < 10000) return 'extraordinary';
return 'asset'; // Default fallback
}
/**
* Get Danish name for account type
*/
export function getAccountTypeName(type: AccountType): string {
const names: Record<AccountType, string> = {
asset: 'Aktiver',
liability: 'Passiver',
equity: 'Egenkapital',
revenue: 'Indtægter',
cogs: 'Vareforbrug',
expense: 'Driftsomkostninger',
personnel: 'Personaleomkostninger',
financial: 'Finansielle poster',
extraordinary: 'Ekstraordinære poster',
};
return names[type];
}
/**
* Get account number range for type
*/
export function getAccountNumberRange(type: AccountType): { min: number; max: number } {
const ranges: Record<AccountType, { min: number; max: number }> = {
asset: { min: 1000, max: 1999 },
liability: { min: 2000, max: 2999 },
equity: { min: 3000, max: 3999 },
revenue: { min: 4000, max: 4999 },
cogs: { min: 5000, max: 5999 },
expense: { min: 6000, max: 6999 },
personnel: { min: 7000, max: 7999 },
financial: { min: 8000, max: 8999 },
extraordinary: { min: 9000, max: 9999 },
};
return ranges[type];
}
/**
* Check if account is a balance sheet account (remains between periods)
*/
export function isBalanceSheetAccount(type: AccountType): boolean {
return ['asset', 'liability', 'equity'].includes(type);
}
/**
* Check if account is an income statement account (resets each period)
*/
export function isIncomeStatementAccount(type: AccountType): boolean {
return !isBalanceSheetAccount(type);
}
/**
* Calculate normal balance direction for account type
* Returns 'debit' or 'credit'
*/
export function getNormalBalance(type: AccountType): 'debit' | 'credit' {
// Asset and expense accounts normally have debit balances
// Liability, equity, and revenue accounts normally have credit balances
switch (type) {
case 'asset':
case 'cogs':
case 'expense':
case 'personnel':
case 'financial':
case 'extraordinary':
return 'debit';
case 'liability':
case 'equity':
case 'revenue':
return 'credit';
}
}
/**
* Danish VAT rates
*/
export const VAT_RATES = {
standard: 0.25, // 25% standard rate
reduced: 0, // No reduced rate in Denmark
zero: 0, // Zero-rated (export, etc.)
} as const;
// Note: VAT calculation functions (calculateVATFromGross, calculateVATFromNet)
// are available from '@/lib/vatCodes' - use those to avoid duplication
/**
* Standard Danish VAT codes
*/
export const VAT_CODES = {
S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' },
NONE: { code: 'NONE', name: 'Ingen moms', rate: 0, type: 'none' },
} as const;
/**
* Generate next transaction number
*/
export function generateTransactionNumber(lastNumber: string | null, prefix: string = ''): string {
if (!lastNumber) {
return `${prefix}1`;
}
const numPart = lastNumber.replace(/\D/g, '');
const nextNum = parseInt(numPart, 10) + 1;
return `${prefix}${nextNum}`;
}
// =====================================================
// AUTO-BOOKING FUNCTIONS (Simple Booking)
// =====================================================
import type { VATCode } from '@/types/vat';
import { VAT_CODE_CONFIG, VAT_ACCOUNTS } from '@/lib/vatCodes';
/**
* Bank transaction input for auto-booking
*/
export interface BankTransactionInput {
id: string;
date: string;
amount: number; // Negative = expense, Positive = income
description: string;
counterparty?: string;
bankAccountId: string;
bankAccountNumber: string;
}
/**
* Simple booking input (one bank transaction -> one contra account)
*/
export interface SimpleBookingInput {
bankTransaction: BankTransactionInput;
contraAccountId: string;
contraAccountNumber: string;
contraAccountName: string;
vatCode: VATCode;
description?: string;
}
/**
* Split booking line (for distributing one bank transaction to multiple accounts)
*/
export interface SplitBookingLine {
accountId: string;
accountNumber: string;
accountName: string;
amount: number; // Gross amount for this line
vatCode: VATCode;
description?: string;
}
/**
* Split booking input (one bank transaction -> multiple contra accounts)
*/
export interface SplitBookingInput {
bankTransaction: BankTransactionInput;
lines: SplitBookingLine[];
}
/**
* Generated transaction line with all details
*/
export interface GeneratedTransactionLine {
accountId: string;
accountNumber: string;
accountName: string;
description: string;
debit: number;
credit: number;
vatCode?: VATCode;
vatAmount?: number;
}
/**
* Generated transaction result
*/
export interface GeneratedTransaction {
date: string;
description: string;
lines: GeneratedTransactionLine[];
bankTransactionId: string;
isValid: boolean;
validationMessage?: string;
}
/**
* Generate simple double-entry from bank transaction
*
* For expense (negative bank amount, e.g., -15.000 kr husleje):
* 1. 6100 Husleje Debit: 12.000 kr (net)
* 2. 5610 Moms (ind) Debit: 3.000 kr (25% VAT)
* 3. 1000 Bank Credit: 15.000 kr (gross)
*
* For income (positive bank amount, e.g., +15.625 kr salg):
* 1. 1000 Bank Debit: 15.625 kr (gross)
* 2. 4000 Salg Credit: 12.500 kr (net)
* 3. 5710 Moms (udg) Credit: 3.125 kr (25% VAT)
*/
export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedTransaction {
const { bankTransaction, contraAccountId, contraAccountNumber, contraAccountName, vatCode } = input;
const description = input.description || bankTransaction.description;
const grossAmount = Math.abs(bankTransaction.amount);
const isExpense = bankTransaction.amount < 0;
const vatConfig = VAT_CODE_CONFIG[vatCode];
const lines: GeneratedTransactionLine[] = [];
// Calculate VAT if applicable
// Note: Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
let netAmount = grossAmount;
let vatAmount = 0;
if (vatConfig.rate > 0) {
const rawNet = grossAmount / (1 + vatConfig.rate);
netAmount = Math.round(rawNet * 100) / 100;
// Calculate VAT as difference to guarantee gross = net + vat
vatAmount = Math.round((grossAmount - netAmount) * 100) / 100;
}
if (isExpense) {
// Expense: Money leaving bank account
// Contra account (expense) gets debited with net amount
lines.push({
accountId: contraAccountId,
accountNumber: contraAccountNumber,
accountName: contraAccountName,
description,
debit: netAmount,
credit: 0,
vatCode,
vatAmount: vatConfig.rate > 0 ? vatAmount : undefined,
});
// VAT account (input VAT) gets debited if applicable
if (vatAmount > 0 && vatConfig.deductible) {
lines.push({
accountId: `vat-input-${vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
description: `Moms: ${description}`,
debit: vatAmount,
credit: 0,
});
}
// For reverse charge (EU purchases), also credit output VAT
if (vatConfig.reverseCharge && vatAmount > 0) {
const outputVatAccount = vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
lines.push({
accountId: `vat-output-${vatCode}`,
accountNumber: outputVatAccount,
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
});
}
// Bank account gets credited with gross amount
lines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description,
debit: 0,
credit: grossAmount,
});
} else {
// Income: Money entering bank account
// Bank account gets debited with gross amount
lines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description,
debit: grossAmount,
credit: 0,
});
// Contra account (revenue) gets credited with net amount
lines.push({
accountId: contraAccountId,
accountNumber: contraAccountNumber,
accountName: contraAccountName,
description,
debit: 0,
credit: netAmount,
vatCode,
vatAmount: vatConfig.rate > 0 ? vatAmount : undefined,
});
// VAT account (output VAT) gets credited if applicable
if (vatAmount > 0 && vatConfig.type === 'output') {
lines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
});
}
}
// Validate double-entry
const validation = validateDoubleEntry(
lines.map((l) => ({ ...l, id: '', transactionId: '' }))
);
return {
date: bankTransaction.date,
description,
lines,
bankTransactionId: bankTransaction.id,
isValid: validation.valid,
validationMessage: validation.valid
? undefined
: `Ubalance: Difference ${validation.difference.toFixed(2)} kr`,
};
}
/**
* Generate split double-entry from bank transaction
* Distributes one bank transaction to multiple contra accounts
*/
export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTransaction {
const { bankTransaction, lines: splitLines } = input;
const grossAmount = Math.abs(bankTransaction.amount);
const isExpense = bankTransaction.amount < 0;
const generatedLines: GeneratedTransactionLine[] = [];
// Validate that split lines sum to bank transaction amount
const splitTotal = splitLines.reduce((sum, line) => sum + Math.abs(line.amount), 0);
if (Math.abs(splitTotal - grossAmount) > 0.01) {
return {
date: bankTransaction.date,
description: bankTransaction.description,
lines: [],
bankTransactionId: bankTransaction.id,
isValid: false,
validationMessage: `Split-beloeb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`,
};
}
if (isExpense) {
// Process each split line for expenses
for (const splitLine of splitLines) {
const vatConfig = VAT_CODE_CONFIG[splitLine.vatCode];
const lineGross = Math.abs(splitLine.amount);
let lineNet = lineGross;
let lineVat = 0;
// Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
if (vatConfig.rate > 0) {
const rawNet = lineGross / (1 + vatConfig.rate);
lineNet = Math.round(rawNet * 100) / 100;
lineVat = Math.round((lineGross - lineNet) * 100) / 100;
}
const description = splitLine.description || bankTransaction.description;
// Contra account (expense) gets debited with net amount
generatedLines.push({
accountId: splitLine.accountId,
accountNumber: splitLine.accountNumber,
accountName: splitLine.accountName,
description,
debit: lineNet,
credit: 0,
vatCode: splitLine.vatCode,
vatAmount: lineVat > 0 ? lineVat : undefined,
});
// VAT account (input VAT) gets debited if applicable
if (lineVat > 0 && vatConfig.deductible) {
generatedLines.push({
accountId: `vat-input-${splitLine.vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
description: `Moms: ${description}`,
debit: lineVat,
credit: 0,
});
}
// For reverse charge, also credit output VAT
if (vatConfig.reverseCharge && lineVat > 0) {
const outputVatAccount = splitLine.vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
generatedLines.push({
accountId: `vat-output-${splitLine.vatCode}`,
accountNumber: outputVatAccount,
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,
});
}
}
// Bank account gets credited with total gross amount
generatedLines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description: bankTransaction.description,
debit: 0,
credit: grossAmount,
});
} else {
// Income: Bank account gets debited first
generatedLines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description: bankTransaction.description,
debit: grossAmount,
credit: 0,
});
// Process each split line for income
for (const splitLine of splitLines) {
const vatConfig = VAT_CODE_CONFIG[splitLine.vatCode];
const lineGross = Math.abs(splitLine.amount);
let lineNet = lineGross;
let lineVat = 0;
// Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
if (vatConfig.rate > 0) {
const rawNet = lineGross / (1 + vatConfig.rate);
lineNet = Math.round(rawNet * 100) / 100;
lineVat = Math.round((lineGross - lineNet) * 100) / 100;
}
const description = splitLine.description || bankTransaction.description;
// Contra account (revenue) gets credited with net amount
generatedLines.push({
accountId: splitLine.accountId,
accountNumber: splitLine.accountNumber,
accountName: splitLine.accountName,
description,
debit: 0,
credit: lineNet,
vatCode: splitLine.vatCode,
vatAmount: lineVat > 0 ? lineVat : undefined,
});
// VAT account (output VAT) gets credited if applicable
if (lineVat > 0 && vatConfig.type === 'output') {
generatedLines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,
});
}
}
}
// Validate double-entry
const validation = validateDoubleEntry(
generatedLines.map((l) => ({ ...l, id: '', transactionId: '' }))
);
return {
date: bankTransaction.date,
description: bankTransaction.description,
lines: generatedLines,
bankTransactionId: bankTransaction.id,
isValid: validation.valid,
validationMessage: validation.valid
? undefined
: `Ubalance: Difference ${validation.difference.toFixed(2)} kr`,
};
}
/**
* Get suggested VAT code based on account type
*/
export function getSuggestedVATCode(accountNumber: string, isExpense: boolean): VATCode {
const accountType = getAccountTypeFromNumber(accountNumber);
// Expenses typically use input VAT (K25)
if (isExpense) {
// Some expense types are typically VAT-exempt
if (accountType === 'financial') return 'NONE';
if (accountType === 'personnel') return 'NONE';
return 'K25';
}
// Revenue typically uses output VAT (S25)
if (accountType === 'revenue') {
return 'S25';
}
return 'NONE';
}
/**
* Consolidate generated lines by account
* Combines lines with the same account to reduce line count
*/
export function consolidateTransactionLines(
lines: GeneratedTransactionLine[]
): GeneratedTransactionLine[] {
const consolidated: Map<string, GeneratedTransactionLine> = new Map();
for (const line of lines) {
const key = line.accountNumber;
const existing = consolidated.get(key);
if (existing) {
existing.debit += line.debit;
existing.credit += line.credit;
// Append description if different
if (line.description && !existing.description.includes(line.description)) {
existing.description = `${existing.description}; ${line.description}`;
}
} else {
consolidated.set(key, { ...line });
}
}
// Filter out lines with zero amounts
return Array.from(consolidated.values()).filter(
(line) => line.debit > 0 || line.credit > 0
);
}

View file

@ -0,0 +1,611 @@
// Fiscal Year Utilities for Danish Accounting (Regnskabsår)
import dayjs from 'dayjs';
import type {
FiscalYear,
AccountingPeriod,
ClosingEntryType,
} from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
// =====================================================
// FISCAL YEAR BOUNDARIES
// =====================================================
/**
* Calculate fiscal year boundaries for a given date
* Based on company's fiscal year start month
*/
export function getFiscalYearBoundaries(
date: string,
fiscalYearStartMonth: number
): { startDate: string; endDate: string; name: string } {
const d = dayjs(date);
const year = d.year();
const month = d.month() + 1; // dayjs months are 0-indexed
let startYear: number;
let endYear: number;
if (fiscalYearStartMonth === 1) {
// Calendar year (January start)
startYear = year;
endYear = year;
} else {
// Non-calendar fiscal year (e.g., July start)
if (month >= fiscalYearStartMonth) {
// We're in the first part of a fiscal year that spans two calendar years
startYear = year;
endYear = year + 1;
} else {
// We're in the second part of a fiscal year
startYear = year - 1;
endYear = year;
}
}
const startDate = dayjs()
.year(startYear)
.month(fiscalYearStartMonth - 1)
.date(1)
.format('YYYY-MM-DD');
const endDate = dayjs()
.year(endYear)
.month(fiscalYearStartMonth - 1)
.subtract(1, 'day')
.endOf('month')
.format('YYYY-MM-DD');
// Name format: "2025" for calendar year, "2024/2025" for split year
const name = startYear === endYear ? `${startYear}` : `${startYear}/${endYear}`;
return { startDate, endDate, name };
}
/**
* Check if a date falls within a fiscal year
*/
export function isDateInFiscalYear(date: string, fiscalYear: FiscalYear): boolean {
const d = dayjs(date);
const start = dayjs(fiscalYear.startDate);
const end = dayjs(fiscalYear.endDate);
return (d.isAfter(start) || d.isSame(start, 'day')) &&
(d.isBefore(end) || d.isSame(end, 'day'));
}
/**
* Find the fiscal year that contains a given date
*/
export function findFiscalYearForDate(
date: string,
fiscalYears: FiscalYear[]
): FiscalYear | undefined {
return fiscalYears.find((fy) => isDateInFiscalYear(date, fy));
}
/**
* Check if a date is outside all existing fiscal years
*/
export function isDateOutsideFiscalYears(
date: string,
fiscalYears: FiscalYear[]
): boolean {
return !findFiscalYearForDate(date, fiscalYears);
}
// =====================================================
// FISCAL YEAR CREATION
// =====================================================
/**
* Generate a new fiscal year object
*/
export function createFiscalYearObject(
companyId: string,
startDate: string,
endDate: string,
name: string
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
return {
companyId,
name,
startDate,
endDate,
status: 'open',
openingBalancePosted: false,
};
}
/**
* Generate fiscal year for a specific date
*/
export function generateFiscalYearForDate(
date: string,
companyId: string,
fiscalYearStartMonth: number
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
const { startDate, endDate, name } = getFiscalYearBoundaries(date, fiscalYearStartMonth);
return createFiscalYearObject(companyId, startDate, endDate, name);
}
// =====================================================
// OPENING BALANCE CALCULATION
// =====================================================
/**
* Account balance at end of fiscal year
*/
export interface AccountClosingBalance {
accountId: string;
accountNumber: string;
accountName: string;
accountType: Account['type'];
closingBalance: number;
}
/**
* Opening balance for new fiscal year
*/
export interface OpeningBalance {
accountId: string;
accountNumber: string;
accountName: string;
accountType: Account['type'];
openingBalance: number;
}
/**
* Calculate closing balances for all accounts in a fiscal year
*/
export function calculateClosingBalances(
fiscalYear: FiscalYear,
accounts: Account[],
transactions: Transaction[]
): AccountClosingBalance[] {
// Filter transactions to this fiscal year
const yearTransactions = transactions.filter((tx) => {
if (tx.isVoided) return false;
return isDateInFiscalYear(tx.date, fiscalYear);
});
// Calculate balance per account
const balances = new Map<string, number>();
for (const tx of yearTransactions) {
for (const line of tx.lines) {
const current = balances.get(line.accountId) || 0;
const change = (line.debit || 0) - (line.credit || 0);
balances.set(line.accountId, current + change);
}
}
// Build closing balance records
return accounts.map((account) => ({
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
accountType: account.type,
closingBalance: balances.get(account.id) || 0,
}));
}
/**
* Calculate opening balances for a new fiscal year
* Based on previous year's closing balances
*
* Rules:
* - Balance sheet accounts (asset, liability, equity): carry forward
* - Income statement accounts (revenue, expense, cogs, personnel, financial, extraordinary): zero
* - Result is transferred to retained earnings (handled separately in closing entries)
*/
export function calculateOpeningBalances(
closingBalances: AccountClosingBalance[]
): OpeningBalance[] {
const balanceSheetTypes: Account['type'][] = ['asset', 'liability', 'equity'];
return closingBalances
.filter((cb) => balanceSheetTypes.includes(cb.accountType))
.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
accountType: cb.accountType,
openingBalance: cb.closingBalance,
}));
}
// =====================================================
// YEAR-END CLOSING ENTRIES
// =====================================================
/**
* Closing summary for display before closing
*/
export interface FiscalYearClosingSummary {
fiscalYearId: string;
totalRevenue: number;
totalExpenses: number;
netResult: number;
unreconciledCount: number;
openPeriodsCount: number;
canClose: boolean;
warnings: string[];
errors: string[];
}
/**
* Calculate closing summary for a fiscal year
*/
export function calculateClosingSummary(
fiscalYear: FiscalYear,
periods: AccountingPeriod[],
closingBalances: AccountClosingBalance[],
transactions: Transaction[]
): FiscalYearClosingSummary {
const warnings: string[] = [];
const errors: string[] = [];
// Calculate totals from closing balances
const revenueTypes: Account['type'][] = ['revenue'];
const expenseTypes: Account['type'][] = ['cogs', 'expense', 'personnel', 'financial', 'extraordinary'];
// Revenue is typically credit (negative balance in our debit-credit system)
const totalRevenue = Math.abs(
closingBalances
.filter((cb) => revenueTypes.includes(cb.accountType))
.reduce((sum, cb) => sum + cb.closingBalance, 0)
);
// Expenses are typically debit (positive balance)
const totalExpenses = closingBalances
.filter((cb) => expenseTypes.includes(cb.accountType))
.reduce((sum, cb) => sum + cb.closingBalance, 0);
const netResult = totalRevenue - totalExpenses;
// Check for unreconciled transactions
const yearTransactions = transactions.filter(
(tx) => !tx.isVoided && isDateInFiscalYear(tx.date, fiscalYear)
);
const unreconciledCount = yearTransactions.filter((tx) => !tx.isReconciled).length;
if (unreconciledCount > 0) {
warnings.push(
`${unreconciledCount} transaktioner er ikke afstemt`
);
}
// Check for open periods
const yearPeriods = periods.filter((p) => p.fiscalYearId === fiscalYear.id);
const openPeriodsCount = yearPeriods.filter((p) => p.status === 'open').length;
if (openPeriodsCount > 0) {
warnings.push(
`${openPeriodsCount} perioder er stadig åbne`
);
}
// Check if fiscal year is already closed/locked
if (fiscalYear.status === 'locked') {
errors.push('Regnskabsåret er allerede låst');
}
const canClose = errors.length === 0;
return {
fiscalYearId: fiscalYear.id,
totalRevenue,
totalExpenses,
netResult,
unreconciledCount,
openPeriodsCount,
canClose,
warnings,
errors,
};
}
/**
* Generate closing entries for year-end
*/
export interface GeneratedClosingEntry {
type: ClosingEntryType;
description: string;
descriptionDanish: string;
lines: Array<{
accountId: string;
accountNumber: string;
accountName: string;
debit: number;
credit: number;
}>;
totalAmount: number;
}
/**
* Generate year-end closing entries
*/
export function generateClosingEntries(
fiscalYear: FiscalYear,
closingBalances: AccountClosingBalance[],
resultAccountId: string,
resultAccountNumber: string,
resultAccountName: string
): GeneratedClosingEntry[] {
const entries: GeneratedClosingEntry[] = [];
const revenueTypes: Account['type'][] = ['revenue'];
const expenseTypes: Account['type'][] = ['cogs', 'expense', 'personnel', 'financial', 'extraordinary'];
// 1. Close revenue accounts to result account
const revenueBalances = closingBalances.filter(
(cb) => revenueTypes.includes(cb.accountType) && cb.closingBalance !== 0
);
if (revenueBalances.length > 0) {
const revenueLines = revenueBalances.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
// Revenue typically has credit balance (negative), so we debit to close
debit: cb.closingBalance < 0 ? Math.abs(cb.closingBalance) : 0,
credit: cb.closingBalance > 0 ? cb.closingBalance : 0,
}));
const totalRevenue = revenueBalances.reduce((sum, cb) => sum + cb.closingBalance, 0);
// Counter entry to result account
revenueLines.push({
accountId: resultAccountId,
accountNumber: resultAccountNumber,
accountName: resultAccountName,
debit: totalRevenue > 0 ? totalRevenue : 0,
credit: totalRevenue < 0 ? Math.abs(totalRevenue) : 0,
});
entries.push({
type: 'revenue-close',
description: `Close revenue accounts for fiscal year ${fiscalYear.name}`,
descriptionDanish: `Luk indtægtskonti for regnskabsår ${fiscalYear.name}`,
lines: revenueLines,
totalAmount: Math.abs(totalRevenue),
});
}
// 2. Close expense accounts to result account
const expenseBalances = closingBalances.filter(
(cb) => expenseTypes.includes(cb.accountType) && cb.closingBalance !== 0
);
if (expenseBalances.length > 0) {
const expenseLines = expenseBalances.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
// Expenses typically have debit balance (positive), so we credit to close
debit: cb.closingBalance < 0 ? Math.abs(cb.closingBalance) : 0,
credit: cb.closingBalance > 0 ? cb.closingBalance : 0,
}));
const totalExpenses = expenseBalances.reduce((sum, cb) => sum + cb.closingBalance, 0);
// Counter entry to result account
expenseLines.push({
accountId: resultAccountId,
accountNumber: resultAccountNumber,
accountName: resultAccountName,
debit: totalExpenses < 0 ? Math.abs(totalExpenses) : 0,
credit: totalExpenses > 0 ? totalExpenses : 0,
});
entries.push({
type: 'expense-close',
description: `Close expense accounts for fiscal year ${fiscalYear.name}`,
descriptionDanish: `Luk udgiftskonti for regnskabsår ${fiscalYear.name}`,
lines: expenseLines,
totalAmount: Math.abs(totalExpenses),
});
}
return entries;
}
// =====================================================
// VALIDATION
// =====================================================
/**
* Validation result for fiscal year operations
*/
export interface FiscalYearValidation {
isValid: boolean;
canProceed: boolean;
errors: Array<{ code: string; message: string; messageDanish: string }>;
warnings: Array<{ code: string; message: string; messageDanish: string }>;
}
/**
* Validate if a fiscal year can be closed
*/
export function validateFiscalYearClose(
fiscalYear: FiscalYear,
periods: AccountingPeriod[]
): FiscalYearValidation {
const errors: FiscalYearValidation['errors'] = [];
const warnings: FiscalYearValidation['warnings'] = [];
// Check if already locked
if (fiscalYear.status === 'locked') {
errors.push({
code: 'ALREADY_LOCKED',
message: 'Fiscal year is already locked',
messageDanish: 'Regnskabsåret er allerede låst',
});
}
// Check for open periods
const yearPeriods = periods.filter((p) => p.fiscalYearId === fiscalYear.id);
const openPeriods = yearPeriods.filter((p) => p.status === 'open');
if (openPeriods.length > 0) {
warnings.push({
code: 'OPEN_PERIODS',
message: `${openPeriods.length} accounting period(s) are still open`,
messageDanish: `${openPeriods.length} regnskabsperiode(r) er stadig åbne`,
});
}
// Check for future periods
const futurePeriods = yearPeriods.filter((p) => p.status === 'future');
if (futurePeriods.length > 0) {
warnings.push({
code: 'FUTURE_PERIODS',
message: `${futurePeriods.length} accounting period(s) have not started yet`,
messageDanish: `${futurePeriods.length} regnskabsperiode(r) er ikke startet endnu`,
});
}
return {
isValid: errors.length === 0,
canProceed: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate if a new fiscal year can be created
*/
export function validateFiscalYearCreation(
newYear: Pick<FiscalYear, 'startDate' | 'endDate'>,
existingYears: FiscalYear[]
): FiscalYearValidation {
const errors: FiscalYearValidation['errors'] = [];
const warnings: FiscalYearValidation['warnings'] = [];
const newStart = dayjs(newYear.startDate);
const newEnd = dayjs(newYear.endDate);
// Check for overlapping fiscal years
for (const existing of existingYears) {
const existingStart = dayjs(existing.startDate);
const existingEnd = dayjs(existing.endDate);
// Use strict inequality - two fiscal years that touch at the boundary
// (one ends on date X, other starts on date X) are allowed
const overlaps = newStart.isBefore(existingEnd) && newEnd.isAfter(existingStart);
if (overlaps) {
errors.push({
code: 'OVERLAP',
message: `Overlaps with existing fiscal year ${existing.name}`,
messageDanish: `Overlapper med eksisterende regnskabsår ${existing.name}`,
});
}
}
// Check if fiscal year length is reasonable (300-400 days typically)
const daysDiff = newEnd.diff(newStart, 'day');
if (daysDiff < 300) {
warnings.push({
code: 'SHORT_YEAR',
message: `Fiscal year is only ${daysDiff} days`,
messageDanish: `Regnskabsåret er kun ${daysDiff} dage`,
});
}
if (daysDiff > 400) {
warnings.push({
code: 'LONG_YEAR',
message: `Fiscal year is ${daysDiff} days (typically should be ~365)`,
messageDanish: `Regnskabsåret er ${daysDiff} dage (normalt omkring 365)`,
});
}
return {
isValid: errors.length === 0,
canProceed: errors.length === 0,
errors,
warnings,
};
}
// =====================================================
// HELPERS
// =====================================================
/**
* Get previous fiscal year
*/
export function getPreviousFiscalYear(
fiscalYear: FiscalYear,
allYears: FiscalYear[]
): FiscalYear | undefined {
const sorted = [...allYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
const currentIndex = sorted.findIndex((y) => y.id === fiscalYear.id);
if (currentIndex === -1 || currentIndex === sorted.length - 1) {
return undefined;
}
return sorted[currentIndex + 1];
}
/**
* Get next fiscal year
*/
export function getNextFiscalYear(
fiscalYear: FiscalYear,
allYears: FiscalYear[]
): FiscalYear | undefined {
const sorted = [...allYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
const currentIndex = sorted.findIndex((y) => y.id === fiscalYear.id);
if (currentIndex === -1 || currentIndex === 0) {
return undefined;
}
return sorted[currentIndex - 1];
}
/**
* Format fiscal year for display
*/
export function formatFiscalYear(fiscalYear: FiscalYear): string {
return `Regnskabsår ${fiscalYear.name}`;
}
/**
* Get fiscal year status color
*/
export function getFiscalYearStatusColor(status: FiscalYear['status']): string {
switch (status) {
case 'open':
return 'green';
case 'closed':
return 'orange';
case 'locked':
return 'red';
default:
return 'default';
}
}
/**
* Get fiscal year status label in Danish
*/
export function getFiscalYearStatusLabel(status: FiscalYear['status']): string {
switch (status) {
case 'open':
return 'Åben';
case 'closed':
return 'Lukket';
case 'locked':
return 'Låst';
default:
return status;
}
}

View file

@ -0,0 +1,167 @@
import dayjs from 'dayjs';
import 'dayjs/locale/da';
// Set Danish locale as default
dayjs.locale('da');
/**
* Format a number as Danish currency (kr.)
*/
export function formatCurrency(
value: number,
options: {
showSign?: boolean;
decimalPlaces?: number;
currency?: string;
} = {}
): string {
const { showSign = false, decimalPlaces = 2, currency = 'kr.' } = options;
const absValue = Math.abs(value);
const formattedNumber = absValue.toLocaleString('da-DK', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
});
let result = `${formattedNumber} ${currency}`;
if (showSign && value !== 0) {
result = value > 0 ? `+${result}` : `-${result}`;
} else if (value < 0) {
result = `-${result}`;
}
return result;
}
/**
* Format a number with Danish locale (1.234,56)
*/
export function formatNumber(
value: number,
decimalPlaces: number = 2
): string {
return value.toLocaleString('da-DK', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
});
}
/**
* Format a date in Danish format
*/
export function formatDate(
date: string | Date,
format: string = 'DD/MM/YYYY'
): string {
return dayjs(date).format(format);
}
/**
* Format a date with time
*/
export function formatDateTime(
date: string | Date,
format: string = 'DD/MM/YYYY HH:mm'
): string {
return dayjs(date).format(format);
}
/**
* Format a date in short Danish format (e.g., "15. jan 2025")
*/
export function formatDateShort(date: string | Date): string {
return dayjs(date).format('D. MMM YYYY');
}
/**
* Format a date in long Danish format (e.g., "15. januar 2025")
*/
export function formatDateLong(date: string | Date): string {
return dayjs(date).format('D. MMMM YYYY');
}
/**
* Format a date for API requests (ISO format)
*/
export function formatDateISO(date: string | Date): string {
return dayjs(date).format('YYYY-MM-DD');
}
/**
* Format period (month/year)
*/
export function formatPeriod(date: string | Date): string {
return dayjs(date).format('MMMM YYYY');
}
/**
* Format account number (e.g., "1000" -> "1000")
*/
export function formatAccountNumber(accountNumber: string): string {
// Already formatted, but could add padding if needed
return accountNumber.padStart(4, '0');
}
/**
* Format CVR number (e.g., "12345678" -> "12 34 56 78")
*/
export function formatCVR(cvr: string): string {
const cleaned = cvr.replace(/\D/g, '');
if (cleaned.length !== 8) return cvr;
return `${cleaned.slice(0, 2)} ${cleaned.slice(2, 4)} ${cleaned.slice(4, 6)} ${cleaned.slice(6, 8)}`;
}
/**
* Get CSS class for amount (positive/negative/zero)
*/
export function getAmountClass(value: number): string {
if (value > 0) return 'amount-positive';
if (value < 0) return 'amount-negative';
return 'amount-zero';
}
/**
* Parse a Danish formatted number string to number
*/
export function parseDanishNumber(value: string): number {
// Replace Danish thousand separator (.) with nothing
// Replace Danish decimal separator (,) with dot
const normalized = value
.replace(/\./g, '')
.replace(',', '.')
.replace(/[^\d.-]/g, '');
return parseFloat(normalized) || 0;
}
/**
* Format transaction number
*/
export function formatTransactionNumber(number: string | number): string {
return `#${number}`;
}
/**
* Format percentage
*/
export function formatPercentage(value: number, decimalPlaces: number = 1): string {
return `${formatNumber(value * 100, decimalPlaces)}%`;
}
/**
* Truncate text with ellipsis
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
}
/**
* Format file size
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

541
frontend/src/lib/periods.ts Normal file
View file

@ -0,0 +1,541 @@
// Period Calculation Utilities for Danish Accounting
import dayjs from 'dayjs';
import type {
FiscalYear,
AccountingPeriod,
VATPeriod,
PeriodFrequency,
PeriodStatus,
PeriodValidationResult,
DANISH_MONTHS,
DANISH_MONTHS_SHORT,
} from '@/types/periods';
import type { VATPeriodicitet } from '@/types/periods';
import type { Transaction } from '@/types/accounting';
// =====================================================
// PERIOD GENERATION
// =====================================================
/**
* Generate accounting periods for a fiscal year
*/
export function generateAccountingPeriods(
fiscalYear: FiscalYear,
frequency: PeriodFrequency
): Omit<AccountingPeriod, 'id' | 'createdAt' | 'updatedAt'>[] {
const periods: Omit<AccountingPeriod, 'id' | 'createdAt' | 'updatedAt'>[] = [];
const startDate = dayjs(fiscalYear.startDate);
const endDate = dayjs(fiscalYear.endDate);
let periodCount: number;
let monthsPerPeriod: number;
switch (frequency) {
case 'monthly':
periodCount = 12;
monthsPerPeriod = 1;
break;
case 'quarterly':
periodCount = 4;
monthsPerPeriod = 3;
break;
case 'half-yearly':
periodCount = 2;
monthsPerPeriod = 6;
break;
case 'yearly':
periodCount = 1;
monthsPerPeriod = 12;
break;
}
for (let i = 0; i < periodCount; i++) {
const periodStart = startDate.add(i * monthsPerPeriod, 'month');
const periodEnd = periodStart.add(monthsPerPeriod, 'month').subtract(1, 'day');
// Make sure we don't exceed fiscal year end
const actualEnd = periodEnd.isAfter(endDate) ? endDate : periodEnd;
const { name, shortName } = getPeriodDisplayName(
periodStart.format('YYYY-MM-DD'),
actualEnd.format('YYYY-MM-DD'),
frequency
);
const status = determinePeriodStatus(
periodStart.format('YYYY-MM-DD'),
actualEnd.format('YYYY-MM-DD')
);
periods.push({
companyId: fiscalYear.companyId,
fiscalYearId: fiscalYear.id,
periodNumber: i + 1,
name,
shortName,
startDate: periodStart.format('YYYY-MM-DD'),
endDate: actualEnd.format('YYYY-MM-DD'),
status,
});
}
return periods;
}
/**
* Generate VAT periods for a year
*/
export function generateVATPeriods(
companyId: string,
periodicitet: VATPeriodicitet,
year: number
): Omit<VATPeriod, 'id' | 'createdAt' | 'updatedAt'>[] {
const periods: Omit<VATPeriod, 'id' | 'createdAt' | 'updatedAt'>[] = [];
let periodCount: number;
let monthsPerPeriod: number;
let deadlineDays: number;
switch (periodicitet) {
case 'monthly':
periodCount = 12;
monthsPerPeriod = 1;
deadlineDays = 25;
break;
case 'quarterly':
periodCount = 4;
monthsPerPeriod = 3;
deadlineDays = 40;
break;
case 'half-yearly':
periodCount = 2;
monthsPerPeriod = 6;
deadlineDays = 60;
break;
case 'yearly':
periodCount = 1;
monthsPerPeriod = 12;
deadlineDays = 90;
break;
}
for (let i = 0; i < periodCount; i++) {
const startMonth = i * monthsPerPeriod + 1;
const periodStart = dayjs(`${year}-${String(startMonth).padStart(2, '0')}-01`);
const periodEnd = periodStart.add(monthsPerPeriod, 'month').subtract(1, 'day');
const deadline = periodEnd.add(deadlineDays, 'day');
const name = getVATPeriodName(periodicitet, year, i + 1);
const status = determineVATPeriodStatus(
periodStart.format('YYYY-MM-DD'),
periodEnd.format('YYYY-MM-DD')
);
periods.push({
companyId,
periodicitet,
year,
periodNumber: i + 1,
name,
startDate: periodStart.format('YYYY-MM-DD'),
endDate: periodEnd.format('YYYY-MM-DD'),
deadline: deadline.format('YYYY-MM-DD'),
status,
});
}
return periods;
}
// =====================================================
// PERIOD NAMING
// =====================================================
/**
* Get period display name based on frequency
*/
export function getPeriodDisplayName(
startDate: string,
endDate: string,
frequency: PeriodFrequency
): { name: string; shortName: string } {
const start = dayjs(startDate);
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
const MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'
];
switch (frequency) {
case 'monthly':
return {
name: `${MONTHS[start.month()]} ${start.year()}`,
shortName: `${MONTHS_SHORT[start.month()]} ${start.year()}`,
};
case 'quarterly': {
const quarter = Math.floor(start.month() / 3) + 1;
return {
name: `Q${quarter} ${start.year()}`,
shortName: `Q${quarter}`,
};
}
case 'half-yearly': {
const half = start.month() < 6 ? 1 : 2;
return {
name: `H${half} ${start.year()}`,
shortName: `H${half}`,
};
}
case 'yearly':
return {
name: `${start.year()}`,
shortName: `${start.year()}`,
};
}
}
/**
* Get VAT period name
*/
export function getVATPeriodName(
periodicitet: VATPeriodicitet,
year: number,
periodNumber: number
): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
switch (periodicitet) {
case 'monthly':
return `${MONTHS[periodNumber - 1]} ${year}`;
case 'quarterly':
return `Q${periodNumber} ${year}`;
case 'half-yearly':
return `H${periodNumber} ${year}`;
case 'yearly':
return `${year}`;
}
}
/**
* Get Danish month name
*/
export function getDanishMonthName(month: number): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
return MONTHS[month - 1] || '';
}
// =====================================================
// PERIOD STATUS
// =====================================================
/**
* Determine period status based on dates
*/
export function determinePeriodStatus(
startDate: string,
endDate: string
): PeriodStatus {
const now = dayjs();
const start = dayjs(startDate);
const end = dayjs(endDate);
if (now.isBefore(start)) return 'future';
if (now.isBefore(end) || now.isSame(end, 'day')) return 'open';
return 'closed';
}
/**
* Determine VAT period status
*/
export function determineVATPeriodStatus(
startDate: string,
endDate: string
): 'future' | 'open' | 'closed' {
const now = dayjs();
const start = dayjs(startDate);
const end = dayjs(endDate);
if (now.isBefore(start)) return 'future';
if (now.isBefore(end) || now.isSame(end, 'day')) return 'open';
return 'closed';
}
// =====================================================
// PERIOD LOOKUP
// =====================================================
/**
* Get period containing a specific date
*/
export function getPeriodForDate(
date: string,
periods: AccountingPeriod[]
): AccountingPeriod | undefined {
const targetDate = dayjs(date);
return periods.find((period) => {
const start = dayjs(period.startDate);
const end = dayjs(period.endDate);
return (
(targetDate.isAfter(start) || targetDate.isSame(start, 'day')) &&
(targetDate.isBefore(end) || targetDate.isSame(end, 'day'))
);
});
}
/**
* Get previous period (for comparison)
*/
export function getPreviousPeriod(
currentPeriod: AccountingPeriod,
allPeriods: AccountingPeriod[]
): AccountingPeriod | undefined {
const currentStart = dayjs(currentPeriod.startDate);
return allPeriods.find((period) => {
const periodEnd = dayjs(period.endDate);
// Previous period ends the day before current period starts
return periodEnd.add(1, 'day').isSame(currentStart, 'day');
});
}
/**
* Get same period from previous year
*/
export function getSamePeriodPreviousYear(
currentPeriod: AccountingPeriod,
allPeriods: AccountingPeriod[]
): AccountingPeriod | undefined {
const currentStart = dayjs(currentPeriod.startDate);
const previousYearStart = currentStart.subtract(1, 'year');
return allPeriods.find((period) => {
const periodStart = dayjs(period.startDate);
return (
periodStart.month() === previousYearStart.month() &&
periodStart.year() === previousYearStart.year() &&
period.periodNumber === currentPeriod.periodNumber
);
});
}
/**
* Calculate year-to-date range
*/
export function getYearToDateRange(
currentPeriod: AccountingPeriod,
fiscalYear: FiscalYear
): { startDate: string; endDate: string } {
return {
startDate: fiscalYear.startDate,
endDate: currentPeriod.endDate,
};
}
// =====================================================
// PERIOD VALIDATION
// =====================================================
/**
* Check if a date can be posted to based on period status
*/
export function canPostToDate(
date: string,
periods: AccountingPeriod[],
settings: {
preventPostingToClosedPeriods: boolean;
preventPostingToFuturePeriods: boolean;
}
): { allowed: boolean; reason?: string; reasonDanish?: string } {
const period = getPeriodForDate(date, periods);
if (!period) {
return {
allowed: false,
reason: 'No period found for this date',
reasonDanish: 'Ingen periode fundet for denne dato',
};
}
if (period.status === 'locked') {
return {
allowed: false,
reason: 'Period is locked',
reasonDanish: 'Perioden er laast',
};
}
if (period.status === 'closed' && settings.preventPostingToClosedPeriods) {
return {
allowed: false,
reason: 'Period is closed',
reasonDanish: 'Perioden er lukket',
};
}
if (period.status === 'future' && settings.preventPostingToFuturePeriods) {
return {
allowed: false,
reason: 'Cannot post to future periods',
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder',
};
}
return { allowed: true };
}
/**
* Validate if period can be closed
*/
export function validatePeriodClose(
period: AccountingPeriod,
transactions: Transaction[],
options?: {
requireAllReconciled?: boolean;
}
): PeriodValidationResult {
const errors: PeriodValidationResult['errors'] = [];
const warnings: PeriodValidationResult['warnings'] = [];
// Check if period is already locked
if (period.status === 'locked') {
errors.push({
code: 'PERIOD_LOCKED',
message: 'Period is already locked and cannot be modified',
messageDanish: 'Perioden er allerede laast og kan ikke aendres',
});
}
// Check for unreconciled transactions
if (options?.requireAllReconciled) {
const unreconciledCount = transactions.filter((tx) => !tx.isReconciled).length;
if (unreconciledCount > 0) {
warnings.push({
code: 'UNRECONCILED_TRANSACTIONS',
message: `${unreconciledCount} transactions are not reconciled`,
messageDanish: `${unreconciledCount} transaktioner er ikke afstemt`,
});
}
}
return {
isValid: errors.length === 0,
canPost: period.status !== 'locked',
errors,
warnings,
};
}
// =====================================================
// VAT PERIOD HELPERS
// =====================================================
/**
* Calculate VAT deadlines based on SKAT rules
*/
export function calculateVATDeadline(
periodEndDate: string,
periodicitet: VATPeriodicitet
): { deadline: string; paymentDeadline: string } {
const endDate = dayjs(periodEndDate);
let deadlineDays: number;
switch (periodicitet) {
case 'monthly':
deadlineDays = 25;
break;
case 'quarterly':
deadlineDays = 40;
break;
case 'half-yearly':
deadlineDays = 60;
break;
case 'yearly':
deadlineDays = 90;
break;
}
const deadline = endDate.add(deadlineDays, 'day');
// Payment deadline is typically same as reporting deadline
const paymentDeadline = deadline;
return {
deadline: deadline.format('YYYY-MM-DD'),
paymentDeadline: paymentDeadline.format('YYYY-MM-DD'),
};
}
/**
* Format period for SKAT export
*/
export function formatPeriodForSKAT(
periodicitet: VATPeriodicitet,
year: number,
periodNumber: number
): string {
switch (periodicitet) {
case 'monthly':
return `${year}${String(periodNumber).padStart(2, '0')}`;
case 'quarterly':
return `${year}Q${periodNumber}`;
case 'half-yearly':
return `${year}H${periodNumber}`;
case 'yearly':
return `${year}`;
}
}
// =====================================================
// FISCAL YEAR HELPERS
// =====================================================
/**
* Create a new fiscal year
*/
export function createFiscalYear(
companyId: string,
startMonth: number,
year: number
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
const startDate = dayjs(`${year}-${String(startMonth).padStart(2, '0')}-01`);
const endDate = startDate.add(1, 'year').subtract(1, 'day');
// Determine fiscal year name
const name = startMonth === 1
? `${year}`
: `${year}/${year + 1}`;
return {
companyId,
name,
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
status: 'open',
openingBalancePosted: false,
};
}
/**
* Check if a date is within a fiscal year
*/
export function isDateInFiscalYear(date: string, fiscalYear: FiscalYear): boolean {
const targetDate = dayjs(date);
const start = dayjs(fiscalYear.startDate);
const end = dayjs(fiscalYear.endDate);
return (
(targetDate.isAfter(start) || targetDate.isSame(start, 'day')) &&
(targetDate.isBefore(end) || targetDate.isSame(end, 'day'))
);
}

View file

@ -0,0 +1,419 @@
// VAT Calculation Engine for Danish SKAT Compliance
import dayjs from 'dayjs';
import type {
VATBoxId,
BasisBoxId,
VATReportPeriod,
VATTransactionLine,
VATCalculationResult,
VATCalculationWarning,
VATCalculationError,
CalculatedVATBox,
VATReport,
SKATExportCSV,
SKATExportXML,
} from '@/types/vat';
import type { Transaction, TransactionLine, Company } from '@/types/accounting';
import {
VAT_CODE_CONFIG,
SKAT_VAT_BOXES,
calculateVATFromGross,
isValidVATCode,
} from './vatCodes';
// =====================================================
// MAIN CALCULATION FUNCTION
// =====================================================
/**
* Calculate VAT report from transactions
*/
export function calculateVATReport(
transactions: Transaction[],
period: VATReportPeriod
): VATCalculationResult {
const warnings: VATCalculationWarning[] = [];
const errors: VATCalculationError[] = [];
const vatLines: VATTransactionLine[] = [];
// Initialize box accumulators
const boxes: Record<VATBoxId | BasisBoxId, {
amount: number;
transactionCount: number;
transactionIds: string[];
}> = {
A: { amount: 0, transactionCount: 0, transactionIds: [] },
B: { amount: 0, transactionCount: 0, transactionIds: [] },
C: { amount: 0, transactionCount: 0, transactionIds: [] },
D: { amount: 0, transactionCount: 0, transactionIds: [] },
'1': { amount: 0, transactionCount: 0, transactionIds: [] },
'2': { amount: 0, transactionCount: 0, transactionIds: [] },
'3': { amount: 0, transactionCount: 0, transactionIds: [] },
'4': { amount: 0, transactionCount: 0, transactionIds: [] },
};
// Process each transaction
for (const transaction of transactions) {
// Check transaction is within period
const txDate = dayjs(transaction.date);
const periodStart = dayjs(period.startDate);
const periodEnd = dayjs(period.endDate);
if (txDate.isBefore(periodStart) || txDate.isAfter(periodEnd)) {
warnings.push({
type: 'period_mismatch',
message: `Transaction ${transaction.transactionNumber} is outside the period`,
messageDanish: `Transaktion ${transaction.transactionNumber} er uden for perioden`,
transactionId: transaction.id,
severity: 'medium',
});
continue;
}
// Skip voided transactions
if (transaction.isVoided) continue;
// Process each line
for (const line of transaction.lines) {
const vatLine = processTransactionLine(transaction, line, warnings, errors);
if (vatLine) {
vatLines.push(vatLine);
// Add to appropriate boxes
const codeConfig = VAT_CODE_CONFIG[vatLine.vatCode];
if (codeConfig.affectsBoxes.vatBox) {
const boxId = codeConfig.affectsBoxes.vatBox;
boxes[boxId].amount += vatLine.vatAmount;
if (!boxes[boxId].transactionIds.includes(transaction.id)) {
boxes[boxId].transactionIds.push(transaction.id);
boxes[boxId].transactionCount++;
}
}
if (codeConfig.affectsBoxes.basisBox) {
const basisId = codeConfig.affectsBoxes.basisBox;
boxes[basisId].amount += Math.abs(vatLine.netAmount);
if (!boxes[basisId].transactionIds.includes(transaction.id)) {
boxes[basisId].transactionIds.push(transaction.id);
boxes[basisId].transactionCount++;
}
}
// For reverse charge, also add to input VAT (Box B) as deductible
if (codeConfig.reverseCharge && codeConfig.deductible) {
boxes.B.amount += vatLine.vatAmount;
if (!boxes.B.transactionIds.includes(transaction.id)) {
boxes.B.transactionIds.push(transaction.id);
boxes.B.transactionCount++;
}
}
}
}
}
// Calculate summary
const totalOutputVAT = boxes.A.amount + boxes.C.amount + boxes.D.amount;
const totalInputVAT = boxes.B.amount;
const netVAT = totalOutputVAT - totalInputVAT;
return {
period,
transactions: vatLines,
boxes,
summary: {
totalOutputVAT: roundToOre(totalOutputVAT),
totalInputVAT: roundToOre(totalInputVAT),
netVAT: roundToOre(netVAT),
},
warnings,
errors,
};
}
/**
* Process a single transaction line for VAT
*/
function processTransactionLine(
transaction: Transaction,
line: TransactionLine,
_warnings: VATCalculationWarning[],
errors: VATCalculationError[]
): VATTransactionLine | null {
// Skip lines without VAT code
if (!line.vatCode) {
return null;
}
// Validate VAT code instead of using type assertion
if (!isValidVATCode(line.vatCode)) {
errors.push({
type: 'invalid_vat_code',
message: `Invalid VAT code: ${line.vatCode}`,
messageDanish: `Ugyldig momskode: ${line.vatCode}`,
transactionId: transaction.id,
});
return null;
}
const vatCode = line.vatCode;
// Skip lines without VAT relevance
if (vatCode === 'NONE') {
return null;
}
const codeConfig = VAT_CODE_CONFIG[vatCode];
// Calculate net amount (amount without VAT)
const grossAmount = line.debit > 0 ? line.debit : line.credit;
let netAmount: number;
let vatAmount: number;
if (line.vatAmount !== undefined && line.vatAmount !== null) {
// VAT amount explicitly provided
vatAmount = line.vatAmount;
netAmount = grossAmount - vatAmount;
} else if (codeConfig.rate > 0) {
// Calculate VAT from gross amount
const calculated = calculateVATFromGross(grossAmount, codeConfig.rate);
netAmount = calculated.net;
vatAmount = calculated.vat;
} else {
// Zero-rated or exempt
netAmount = grossAmount;
vatAmount = 0;
}
// Determine sign based on transaction type
const isOutput = codeConfig.type === 'output';
const isInput = codeConfig.type === 'input' || codeConfig.type === 'reverse_charge';
// For output VAT (sales), credit increases VAT liability
// For input VAT (purchases), debit increases VAT deduction
if (isOutput && line.debit > 0) {
// This is a reversal/return of sales
netAmount = -netAmount;
vatAmount = -vatAmount;
} else if (isInput && line.credit > 0) {
// This is a reversal/return of purchases
netAmount = -netAmount;
vatAmount = -vatAmount;
}
return {
transactionId: transaction.id,
transactionLineId: line.id,
transactionNumber: transaction.transactionNumber,
transactionDate: transaction.date,
accountId: line.accountId,
accountNumber: line.account?.accountNumber || '',
accountName: line.account?.name || '',
description: line.description || transaction.description,
debit: line.debit,
credit: line.credit,
netAmount: roundToOre(netAmount),
vatCode,
vatAmount: roundToOre(Math.abs(vatAmount)),
vatRate: codeConfig.rate,
isReverseCharge: codeConfig.reverseCharge,
};
}
// =====================================================
// EXPORT FUNCTIONS
// =====================================================
/**
* Generate SKAT CSV export data
*/
export function generateSKATCSV(
calculation: VATCalculationResult,
company: Company
): SKATExportCSV {
return {
cvr: company.cvr.replace(/\s/g, ''),
periode: formatPeriodForSKATExport(calculation.period),
rubrikA: Math.round(calculation.boxes.A.amount),
rubrikB: Math.round(calculation.boxes.B.amount),
rubrikC: Math.round(calculation.boxes.C.amount),
rubrikD: Math.round(calculation.boxes.D.amount),
felt1: Math.round(calculation.boxes['1'].amount),
felt2: Math.round(calculation.boxes['2'].amount),
felt3: Math.round(calculation.boxes['3'].amount),
felt4: Math.round(calculation.boxes['4'].amount),
};
}
/**
* Generate SKAT XML export data
*/
export function generateSKATXML(
calculation: VATCalculationResult,
company: Company
): SKATExportXML {
return {
version: '1.0',
cvr: company.cvr.replace(/\s/g, ''),
periodeStart: calculation.period.startDate,
periodeSlut: calculation.period.endDate,
angivelse: {
salgsmoms: roundToOre(calculation.boxes.A.amount),
koebsmoms: roundToOre(calculation.boxes.B.amount),
euVarekoebMoms: roundToOre(calculation.boxes.C.amount),
ydelseskoebMoms: roundToOre(calculation.boxes.D.amount),
salgMedMoms: roundToOre(calculation.boxes['1'].amount),
salgUdenMoms: roundToOre(calculation.boxes['2'].amount),
euVarekoeb: roundToOre(calculation.boxes['3'].amount),
ydelseskoeb: roundToOre(calculation.boxes['4'].amount),
},
};
}
/**
* Export VAT report as CSV string
*/
export function exportVATReportCSV(
calculation: VATCalculationResult,
company: Company
): string {
const data = generateSKATCSV(calculation, company);
const header = 'CVR;Periode;RubrikA;RubrikB;RubrikC;RubrikD;Felt1;Felt2;Felt3;Felt4';
const row = `${data.cvr};${data.periode};${data.rubrikA};${data.rubrikB};${data.rubrikC};${data.rubrikD};${data.felt1};${data.felt2};${data.felt3};${data.felt4}`;
return `${header}\n${row}`;
}
/**
* Export VAT report as XML string
*/
export function exportVATReportXML(
calculation: VATCalculationResult,
company: Company
): string {
const data = generateSKATXML(calculation, company);
return `<?xml version="1.0" encoding="UTF-8"?>
<Momsangivelse version="${data.version}">
<Virksomhed>
<CVR>${data.cvr}</CVR>
<Navn>${company.name}</Navn>
</Virksomhed>
<Periode>
<Start>${data.periodeStart}</Start>
<Slut>${data.periodeSlut}</Slut>
</Periode>
<Angivelse>
<Salgsmoms>${data.angivelse.salgsmoms}</Salgsmoms>
<Koebsmoms>${data.angivelse.koebsmoms}</Koebsmoms>
<EUVarekoebMoms>${data.angivelse.euVarekoebMoms}</EUVarekoebMoms>
<YdelseskoebMoms>${data.angivelse.ydelseskoebMoms}</YdelseskoebMoms>
<SalgMedMoms>${data.angivelse.salgMedMoms}</SalgMedMoms>
<SalgUdenMoms>${data.angivelse.salgUdenMoms}</SalgUdenMoms>
<EUVarekoeb>${data.angivelse.euVarekoeb}</EUVarekoeb>
<Ydelseskoeb>${data.angivelse.ydelseskoeb}</Ydelseskoeb>
</Angivelse>
<Resultat>
<UdgaaendeMoms>${calculation.summary.totalOutputVAT}</UdgaaendeMoms>
<IndgaaendeMoms>${calculation.summary.totalInputVAT}</IndgaaendeMoms>
<NettoMoms>${calculation.summary.netVAT}</NettoMoms>
</Resultat>
</Momsangivelse>`;
}
// =====================================================
// HELPER FUNCTIONS
// =====================================================
/**
* Round to Danish ore (2 decimal places)
*/
function roundToOre(amount: number): number {
return Math.round(amount * 100) / 100;
}
/**
* Format period for SKAT export
*/
function formatPeriodForSKATExport(period: VATReportPeriod): string {
switch (period.periodicitet) {
case 'monthly':
return dayjs(period.startDate).format('YYYYMM');
case 'quarterly':
return `${period.year}Q${period.periodNumber}`;
case 'half-yearly':
return `${period.year}H${period.periodNumber}`;
case 'yearly':
return `${period.year}`;
}
}
/**
* Format VAT period for display
*/
export function formatVATPeriodDisplay(period: VATReportPeriod): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
switch (period.periodicitet) {
case 'monthly':
return `${MONTHS[period.periodNumber - 1]} ${period.year}`;
case 'quarterly':
return `Q${period.periodNumber} ${period.year}`;
case 'half-yearly':
return `H${period.periodNumber} ${period.year}`;
case 'yearly':
return `${period.year}`;
}
}
/**
* Create empty calculated VAT boxes
*/
export function createEmptyCalculatedBoxes(): VATReport['boxes'] {
const createBox = (id: VATBoxId | BasisBoxId): CalculatedVATBox => ({
...SKAT_VAT_BOXES[id],
amount: 0,
transactionCount: 0,
transactionIds: [],
});
return {
A: createBox('A'),
B: createBox('B'),
C: createBox('C'),
D: createBox('D'),
'1': createBox('1'),
'2': createBox('2'),
'3': createBox('3'),
'4': createBox('4'),
};
}
/**
* Calculate days until VAT deadline
*/
export function getDaysUntilDeadline(deadline: string): number {
const today = dayjs();
const deadlineDate = dayjs(deadline);
return deadlineDate.diff(today, 'day');
}
/**
* Check if VAT deadline is approaching (within 14 days)
*/
export function isDeadlineApproaching(deadline: string): boolean {
const days = getDaysUntilDeadline(deadline);
return days >= 0 && days <= 14;
}
/**
* Check if VAT deadline is overdue
*/
export function isDeadlineOverdue(deadline: string): boolean {
return getDaysUntilDeadline(deadline) < 0;
}

View file

@ -0,0 +1,378 @@
// VAT Code Configuration for Danish SKAT Compliance
import type {
VATCode,
VATCodeConfig,
VATPeriodicitetConfig,
SKATVATBox,
VATBoxId,
BasisBoxId,
} from '@/types/vat';
import type { VATPeriodicitet } from '@/types/periods';
/**
* Complete VAT code configuration for Danish bookkeeping
*/
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
S25: {
code: 'S25',
nameDanish: 'Udgaaende moms 25%',
nameEnglish: 'Output VAT 25%',
rate: 0.25,
type: 'output',
affectsBoxes: {
vatBox: 'A',
basisBox: '1',
},
reverseCharge: false,
deductible: false,
description: 'Moms paa salg af varer og ydelser i Danmark',
},
K25: {
code: 'K25',
nameDanish: 'Indgaaende moms 25%',
nameEnglish: 'Input VAT 25%',
rate: 0.25,
type: 'input',
affectsBoxes: {
vatBox: 'B',
},
reverseCharge: false,
deductible: true,
description: 'Fradragsberettiget moms paa koeb',
},
EU_VARE: {
code: 'EU_VARE',
nameDanish: 'EU-varekoeb (erhvervelsesmoms)',
nameEnglish: 'EU goods purchase (acquisition VAT)',
rate: 0.25,
type: 'reverse_charge',
affectsBoxes: {
vatBox: 'C',
basisBox: '3',
},
reverseCharge: true,
deductible: true, // Both output and input VAT
description: 'Koeb af varer fra andre EU-lande med omvendt betalingspligt',
},
EU_YDELSE: {
code: 'EU_YDELSE',
nameDanish: 'EU-ydelseskoeb (omvendt betalingspligt)',
nameEnglish: 'EU services purchase (reverse charge)',
rate: 0.25,
type: 'reverse_charge',
affectsBoxes: {
vatBox: 'D',
basisBox: '4',
},
reverseCharge: true,
deductible: true,
description: 'Koeb af ydelser fra udlandet med omvendt betalingspligt',
},
MOMSFRI: {
code: 'MOMSFRI',
nameDanish: 'Momsfritaget',
nameEnglish: 'VAT exempt',
rate: 0,
type: 'exempt',
affectsBoxes: {
basisBox: '2',
},
reverseCharge: false,
deductible: false,
description: 'Momsfritaget salg (sundhed, undervisning, mv.)',
},
EKSPORT: {
code: 'EKSPORT',
nameDanish: 'Eksport (0%)',
nameEnglish: 'Export (0%)',
rate: 0,
type: 'exempt',
affectsBoxes: {
basisBox: '2',
},
reverseCharge: false,
deductible: false,
description: 'Eksport til lande uden for EU',
},
NONE: {
code: 'NONE',
nameDanish: 'Ingen moms',
nameEnglish: 'No VAT',
rate: 0,
type: 'none',
affectsBoxes: {},
reverseCharge: false,
deductible: false,
description: 'Transaktioner uden momsrelevans',
},
};
/**
* VAT period configuration based on SKAT requirements
*/
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
monthly: {
type: 'monthly',
nameDanish: 'Maanedlig',
nameEnglish: 'Monthly',
deadlineDaysAfterPeriod: 25, // 25th of following month
periodsPerYear: 12,
threshold: { min: 50000000 }, // >50M DKK/year
},
quarterly: {
type: 'quarterly',
nameDanish: 'Kvartalsvis',
nameEnglish: 'Quarterly',
deadlineDaysAfterPeriod: 40, // ~40 days after quarter end (1st of 2nd month)
periodsPerYear: 4,
threshold: { min: 1000000, max: 50000000 }, // 1M-50M DKK/year
},
'half-yearly': {
type: 'half-yearly',
nameDanish: 'Halvaarslig',
nameEnglish: 'Half-yearly',
deadlineDaysAfterPeriod: 60, // ~2 months after period
periodsPerYear: 2,
threshold: { min: 300000, max: 1000000 }, // 300K-1M DKK/year
},
yearly: {
type: 'yearly',
nameDanish: 'Aarslig',
nameEnglish: 'Yearly',
deadlineDaysAfterPeriod: 90, // March 1st for calendar year
periodsPerYear: 1,
threshold: { max: 300000 }, // <300K DKK/year
},
};
/**
* SKAT VAT box definitions
*/
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
// VAT amounts (Momsbeloeb)
A: {
id: 'A',
type: 'vat',
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT (sales)',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
skippable: false,
isDeductible: false,
},
B: {
id: 'B',
type: 'vat',
nameDanish: 'Koebsmoms',
nameEnglish: 'Input VAT (purchases)',
description: 'Fradragsberettiget moms af koeb',
skippable: false,
isDeductible: true,
},
C: {
id: 'C',
type: 'vat',
nameDanish: 'Moms af EU-varekoeb',
nameEnglish: 'VAT on EU goods purchases',
description: 'Erhvervelsesmoms ved koeb af varer fra andre EU-lande',
skippable: true,
isDeductible: false, // Listed as output, but can be deducted via B
},
D: {
id: 'D',
type: 'vat',
nameDanish: 'Moms af ydelseskoeb fra udland',
nameEnglish: 'VAT on foreign services',
description: 'Moms ved koeb af ydelser fra udlandet med omvendt betalingspligt',
skippable: true,
isDeductible: false,
},
// Basis/turnover amounts (Omsaetning)
'1': {
id: '1',
type: 'basis',
nameDanish: 'Salg med moms',
nameEnglish: 'Sales with VAT',
description: 'Vaerdi af varer og ydelser solgt med dansk moms (momsgrundlag)',
skippable: false,
isDeductible: false,
},
'2': {
id: '2',
type: 'basis',
nameDanish: 'Salg uden moms',
nameEnglish: 'Sales without VAT',
description: 'Momsfrit salg og eksport',
skippable: true,
isDeductible: false,
},
'3': {
id: '3',
type: 'basis',
nameDanish: 'EU-varekoeb',
nameEnglish: 'EU goods purchases',
description: 'Vaerdi af varer koebt fra andre EU-lande',
skippable: true,
isDeductible: false,
},
'4': {
id: '4',
type: 'basis',
nameDanish: 'Ydelseskoeb fra udland',
nameEnglish: 'Foreign services purchases',
description: 'Vaerdi af ydelser koebt fra udlandet',
skippable: true,
isDeductible: false,
},
};
/**
* Default VAT accounts for automatic double-entry
*/
export const VAT_ACCOUNTS = {
inputVAT: '5610', // Indgaaende moms (fradrag)
outputVAT: '5710', // Udgaaende moms (skyld)
euVAT: '5620', // EU-moms (erhvervelsesmoms)
} as const;
// =====================================================
// HELPER FUNCTIONS
// =====================================================
/**
* Valid VAT code values for validation
*/
export const VALID_VAT_CODES: readonly VATCode[] = [
'S25', 'K25', 'EU_VARE', 'EU_YDELSE', 'MOMSFRI', 'EKSPORT', 'NONE'
] as const;
/**
* Check if a value is a valid VAT code
*/
export function isValidVATCode(code: unknown): code is VATCode {
return typeof code === 'string' && VALID_VAT_CODES.includes(code as VATCode);
}
/**
* Safely convert a string to VATCode, returns 'NONE' if invalid
*/
export function toVATCode(code: unknown): VATCode {
return isValidVATCode(code) ? code : 'NONE';
}
/**
* Get VAT code configuration
*/
export function getVATCodeConfig(code: VATCode): VATCodeConfig {
return VAT_CODE_CONFIG[code];
}
/**
* Get all VAT codes for a dropdown
*/
export function getVATCodeOptions(): Array<{ value: VATCode; label: string; description: string }> {
return Object.values(VAT_CODE_CONFIG).map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
description: config.description,
}));
}
/**
* Get VAT codes for expense transactions (input VAT)
*/
export function getExpenseVATCodeOptions(): Array<{ value: VATCode; label: string }> {
return Object.values(VAT_CODE_CONFIG)
.filter((config) => config.type === 'input' || config.type === 'reverse_charge' || config.type === 'none')
.map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
}));
}
/**
* Get VAT codes for income transactions (output VAT)
*/
export function getIncomeVATCodeOptions(): Array<{ value: VATCode; label: string }> {
return Object.values(VAT_CODE_CONFIG)
.filter((config) => config.type === 'output' || config.type === 'exempt' || config.type === 'none')
.map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
}));
}
/**
* Get period options for a dropdown
*/
export function getPeriodicitetOptions(): Array<{ value: VATPeriodicitet; label: string; description: string }> {
return Object.values(VAT_PERIODICITET_CONFIG).map((config) => ({
value: config.type,
label: config.nameDanish,
description: config.threshold?.min
? `Omsaetning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK`
: config.threshold?.max
? `Omsaetning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
: 'Standard',
}));
}
/**
* Get SKAT box definition
*/
export function getSKATBox(boxId: VATBoxId | BasisBoxId): SKATVATBox {
return SKAT_VAT_BOXES[boxId];
}
/**
* Check if a VAT code is deductible (affects input VAT)
*/
export function isVATDeductible(code: VATCode): boolean {
return VAT_CODE_CONFIG[code].deductible;
}
/**
* Check if a VAT code is reverse charge
*/
export function isReverseCharge(code: VATCode): boolean {
return VAT_CODE_CONFIG[code].reverseCharge;
}
/**
* Get VAT rate for a code
*/
export function getVATRate(code: VATCode): number {
return VAT_CODE_CONFIG[code].rate;
}
/**
* Calculate VAT from gross amount
* Note: Rounds net first, then calculates VAT as gross - net
* This ensures net + vat = gross (no floating point drift)
*/
export function calculateVATFromGross(grossAmount: number, rate: number): { net: number; vat: number } {
if (rate === 0) {
return { net: grossAmount, vat: 0 };
}
const rawNet = grossAmount / (1 + rate);
const net = Math.round(rawNet * 100) / 100;
// Calculate VAT as difference to guarantee gross = net + vat
const vat = Math.round((grossAmount - net) * 100) / 100;
return { net, vat };
}
/**
* Calculate VAT from net amount
* Note: Rounds VAT first, then calculates gross as net + vat
* This ensures net + vat = gross (no floating point drift)
*/
export function calculateVATFromNet(netAmount: number, rate: number): { gross: number; vat: number } {
if (rate === 0) {
return { gross: netAmount, vat: 0 };
}
const rawVat = netAmount * rate;
const vat = Math.round(rawVat * 100) / 100;
// Calculate gross as sum to guarantee net + vat = gross
const gross = Math.round((netAmount + vat) * 100) / 100;
return { gross, vat };
}

25
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from 'antd';
import daDK from 'antd/locale/da_DK';
import dayjs from 'dayjs';
import 'dayjs/locale/da';
import App from './App';
import { queryClient } from './api/client';
import { theme } from './styles/theme';
import './styles/global.css';
// Set dayjs locale globally
dayjs.locale('da');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider theme={theme} locale={daDK}>
<App />
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,727 @@
import { useState } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Select,
DatePicker,
Space,
List,
Checkbox,
Tag,
Statistic,
Modal,
Form,
Input,
message,
Tooltip,
Alert,
} from 'antd';
import {
SwapOutlined,
PlusOutlined,
UndoOutlined,
CheckOutlined,
LinkOutlined,
BulbOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useReconciliationStore } from '@/stores/reconciliationStore';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { BankTransaction, BankAccount } from '@/types/accounting';
const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
// Mock data
const mockBankAccounts: BankAccount[] = [
{
id: '1',
companyId: '1',
name: 'Erhvervskonto',
bankName: 'Danske Bank',
accountNumber: '1234-5678901234',
iban: 'DK1234567890123456',
currency: 'DKK',
ledgerAccountId: '1',
isActive: true,
},
{
id: '2',
companyId: '1',
name: 'Opsparingskonto',
bankName: 'Nordea',
accountNumber: '9876-5432109876',
iban: 'DK9876543210987654',
currency: 'DKK',
ledgerAccountId: '2',
isActive: true,
},
];
const mockBankTransactions: BankTransaction[] = [
{
id: 'bt1',
bankAccountId: '1',
date: '2025-01-15',
valueDate: '2025-01-15',
description: 'FAKTURA 1234 FRA KUNDE A/S',
amount: 15625,
balance: 156250,
reference: 'FI1234',
counterparty: 'Kunde A/S',
isReconciled: false,
importedAt: '2025-01-16T08:00:00Z',
},
{
id: 'bt2',
bankAccountId: '1',
date: '2025-01-14',
valueDate: '2025-01-14',
description: 'HUSLEJE JANUAR EJENDOM APS',
amount: -15000,
balance: 140625,
reference: 'BS123456',
counterparty: 'Ejendom ApS',
isReconciled: false,
importedAt: '2025-01-16T08:00:00Z',
},
{
id: 'bt3',
bankAccountId: '1',
date: '2025-01-13',
valueDate: '2025-01-13',
description: 'MOBILEPAY STAPLES',
amount: -499,
balance: 155625,
counterparty: 'Staples',
isReconciled: false,
importedAt: '2025-01-16T08:00:00Z',
},
{
id: 'bt4',
bankAccountId: '1',
date: '2025-01-10',
valueDate: '2025-01-10',
description: 'OVERFOERSEL FRA DEBITOR',
amount: 8750,
balance: 156124,
reference: 'FAKTURA1233',
counterparty: 'Debitor B ApS',
isReconciled: true,
matchedTransactionId: 'tx3',
importedAt: '2025-01-11T08:00:00Z',
},
];
const mockLedgerEntries = [
{
id: 'le1',
date: '2025-01-15',
description: 'Faktura #1234 - Kunde A/S',
amount: 15625,
accountId: '1',
accountName: '1000 Bank',
transactionNumber: '2025-0001',
isReconciled: false,
},
{
id: 'le2',
date: '2025-01-14',
description: 'Husleje januar 2025',
amount: -15000,
accountId: '1',
accountName: '1000 Bank',
transactionNumber: '2025-0002',
isReconciled: false,
},
{
id: 'le3',
date: '2025-01-12',
description: 'Kontorartikler',
amount: -500,
accountId: '1',
accountName: '1000 Bank',
transactionNumber: '2025-0003',
isReconciled: false,
},
];
// Match suggestions (would come from backend AI/rules)
const mockSuggestions = [
{
bankTransactionId: 'bt1',
ledgerEntryId: 'le1',
confidence: 0.95,
reason: 'Beløb og dato matcher, "Faktura 1234" findes i begge',
},
{
bankTransactionId: 'bt2',
ledgerEntryId: 'le2',
confidence: 0.88,
reason: 'Beløb matcher, begge er husleje i januar',
},
];
export default function Bankafstemning() {
const { company } = useCompany();
const [selectedBankAccount, setSelectedBankAccount] = useState<string>(mockBankAccounts[0].id);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
dayjs().startOf('month'),
dayjs().endOf('month'),
]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedBankTx, setSelectedBankTx] = useState<BankTransaction | null>(null);
const [form] = Form.useForm();
const {
selectedBankTransactions,
selectedLedgerTransactions,
toggleBankTransaction,
toggleLedgerTransaction,
clearAllSelections,
pendingMatches,
addPendingMatch,
removePendingMatch,
} = useReconciliationStore();
// Filter transactions
const bankTransactions = mockBankTransactions.filter(
(tx) => tx.bankAccountId === selectedBankAccount && !tx.isReconciled
);
const ledgerEntries = mockLedgerEntries.filter((entry) => !entry.isReconciled);
// Calculate totals
const bankTotal = bankTransactions.reduce((sum, tx) => sum + tx.amount, 0);
const ledgerTotal = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0);
const difference = bankTotal - ledgerTotal;
// Check if selected items can be matched
const canMatch =
selectedBankTransactions.length === 1 && selectedLedgerTransactions.length === 1;
// Get suggestion for a bank transaction
const getSuggestion = (bankTxId: string) => {
return mockSuggestions.find((s) => s.bankTransactionId === bankTxId);
};
const handleMatch = () => {
if (!canMatch) return;
const bankTx = bankTransactions.find((tx) => tx.id === selectedBankTransactions[0]);
const ledgerEntry = ledgerEntries.find((e) => e.id === selectedLedgerTransactions[0]);
if (bankTx && ledgerEntry) {
addPendingMatch({
bankTransactionId: bankTx.id,
ledgerTransactionId: ledgerEntry.id,
matchType: 'existing',
});
message.success('Match tilføjet');
}
};
const handleCreateEntry = (bankTx: BankTransaction) => {
setSelectedBankTx(bankTx);
form.setFieldsValue({
date: dayjs(bankTx.date),
description: bankTx.description,
amount: Math.abs(bankTx.amount),
});
setIsCreateModalOpen(true);
};
const handleSubmitCreate = async () => {
try {
const values = await form.validateFields();
console.log('Creating entry:', values);
if (selectedBankTx) {
addPendingMatch({
bankTransactionId: selectedBankTx.id,
matchType: 'new',
newTransaction: {
description: values.description,
accountId: values.accountId,
},
});
}
message.success('Postering oprettet og matchet');
setIsCreateModalOpen(false);
setSelectedBankTx(null);
} catch (error) {
console.error('Validation failed:', error);
}
};
const handleSaveAll = () => {
if (pendingMatches.length === 0) {
message.warning('Ingen matches at gemme');
return;
}
// TODO: Send to GraphQL mutation
console.log('Saving matches:', pendingMatches);
message.success(`${pendingMatches.length} afstemninger gemt`);
};
const handleApplySuggestion = (suggestion: typeof mockSuggestions[0]) => {
addPendingMatch({
bankTransactionId: suggestion.bankTransactionId,
ledgerTransactionId: suggestion.ledgerEntryId,
matchType: 'existing',
});
message.success('Forslag anvendt');
};
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>
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
<Select
value={selectedBankAccount}
onChange={setSelectedBankAccount}
style={{ width: 250 }}
options={mockBankAccounts.map((acc) => ({
value: acc.id,
label: `${acc.bankName} - ${acc.name}`,
}))}
/>
<RangePicker
value={dateRange}
onChange={(dates) => setDateRange(dates as [dayjs.Dayjs, dayjs.Dayjs])}
format="DD/MM/YYYY"
/>
</Space>
{/* Summary */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small">
<Statistic
title="Bank (uafstemt)"
value={bankTotal}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color: bankTotal >= 0 ? accountingColors.credit : accountingColors.debit,
}}
/>
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Bogføring (uafstemt)"
value={ledgerTotal}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color: ledgerTotal >= 0 ? accountingColors.credit : accountingColors.debit,
}}
/>
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Difference"
value={difference}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color:
Math.abs(difference) < 0.01
? accountingColors.credit
: accountingColors.debit,
}}
/>
</Card>
</Col>
</Row>
{/* Suggestions Alert */}
{mockSuggestions.length > 0 && (
<Alert
message={
<Space>
<BulbOutlined />
<Text strong>{mockSuggestions.length} automatiske matchforslag fundet</Text>
</Space>
}
type="info"
style={{ marginBottom: 16 }}
action={
<Button
size="small"
onClick={() => mockSuggestions.forEach(handleApplySuggestion)}
>
Anvend alle
</Button>
}
/>
)}
{/* Match Button */}
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Button
type="primary"
icon={<LinkOutlined />}
size="large"
onClick={handleMatch}
disabled={!canMatch}
>
Match valgte ({selectedBankTransactions.length} bank,{' '}
{selectedLedgerTransactions.length} bogføring)
</Button>
</div>
{/* Side-by-side panels */}
<Row gutter={16}>
{/* Bank Transactions */}
<Col span={12}>
<Card
title={
<Space>
<Text strong>Banktransaktioner</Text>
<Tag color="blue">{bankTransactions.length} uafstemte</Tag>
</Space>
}
size="small"
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
>
<List
dataSource={bankTransactions}
renderItem={(tx) => {
const suggestion = getSuggestion(tx.id);
const isPending = pendingMatches.some(
(m) => m.bankTransactionId === tx.id
);
const isSelected = selectedBankTransactions.includes(tx.id);
if (isPending) return null;
return (
<List.Item
style={{
padding: '8px 16px',
cursor: 'pointer',
backgroundColor: isSelected ? '#e6f4ff' : undefined,
}}
onClick={() => toggleBankTransaction(tx.id)}
>
<div style={{ width: '100%' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Space>
<Checkbox checked={isSelected} />
<div>
<Text style={{ display: 'block' }}>
{tx.description}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(tx.date)} {tx.counterparty || 'Ukendt'}
</Text>
</div>
</Space>
<div style={{ textAlign: 'right' }}>
<Text
strong
className="tabular-nums"
style={{
color:
tx.amount >= 0
? accountingColors.credit
: accountingColors.debit,
}}
>
{formatCurrency(tx.amount, { showSign: true })}
</Text>
{suggestion && (
<div>
<Tooltip title={suggestion.reason}>
<Tag
color="green"
style={{ cursor: 'pointer', marginTop: 4 }}
onClick={(e) => {
e.stopPropagation();
handleApplySuggestion(suggestion);
}}
>
<BulbOutlined />{' '}
{Math.round(suggestion.confidence * 100)}% match
</Tag>
</Tooltip>
</div>
)}
</div>
</div>
<div style={{ marginTop: 4 }}>
<Button
type="link"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
handleCreateEntry(tx);
}}
>
Opret postering
</Button>
</div>
</div>
</List.Item>
);
}}
/>
</Card>
</Col>
{/* Ledger Entries */}
<Col span={12}>
<Card
title={
<Space>
<Text strong>Bogføringsposter</Text>
<Tag color="orange">{ledgerEntries.length} uafstemte</Tag>
</Space>
}
size="small"
bodyStyle={{ padding: 0, maxHeight: 500, overflow: 'auto' }}
>
<List
dataSource={ledgerEntries}
renderItem={(entry) => {
const isPending = pendingMatches.some(
(m) => m.ledgerTransactionId === entry.id
);
const isSelected = selectedLedgerTransactions.includes(entry.id);
if (isPending) return null;
return (
<List.Item
style={{
padding: '8px 16px',
cursor: 'pointer',
backgroundColor: isSelected ? '#e6f4ff' : undefined,
}}
onClick={() => toggleLedgerTransaction(entry.id)}
>
<div style={{ width: '100%' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Space>
<Checkbox checked={isSelected} />
<div>
<Text style={{ display: 'block' }}>
{entry.description}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(entry.date)} Bilag{' '}
{entry.transactionNumber}
</Text>
</div>
</Space>
<Text
strong
className="tabular-nums"
style={{
color:
entry.amount >= 0
? accountingColors.credit
: accountingColors.debit,
}}
>
{formatCurrency(entry.amount, { showSign: true })}
</Text>
</div>
</div>
</List.Item>
);
}}
/>
</Card>
</Col>
</Row>
{/* Pending Matches */}
{pendingMatches.length > 0 && (
<Card
title={
<Space>
<Text strong>Afventende matches</Text>
<Tag color="blue">{pendingMatches.length}</Tag>
</Space>
}
size="small"
style={{ marginTop: 16 }}
>
<List
dataSource={pendingMatches}
renderItem={(match) => {
const bankTx = mockBankTransactions.find(
(tx) => tx.id === match.bankTransactionId
);
const ledgerEntry = mockLedgerEntries.find(
(e) => e.id === match.ledgerTransactionId
);
return (
<List.Item
actions={[
<Button
type="link"
danger
size="small"
onClick={() => removePendingMatch(match.bankTransactionId)}
>
Fjern
</Button>,
]}
>
<Space split={<SwapOutlined />}>
<Text>
Bank: {bankTx?.description} ({formatCurrency(bankTx?.amount || 0)})
</Text>
{match.matchType === 'existing' ? (
<Text>
Bilag: {ledgerEntry?.transactionNumber} - {ledgerEntry?.description}
</Text>
) : (
<Tag color="green">Ny postering oprettes</Tag>
)}
</Space>
</List.Item>
);
}}
/>
</Card>
)}
{/* Create Entry Modal */}
<Modal
title="Opret bogføringspost"
open={isCreateModalOpen}
onCancel={() => {
setIsCreateModalOpen(false);
setSelectedBankTx(null);
}}
onOk={handleSubmitCreate}
okText="Opret og match"
cancelText="Annuller"
>
{selectedBankTx && (
<Alert
message={
<Space direction="vertical" size={0}>
<Text>Banktransaktion:</Text>
<Text strong>{selectedBankTx.description}</Text>
<Text>
{formatDate(selectedBankTx.date)} {' '}
{formatCurrency(selectedBankTx.amount, { showSign: true })}
</Text>
</Space>
}
type="info"
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} layout="vertical">
<Form.Item
name="date"
label="Dato"
rules={[{ required: true }]}
>
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
name="accountId"
label="Modkonto"
rules={[{ required: true }]}
>
<Select
placeholder="Vælg konto"
options={[
{ value: '6100', label: '6100 - Husleje' },
{ value: '6800', label: '6800 - Kontorartikler' },
{ value: '5000', label: '5000 - Varekøb' },
{ value: '4000', label: '4000 - Salg' },
]}
/>
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vælg momskode"
allowClear
options={[
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
{ value: 'NONE', label: 'Ingen moms' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View file

@ -0,0 +1,329 @@
import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress } from 'antd';
import {
BankOutlined,
RiseOutlined,
FallOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { Line, Pie, Column } from '@ant-design/charts';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
const { Title, Text } = Typography;
// Mock data - will be replaced with API calls
const mockMetrics = {
cashPosition: 1234567.89,
cashChange: 0.12,
accountsReceivable: 456789.12,
arChange: -0.05,
accountsPayable: 234567.89,
apChange: 0.08,
monthlyRevenue: 789012.34,
revenueChange: 0.15,
monthlyExpenses: 567890.12,
expenseChange: 0.03,
vatLiability: 123456.78,
unreconciledCount: 23,
pendingInvoices: 12,
overdueInvoices: 3,
};
const mockCashFlowData = [
{ month: 'Jan', inflow: 120000, outflow: 80000, balance: 40000 },
{ month: 'Feb', inflow: 150000, outflow: 90000, balance: 60000 },
{ month: 'Mar', inflow: 130000, outflow: 85000, balance: 45000 },
{ month: 'Apr', inflow: 160000, outflow: 95000, balance: 65000 },
{ month: 'Maj', inflow: 140000, outflow: 100000, balance: 40000 },
{ month: 'Jun', inflow: 180000, outflow: 110000, balance: 70000 },
];
const mockExpenseBreakdown = [
{ category: 'Personale', value: 45 },
{ category: 'Lokaler', value: 20 },
{ category: 'IT & Software', value: 15 },
{ category: 'Marketing', value: 10 },
{ category: 'Andet', value: 10 },
];
const mockRecentTransactions = [
{ id: '1', date: '2025-01-15', description: 'Faktura #1234', amount: 12500, type: 'income' },
{ id: '2', date: '2025-01-14', description: 'Husleje januar', amount: -15000, type: 'expense' },
{ id: '3', date: '2025-01-13', description: 'Faktura #1233', amount: 8750, type: 'income' },
{ id: '4', date: '2025-01-12', description: 'Telefon abonnement', amount: -499, type: 'expense' },
{ id: '5', date: '2025-01-11', description: 'Faktura #1232', amount: 25000, type: 'income' },
];
export default function Dashboard() {
const { company } = useCompany();
const cashFlowConfig = {
data: mockCashFlowData,
xField: 'month',
yField: 'balance',
smooth: true,
color: accountingColors.balance,
point: { size: 4, shape: 'circle' },
area: { style: { fillOpacity: 0.1 } },
yAxis: {
label: {
formatter: (v: string) => `${parseInt(v) / 1000}k`,
},
},
height: 200,
};
const expenseConfig = {
data: mockExpenseBreakdown,
angleField: 'value',
colorField: 'category',
radius: 0.8,
innerRadius: 0.6,
label: {
type: 'outer',
formatter: (datum: { category: string; value: number }) =>
`${datum.category}: ${datum.value}%`,
},
legend: {
position: 'right' as const,
},
height: 200,
};
const revenueExpenseConfig = {
data: mockCashFlowData.flatMap((d) => [
{ month: d.month, type: 'Indtaegter', value: d.inflow },
{ month: d.month, type: 'Udgifter', value: d.outflow },
]),
isGroup: true,
xField: 'month',
yField: 'value',
seriesField: 'type',
color: [accountingColors.credit, accountingColors.debit],
yAxis: {
label: {
formatter: (v: string) => `${parseInt(v) / 1000}k`,
},
},
height: 200,
};
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>
{/* KPI Cards */}
<Row gutter={[16, 16]}>
{/* Cash Position */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Likviditet"
value={mockMetrics.cashPosition}
precision={2}
prefix={<BankOutlined />}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag
color={mockMetrics.cashChange >= 0 ? 'green' : 'red'}
icon={mockMetrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
>
{mockMetrics.cashChange >= 0 ? '+' : ''}
{(mockMetrics.cashChange * 100).toFixed(1)}% denne maaned
</Tag>
</div>
</Card>
</Col>
{/* Accounts Receivable */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Tilgodehavender"
value={mockMetrics.accountsReceivable}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Space size={4}>
<Tag color="blue">{mockMetrics.pendingInvoices} afventer</Tag>
{mockMetrics.overdueInvoices > 0 && (
<Tag color="red" icon={<WarningOutlined />}>
{mockMetrics.overdueInvoices} forfaldne
</Tag>
)}
</Space>
</div>
</Card>
</Col>
{/* Accounts Payable */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Kreditorer"
value={mockMetrics.accountsPayable}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color={mockMetrics.apChange >= 0 ? 'orange' : 'green'}>
{mockMetrics.apChange >= 0 ? '+' : ''}
{(mockMetrics.apChange * 100).toFixed(1)}% denne maaned
</Tag>
</div>
</Card>
</Col>
{/* VAT Liability */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Moms til betaling"
value={mockMetrics.vatLiability}
precision={2}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color="blue">Naeste frist: 1. marts</Tag>
</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">
<Line {...cashFlowConfig} />
</Card>
</Col>
{/* Revenue vs Expenses */}
<Col xs={24} lg={12}>
<Card title="Indtaegter vs. Udgifter" size="small">
<Column {...revenueExpenseConfig} />
</Card>
</Col>
</Row>
{/* Bottom Row */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* Expense Breakdown */}
<Col xs={24} lg={8}>
<Card title="Udgiftsfordeling" size="small">
<Pie {...expenseConfig} />
</Card>
</Col>
{/* Reconciliation Status */}
<Col xs={24} lg={8}>
<Card title="Afstemningsstatus" size="small">
<div style={{ padding: '16px 0' }}>
<Statistic
title="Uafstemte transaktioner"
value={mockMetrics.unreconciledCount}
prefix={<FileTextOutlined />}
/>
<Progress
percent={75}
status="active"
strokeColor={accountingColors.balance}
style={{ marginTop: 16 }}
/>
<Text type="secondary">75% afstemt denne maaned</Text>
</div>
</Card>
</Col>
{/* Recent Transactions */}
<Col xs={24} lg={8}>
<Card
title="Seneste transaktioner"
size="small"
bodyStyle={{ padding: 0 }}
>
<div style={{ maxHeight: 240, overflow: 'auto' }}>
{mockRecentTransactions.map((tx) => (
<div
key={tx.id}
style={{
padding: '8px 16px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<Text style={{ display: 'block' }}>{tx.description}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(tx.date)}
</Text>
</div>
<Text
strong
className="tabular-nums"
style={{
color: tx.amount >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(tx.amount, { showSign: true })}
</Text>
</div>
))}
</div>
</Card>
</Col>
</Row>
{/* Quick Actions */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title="Hurtige handlinger" size="small">
<Row gutter={[16, 16]}>
<Col>
<Space>
<CheckCircleOutlined style={{ color: accountingColors.credit }} />
<Text>23 transaktioner klar til afstemning</Text>
</Space>
</Col>
<Col>
<Space>
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
<Text>Momsindberetning forfalder om 14 dage</Text>
</Space>
</Col>
<Col>
<Space>
<WarningOutlined style={{ color: accountingColors.debit }} />
<Text>3 fakturaer er forfaldne</Text>
</Space>
</Col>
</Row>
</Card>
</Col>
</Row>
</div>
);
}

View file

@ -0,0 +1,322 @@
// HurtigBogforing - Quick Booking Page for simple bank transaction processing
import { useState, useEffect, useMemo } from 'react';
import {
Card,
Row,
Col,
Select,
Typography,
Space,
Badge,
Statistic,
Divider,
Switch,
message,
Spin,
} from 'antd';
import {
BankOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { formatCurrency } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { Account, BankAccount } from '@/types/accounting';
import type { GeneratedTransaction } from '@/lib/accounting';
import {
BankTransactionList,
QuickBookModal,
SplitBookModal,
} from '@/components/simple-booking';
import {
useSimpleBookingStore,
useUnbookedTransactions,
type PendingBankTransaction,
} from '@/stores/simpleBookingStore';
const { Title, Text } = Typography;
// Mock data for development - replace with actual API calls
const MOCK_BANK_ACCOUNTS: BankAccount[] = [
{
id: 'bank-1',
name: 'Hovedkonto',
accountNumber: '1000',
bankName: 'Danske Bank',
iban: 'DK12 3456 7890 1234 56',
currency: 'DKK',
balance: 125000,
lastSyncedAt: new Date().toISOString(),
isActive: true,
},
{
id: 'bank-2',
name: 'Opsparingskonto',
accountNumber: '1010',
bankName: 'Danske Bank',
iban: 'DK12 3456 7890 1234 99',
currency: 'DKK',
balance: 50000,
lastSyncedAt: new Date().toISOString(),
isActive: true,
},
];
const MOCK_ACCOUNTS: Account[] = [
{ id: 'acc-1', accountNumber: '1000', name: 'Bank', type: 'asset', balance: 125000 },
{ id: 'acc-2', accountNumber: '4000', name: 'Salg af varer', type: 'revenue', balance: 0 },
{ id: 'acc-3', accountNumber: '4100', name: 'Salg af ydelser', type: 'revenue', balance: 0 },
{ id: 'acc-4', accountNumber: '5000', name: 'Varekoeb', type: 'cogs', balance: 0 },
{ id: 'acc-5', accountNumber: '6100', name: 'Husleje', type: 'expense', balance: 0 },
{ id: 'acc-6', accountNumber: '6200', name: 'El og varme', type: 'expense', balance: 0 },
{ id: 'acc-7', accountNumber: '6300', name: 'Vedligeholdelse', type: 'expense', balance: 0 },
{ id: 'acc-8', accountNumber: '6400', name: 'Administration', type: 'expense', balance: 0 },
{ id: 'acc-9', accountNumber: '6500', name: 'IT og software', type: 'expense', balance: 0 },
{ id: 'acc-10', accountNumber: '6800', name: 'Kontorartikler', type: 'expense', balance: 0 },
{ id: 'acc-11', accountNumber: '7100', name: 'Loen', type: 'personnel', balance: 0 },
{ id: 'acc-12', accountNumber: '7200', name: 'ATP', type: 'personnel', balance: 0 },
{ id: 'acc-13', accountNumber: '8100', name: 'Renteindtaegter', type: 'financial', balance: 0 },
{ id: 'acc-14', accountNumber: '8200', name: 'Renteudgifter', type: 'financial', balance: 0 },
];
const MOCK_PENDING_TRANSACTIONS: PendingBankTransaction[] = [
{
id: 'tx-1',
date: '2025-01-15',
amount: -15000,
description: 'HUSLEJE JANUAR EJENDOM APS',
counterparty: 'Ejendom ApS',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: false,
},
{
id: 'tx-2',
date: '2025-01-14',
amount: -499,
description: 'MOBILEPAY STAPLES',
counterparty: 'Staples',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: false,
},
{
id: 'tx-3',
date: '2025-01-13',
amount: 25000,
description: 'OVERFOERSEL FRA KUNDE ABC APS',
counterparty: 'ABC ApS',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: false,
},
{
id: 'tx-4',
date: '2025-01-12',
amount: -10000,
description: 'SAMLET BETALING DIVERSE',
counterparty: 'Diverse leverandoerer',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: false,
},
{
id: 'tx-5',
date: '2025-01-11',
amount: -2500,
description: 'ELREGNING JANUAR',
counterparty: 'Andel Energi',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: false,
},
{
id: 'tx-6',
date: '2025-01-10',
amount: 12500,
description: 'FAKTURA 2025-001',
counterparty: 'XYZ Holding A/S',
bankAccountId: 'bank-1',
bankAccountNumber: '1000',
isBooked: true,
bookedAt: '2025-01-10T14:30:00Z',
transactionId: 'booked-tx-1',
},
];
export function HurtigBogforing() {
const [selectedBankAccountId, setSelectedBankAccountId] = useState<string | undefined>(
MOCK_BANK_ACCOUNTS[0]?.id
);
const [showBooked, setShowBooked] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {
setPendingTransactions,
openSimpleBooking,
openSplitBooking,
markAsBooked,
setSaving,
} = useSimpleBookingStore();
const unbookedTransactions = useUnbookedTransactions();
// Initialize with mock data
useEffect(() => {
setPendingTransactions(MOCK_PENDING_TRANSACTIONS);
}, [setPendingTransactions]);
// Filter transactions by selected bank account
const filteredTransactions = useMemo(() => {
if (!selectedBankAccountId) return [];
return MOCK_PENDING_TRANSACTIONS.filter(
(tx) => tx.bankAccountId === selectedBankAccountId
);
}, [selectedBankAccountId]);
const selectedBankAccount = MOCK_BANK_ACCOUNTS.find(
(ba) => ba.id === selectedBankAccountId
);
// Stats
const unbookedCount = filteredTransactions.filter((tx) => !tx.isBooked).length;
const bookedCount = filteredTransactions.filter((tx) => tx.isBooked).length;
const totalUnbookedAmount = filteredTransactions
.filter((tx) => !tx.isBooked)
.reduce((sum, tx) => sum + tx.amount, 0);
// Handle booking submission
const handleBookingSubmit = async (transaction: GeneratedTransaction) => {
setSaving(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500));
// Mark as booked
markAsBooked(transaction.bankTransactionId, `tx-${Date.now()}`);
message.success('Transaktion bogfoert');
} catch (error) {
message.error('Fejl ved bogfoering');
} finally {
setSaving(false);
}
};
// Bank account selector options
const bankAccountOptions = MOCK_BANK_ACCOUNTS.map((ba) => ({
value: ba.id,
label: (
<Space>
<BankOutlined />
<span>{ba.name}</span>
<Text type="secondary">({ba.accountNumber})</Text>
</Space>
),
}));
return (
<div className="hurtig-bogforing-page">
{/* Header */}
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>
<ThunderboltOutlined style={{ marginRight: 8 }} />
Hurtig Bogfoering
</Title>
<Text type="secondary">
Bogfoer banktransaktioner hurtigt med et enkelt klik
</Text>
</div>
{/* Stats and controls */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card size="small">
<Statistic
title="Bankkonto"
valueRender={() => (
<Select
style={{ width: '100%' }}
value={selectedBankAccountId}
onChange={setSelectedBankAccountId}
options={bankAccountOptions}
/>
)}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Ubogfoerte transaktioner"
value={unbookedCount}
prefix={<ClockCircleOutlined />}
valueStyle={{ color: unbookedCount > 0 ? accountingColors.warning : accountingColors.credit }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Bogfoerte transaktioner"
value={bookedCount}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: accountingColors.credit }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Ubogfoert beloeb"
value={formatCurrency(totalUnbookedAmount)}
valueStyle={{ color: totalUnbookedAmount < 0 ? accountingColors.debit : accountingColors.credit }}
/>
</Card>
</Col>
</Row>
{/* Bank account info */}
{selectedBankAccount && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row justify="space-between" align="middle">
<Col>
<Space>
<BankOutlined style={{ fontSize: 20 }} />
<div>
<Text strong>{selectedBankAccount.name}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
{selectedBankAccount.bankName} {selectedBankAccount.iban}
</Text>
</div>
</Space>
</Col>
<Col>
<Space>
<Text>Vis boerte:</Text>
<Switch checked={showBooked} onChange={setShowBooked} />
</Space>
</Col>
</Row>
</Card>
)}
{/* Transaction list */}
<Spin spinning={isLoading}>
<BankTransactionList
transactions={filteredTransactions}
onBook={openSimpleBooking}
onSplit={openSplitBooking}
showBooked={showBooked}
/>
</Spin>
{/* Modals */}
<QuickBookModal accounts={MOCK_ACCOUNTS} onSubmit={handleBookingSubmit} />
<SplitBookModal accounts={MOCK_ACCOUNTS} onSubmit={handleBookingSubmit} />
</div>
);
}
export default HurtigBogforing;

View file

@ -0,0 +1,540 @@
import { useState } from 'react';
import {
Typography,
Button,
Space,
DatePicker,
Select,
Modal,
Form,
Input,
InputNumber,
message,
Tag,
Tooltip,
Dropdown,
} from 'antd';
import {
PlusOutlined,
FilterOutlined,
EyeOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import dayjs from 'dayjs';
import DataTable, { DataTableColumn } from '@/components/tables/DataTable';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency } from '@/lib/formatters';
import { validateDoubleEntry } from '@/lib/accounting';
import type { Transaction, TransactionLine, Account } from '@/types/accounting';
const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
// Mock data - will be replaced with GraphQL queries
const mockAccounts: Account[] = [
{ id: '1', companyId: '1', accountNumber: '1000', name: 'Bank', type: 'asset', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '2', companyId: '1', accountNumber: '1100', name: 'Debitorer', type: 'asset', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '3', companyId: '1', accountNumber: '2000', name: 'Kreditorer', type: 'liability', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '4', companyId: '1', accountNumber: '4000', name: 'Salg af varer', type: 'revenue', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '5', companyId: '1', accountNumber: '5000', name: 'Varekøb', type: 'cogs', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '6', companyId: '1', accountNumber: '6100', name: 'Husleje', type: 'expense', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
{ id: '7', companyId: '1', accountNumber: '6800', name: 'Kontorartikler', type: 'expense', isActive: true, balance: 0, createdAt: '', updatedAt: '' },
];
const mockTransactions: Transaction[] = [
{
id: '1',
companyId: '1',
transactionNumber: '2025-0001',
date: '2025-01-15',
description: 'Salg faktura #1234',
lines: [
{ id: '1-1', transactionId: '1', accountId: '2', debit: 15625, credit: 0, description: 'Debitor' },
{ id: '1-2', transactionId: '1', accountId: '4', debit: 0, credit: 12500, description: 'Salg' },
{ id: '1-3', transactionId: '1', accountId: '8', debit: 0, credit: 3125, description: 'Moms' },
],
isReconciled: true,
isVoided: false,
attachments: [],
createdAt: '2025-01-15T10:00:00Z',
updatedAt: '2025-01-15T10:00:00Z',
createdBy: 'user1',
},
{
id: '2',
companyId: '1',
transactionNumber: '2025-0002',
date: '2025-01-14',
description: 'Husleje januar 2025',
lines: [
{ id: '2-1', transactionId: '2', accountId: '6', debit: 15000, credit: 0, description: 'Husleje' },
{ id: '2-2', transactionId: '2', accountId: '1', debit: 0, credit: 15000, description: 'Bank' },
],
isReconciled: false,
isVoided: false,
attachments: [],
createdAt: '2025-01-14T09:00:00Z',
updatedAt: '2025-01-14T09:00:00Z',
createdBy: 'user1',
},
{
id: '3',
companyId: '1',
transactionNumber: '2025-0003',
date: '2025-01-13',
description: 'Køb af kontorartikler',
lines: [
{ id: '3-1', transactionId: '3', accountId: '7', debit: 500, credit: 0, description: 'Kontorartikler' },
{ id: '3-2', transactionId: '3', accountId: '1', debit: 0, credit: 500, description: 'Bank' },
],
isReconciled: true,
isVoided: false,
attachments: [],
createdAt: '2025-01-13T14:00:00Z',
updatedAt: '2025-01-13T14:00:00Z',
createdBy: 'user1',
},
];
// Extend transaction with calculated fields for display
interface TransactionDisplay extends Transaction {
totalDebit: number;
totalCredit: number;
}
export default function Kassekladde() {
const { company } = useCompany();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [dateFilter, setDateFilter] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [form] = Form.useForm();
const [lines, setLines] = useState<Partial<TransactionLine>[]>([
{ debit: 0, credit: 0 },
{ debit: 0, credit: 0 },
]);
// Process transactions for display
const displayData: TransactionDisplay[] = mockTransactions.map((tx) => ({
...tx,
totalDebit: tx.lines.reduce((sum, line) => sum + (line.debit || 0), 0),
totalCredit: tx.lines.reduce((sum, line) => sum + (line.credit || 0), 0),
}));
const columns: DataTableColumn<TransactionDisplay>[] = [
{
dataIndex: 'transactionNumber',
title: 'Bilagsnr.',
width: 120,
sortable: true,
render: (value) => <Text strong>#{value as string}</Text>,
},
{
dataIndex: 'date',
title: 'Dato',
width: 100,
sortable: true,
columnType: 'date',
},
{
dataIndex: 'description',
title: 'Beskrivelse',
ellipsis: true,
},
{
dataIndex: 'totalDebit',
title: 'Debet',
width: 120,
sortable: true,
columnType: 'currency',
},
{
dataIndex: 'totalCredit',
title: 'Kredit',
width: 120,
sortable: true,
columnType: 'currency',
},
{
dataIndex: 'isReconciled',
title: 'Status',
width: 100,
render: (value, record) => {
if (record.isVoided) {
return <Tag color="red">Annulleret</Tag>;
}
return value ? (
<Tag color="green">Afstemt</Tag>
) : (
<Tag color="orange">Uafstemt</Tag>
);
},
},
{
dataIndex: 'id',
title: '',
width: 50,
render: (_, record) => {
const menuItems: MenuProps['items'] = [
{
key: 'view',
icon: <EyeOutlined />,
label: 'Vis detaljer',
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Rediger',
disabled: record.isReconciled || record.isVoided,
},
{
key: 'copy',
icon: <CopyOutlined />,
label: 'Kopier',
},
{
type: 'divider',
},
{
key: 'void',
icon: <DeleteOutlined />,
label: 'Annuller',
danger: true,
disabled: record.isVoided,
},
];
return (
<Dropdown
menu={{
items: menuItems,
onClick: ({ key }) => handleAction(key, record),
}}
trigger={['click']}
>
<Button type="text" icon={<MoreOutlined />} size="small" />
</Dropdown>
);
},
},
];
const handleAction = (action: string, record: TransactionDisplay) => {
switch (action) {
case 'view':
// TODO: Show transaction details modal
message.info(`Vis detaljer for bilag ${record.transactionNumber}`);
break;
case 'edit':
setEditingTransaction(record);
setIsModalOpen(true);
break;
case 'copy':
// TODO: Copy transaction
message.success(`Bilag ${record.transactionNumber} kopieret`);
break;
case 'void':
Modal.confirm({
title: 'Annuller bilag',
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`);
},
});
break;
}
};
const handleAddLine = () => {
setLines([...lines, { debit: 0, credit: 0 }]);
};
const handleRemoveLine = (index: number) => {
if (lines.length > 2) {
setLines(lines.filter((_, i) => i !== index));
}
};
const handleLineChange = (index: number, field: string, value: unknown) => {
const newLines = [...lines];
newLines[index] = { ...newLines[index], [field]: value };
// Auto-balance: if debit is entered, clear credit and vice versa
if (field === 'debit' && value) {
newLines[index].credit = 0;
} else if (field === 'credit' && value) {
newLines[index].debit = 0;
}
setLines(newLines);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Validate double-entry
const validation = validateDoubleEntry(lines as TransactionLine[]);
if (!validation.valid) {
message.error(
`Debet (${formatCurrency(validation.totalDebit)}) skal være lig kredit (${formatCurrency(validation.totalCredit)})`
);
return;
}
// TODO: Submit via GraphQL mutation
console.log('Submitting:', { ...values, lines });
message.success('Bilag oprettet');
setIsModalOpen(false);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
} catch (error) {
console.error('Validation failed:', error);
}
};
const balance = validateDoubleEntry(lines as TransactionLine[]);
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">{company?.name}</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingTransaction(null);
setIsModalOpen(true);
}}
>
Nyt bilag
</Button>
</div>
{/* Filters */}
<Space style={{ marginBottom: 16 }} wrap>
<RangePicker
placeholder={['Fra dato', 'Til dato']}
value={dateFilter}
onChange={(dates) => setDateFilter(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
format="DD/MM/YYYY"
/>
<Select
placeholder="Konto"
style={{ width: 200 }}
allowClear
options={mockAccounts.map((acc) => ({
value: acc.id,
label: `${acc.accountNumber} - ${acc.name}`,
}))}
/>
<Select
placeholder="Status"
style={{ width: 120 }}
allowClear
options={[
{ value: 'reconciled', label: 'Afstemt' },
{ value: 'unreconciled', label: 'Uafstemt' },
{ value: 'voided', label: 'Annulleret' },
]}
/>
<Button icon={<FilterOutlined />}>Flere filtre</Button>
</Space>
{/* Data Table */}
<DataTable<TransactionDisplay>
data={displayData}
columns={columns}
exportFilename="kassekladde"
rowSelection="multiple"
onRowClick={(record) => handleAction('view', record)}
rowClassName={(record) =>
record.isVoided ? 'voided-row' : ''
}
/>
{/* Create/Edit Modal */}
<Modal
title={editingTransaction ? 'Rediger bilag' : 'Nyt bilag'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
setLines([{ debit: 0, credit: 0 }, { debit: 0, credit: 0 }]);
}}
onOk={handleSubmit}
okText="Gem"
cancelText="Annuller"
width={800}
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }} direction="vertical" size="middle">
<Space style={{ width: '100%' }}>
<Form.Item
name="date"
label="Dato"
rules={[{ required: true, message: 'Vælg dato' }]}
initialValue={dayjs()}
>
<DatePicker format="DD/MM/YYYY" style={{ width: 150 }} />
</Form.Item>
<Form.Item
name="description"
label="Beskrivelse"
rules={[{ required: true, message: 'Indtast beskrivelse' }]}
style={{ flex: 1 }}
>
<Input placeholder="F.eks. Faktura #1234 til kunde" />
</Form.Item>
</Space>
{/* Transaction Lines */}
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Posteringslinjer
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<th style={{ textAlign: 'left', padding: 8, width: '40%' }}>Konto</th>
<th style={{ textAlign: 'right', padding: 8, width: '20%' }}>Debet</th>
<th style={{ textAlign: 'right', padding: 8, width: '20%' }}>Kredit</th>
<th style={{ textAlign: 'left', padding: 8, width: '15%' }}>Tekst</th>
<th style={{ width: '5%' }}></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index}>
<td style={{ padding: 4 }}>
<Select
style={{ width: '100%' }}
placeholder="Vælg konto"
showSearch
optionFilterProp="label"
value={line.accountId}
onChange={(value) => handleLineChange(index, 'accountId', value)}
options={mockAccounts.map((acc) => ({
value: acc.id,
label: `${acc.accountNumber} - ${acc.name}`,
}))}
/>
</td>
<td style={{ padding: 4 }}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
value={line.debit}
onChange={(value) => handleLineChange(index, 'debit', value || 0)}
formatter={(value) =>
`${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
parser={(value) =>
value?.replace(/\./g, '').replace(',', '.') as unknown as number
}
/>
</td>
<td style={{ padding: 4 }}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
value={line.credit}
onChange={(value) => handleLineChange(index, 'credit', value || 0)}
formatter={(value) =>
`${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
parser={(value) =>
value?.replace(/\./g, '').replace(',', '.') as unknown as number
}
/>
</td>
<td style={{ padding: 4 }}>
<Input
placeholder="Valgfri"
value={line.description}
onChange={(e) =>
handleLineChange(index, 'description', e.target.value)
}
/>
</td>
<td style={{ padding: 4 }}>
{lines.length > 2 && (
<Button
type="text"
danger
size="small"
onClick={() => handleRemoveLine(index)}
>
×
</Button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #f0f0f0' }}>
<td style={{ padding: 8 }}>
<Button type="dashed" size="small" onClick={handleAddLine}>
+ Tilføj linje
</Button>
</td>
<td
style={{
padding: 8,
textAlign: 'right',
fontWeight: 'bold',
}}
>
{formatCurrency(balance.totalDebit)}
</td>
<td
style={{
padding: 8,
textAlign: 'right',
fontWeight: 'bold',
}}
>
{formatCurrency(balance.totalCredit)}
</td>
<td colSpan={2} style={{ padding: 8 }}>
{!balance.valid && (
<Tooltip title={`Difference: ${formatCurrency(balance.difference)}`}>
<Tag color="red">Ubalance!</Tag>
</Tooltip>
)}
{balance.valid && balance.totalDebit > 0 && (
<Tag color="green">Balancerer</Tag>
)}
</td>
</tr>
</tfoot>
</table>
</div>
</Space>
</Form>
</Modal>
</div>
);
}

View file

@ -0,0 +1,528 @@
import { useState } from 'react';
import {
Typography,
Button,
Card,
Row,
Col,
Tree,
Table,
Space,
Tag,
Modal,
Form,
Input,
Select,
Tabs,
Statistic,
message,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
FolderOutlined,
FileOutlined,
SearchOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { getAccountTypeName, getAccountNumberRange } from '@/lib/accounting';
import { accountingColors } from '@/styles/theme';
import type { Account, AccountType } from '@/types/accounting';
const { Title, Text } = Typography;
// Mock data
const mockAccounts: Account[] = [
{ id: '1', companyId: '1', accountNumber: '1000', name: 'Bankkonto', type: 'asset', isActive: true, balance: 125000, createdAt: '', updatedAt: '' },
{ id: '2', companyId: '1', accountNumber: '1100', name: 'Debitorer', type: 'asset', isActive: true, balance: 45000, createdAt: '', updatedAt: '' },
{ id: '3', companyId: '1', accountNumber: '1200', name: 'Varelager', type: 'asset', isActive: true, balance: 75000, createdAt: '', updatedAt: '' },
{ id: '4', companyId: '1', accountNumber: '2000', name: 'Kreditorer', type: 'liability', isActive: true, balance: -35000, createdAt: '', updatedAt: '' },
{ id: '5', companyId: '1', accountNumber: '2100', name: 'Moms', type: 'liability', isActive: true, balance: -12500, createdAt: '', updatedAt: '' },
{ id: '6', companyId: '1', accountNumber: '3000', name: 'Egenkapital', type: 'equity', isActive: true, balance: -100000, createdAt: '', updatedAt: '' },
{ id: '7', companyId: '1', accountNumber: '4000', name: 'Salg af varer', type: 'revenue', isActive: true, balance: -250000, createdAt: '', updatedAt: '' },
{ id: '8', companyId: '1', accountNumber: '5000', name: 'Varekøb', type: 'cogs', isActive: true, balance: 80000, createdAt: '', updatedAt: '' },
{ id: '9', companyId: '1', accountNumber: '6100', name: 'Husleje', type: 'expense', isActive: true, balance: 60000, createdAt: '', updatedAt: '' },
{ id: '10', companyId: '1', accountNumber: '6200', name: 'El og varme', type: 'expense', isActive: true, balance: 8000, createdAt: '', updatedAt: '' },
{ id: '11', companyId: '1', accountNumber: '7000', name: 'Lønninger', type: 'personnel', isActive: true, balance: 120000, createdAt: '', updatedAt: '' },
{ id: '12', companyId: '1', accountNumber: '8100', name: 'Renteindtægter', type: 'financial', isActive: true, balance: -500, createdAt: '', updatedAt: '' },
];
const mockTransactions: { accountId: string; date: string; description: string; debit: number; credit: number; balance: number }[] = [
{ accountId: '1', date: '2025-01-15', description: 'Faktura #1234 modtaget', debit: 15625, credit: 0, balance: 140625 },
{ accountId: '1', date: '2025-01-14', description: 'Husleje januar', debit: 0, credit: 15000, balance: 125000 },
{ accountId: '1', date: '2025-01-13', description: 'Kontorartikler', debit: 0, credit: 500, balance: 140000 },
{ accountId: '1', date: '2025-01-10', description: 'Salg #1233', debit: 8750, credit: 0, balance: 140500 },
{ accountId: '1', date: '2025-01-05', description: 'Lønudbetaling', debit: 0, credit: 45000, balance: 131750 },
];
const accountTypes: AccountType[] = [
'asset',
'liability',
'equity',
'revenue',
'cogs',
'expense',
'personnel',
'financial',
'extraordinary',
];
export default function Kontooversigt() {
const { company } = useCompany();
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
// Build tree data from accounts
const buildTreeData = (): DataNode[] => {
return accountTypes.map((type) => {
const range = getAccountNumberRange(type);
const typeAccounts = mockAccounts.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,
})),
};
});
};
const handleSelectAccount = (selectedKeys: React.Key[]) => {
const key = selectedKeys[0];
if (key && !accountTypes.includes(key as AccountType)) {
const account = mockAccounts.find((acc) => acc.id === key);
setSelectedAccount(account || null);
}
};
const handleCreateAccount = () => {
setEditingAccount(null);
form.resetFields();
setIsModalOpen(true);
};
const handleEditAccount = (account: Account) => {
setEditingAccount(account);
form.setFieldsValue(account);
setIsModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
console.log('Submitting account:', values);
message.success(editingAccount ? 'Konto opdateret' : 'Konto oprettet');
setIsModalOpen(false);
} catch (error) {
console.error('Validation failed:', error);
}
};
// Account transactions for selected account
const accountTransactions = selectedAccount
? mockTransactions.filter((tx) => tx.accountId === selectedAccount.id)
: [];
const transactionColumns = [
{
dataIndex: 'date',
title: 'Dato',
width: 100,
render: (value: string) => formatDate(value),
},
{
dataIndex: 'description',
title: 'Beskrivelse',
ellipsis: true,
},
{
dataIndex: 'debit',
title: 'Debet',
width: 120,
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<span className="tabular-nums">{formatCurrency(value)}</span>
) : null,
},
{
dataIndex: 'credit',
title: 'Kredit',
width: 120,
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<span className="tabular-nums">{formatCurrency(value)}</span>
) : null,
},
{
dataIndex: 'balance',
title: 'Saldo',
width: 120,
align: 'right' as const,
render: (value: number) => (
<span
className="tabular-nums"
style={{
fontWeight: 'bold',
color: value >= 0 ? accountingColors.credit : accountingColors.debit,
}}
>
{formatCurrency(value)}
</span>
),
},
];
// Calculate totals
const totalAssets = mockAccounts
.filter((a) => a.type === 'asset')
.reduce((sum, a) => sum + a.balance, 0);
const totalLiabilities = mockAccounts
.filter((a) => ['liability', 'equity'].includes(a.type))
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalRevenue = mockAccounts
.filter((a) => a.type === 'revenue')
.reduce((sum, a) => sum + Math.abs(a.balance), 0);
const totalExpenses = mockAccounts
.filter((a) => ['cogs', 'expense', 'personnel', 'financial'].includes(a.type))
.reduce((sum, a) => sum + a.balance, 0);
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Kontooversigt
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateAccount}>
Ny konto
</Button>
</div>
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Aktiver"
value={totalAssets}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Passiver"
value={totalLiabilities}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Omsætning"
value={totalRevenue}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Omkostninger"
value={totalExpenses}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
</Row>
{/* Main Content */}
<Row gutter={16}>
{/* Account Tree */}
<Col xs={24} lg={10}>
<Card
title="Kontoplan"
size="small"
extra={
<Input
placeholder="Søg konto..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 200 }}
allowClear
/>
}
>
<Tree
showIcon
defaultExpandAll
treeData={buildTreeData()}
onSelect={handleSelectAccount}
selectedKeys={selectedAccount ? [selectedAccount.id] : []}
style={{ maxHeight: 500, overflow: 'auto' }}
/>
</Card>
</Col>
{/* Account Details */}
<Col xs={24} lg={14}>
{selectedAccount ? (
<Card
title={
<Space>
<Text code>{selectedAccount.accountNumber}</Text>
<Text strong>{selectedAccount.name}</Text>
</Space>
}
size="small"
extra={
<Button
icon={<EditOutlined />}
onClick={() => handleEditAccount(selectedAccount)}
>
Rediger
</Button>
}
>
<Tabs
items={[
{
key: 'transactions',
label: 'Bevægelser',
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>
<Table
dataSource={accountTransactions}
columns={transactionColumns}
rowKey={(_, index) => String(index)}
size="small"
pagination={{ pageSize: 10 }}
/>
</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">
<div
style={{
textAlign: 'center',
padding: 40,
color: '#8c8c8c',
}}
>
<FileOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>Vælg en konto for at se detaljer</div>
</div>
</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"
>
<Form form={form} layout="vertical">
<Form.Item
name="accountNumber"
label="Kontonummer"
rules={[
{ required: true, message: 'Indtast kontonummer' },
{
pattern: /^\d{4}$/,
message: 'Kontonummer skal være 4 cifre',
},
]}
>
<Input placeholder="F.eks. 1000" maxLength={4} />
</Form.Item>
<Form.Item
name="name"
label="Kontonavn"
rules={[{ required: true, message: 'Indtast kontonavn' }]}
>
<Input placeholder="F.eks. Bankkonto" />
</Form.Item>
<Form.Item
name="type"
label="Kontotype"
rules={[{ required: true, message: 'Vælg kontotype' }]}
>
<Select
placeholder="Vælg type"
options={accountTypes.map((type) => ({
value: type,
label: getAccountTypeName(type),
}))}
/>
</Form.Item>
<Form.Item name="vatCode" label="Momskode">
<Select
placeholder="Vælg momskode"
allowClear
options={[
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
{ value: 'E0', label: 'E0 - EU-varekøb 0%' },
{ value: 'U0', label: 'U0 - Eksport 0%' },
]}
/>
</Form.Item>
<Form.Item name="description" label="Beskrivelse">
<Input.TextArea rows={2} placeholder="Valgfri beskrivelse" />
</Form.Item>
<Form.Item name="isActive" label="Status" initialValue={true}>
<Select
options={[
{ value: true, label: 'Aktiv' },
{ value: false, label: 'Inaktiv' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View file

@ -0,0 +1,567 @@
import { useState } from 'react';
import {
Typography,
Card,
Row,
Col,
Select,
DatePicker,
Table,
Statistic,
Tag,
Space,
Button,
Descriptions,
Collapse,
} from 'antd';
import {
TeamOutlined,
DownloadOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Column } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { formatCurrency } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { Employee, PayrollEntry } from '@/types/accounting';
const { Title, Text } = Typography;
// Mock data
const mockEmployees: Employee[] = [
{
id: '1',
companyId: '1',
name: 'Anders Andersen',
employeeNumber: 'EMP001',
department: 'Salg',
position: 'Salgschef',
startDate: '2020-03-15',
isActive: true,
},
{
id: '2',
companyId: '1',
name: 'Bente Bentsen',
employeeNumber: 'EMP002',
department: 'Økonomi',
position: 'Bogholder',
startDate: '2021-06-01',
isActive: true,
},
{
id: '3',
companyId: '1',
name: 'Christian Christensen',
employeeNumber: 'EMP003',
department: 'IT',
position: 'Udvikler',
startDate: '2022-01-10',
isActive: true,
},
{
id: '4',
companyId: '1',
name: 'Dorthe Dahl',
employeeNumber: 'EMP004',
department: 'HR',
position: 'HR Manager',
startDate: '2019-08-20',
isActive: true,
},
];
const mockPayroll: PayrollEntry[] = [
{
id: '1',
employeeId: '1',
period: '2025-01',
grossSalary: 45000,
amBidrag: 3600,
aSkat: 12500,
atp: 99.65,
pension: 4500,
netSalary: 24300.35,
otherDeductions: 0,
status: 'approved',
},
{
id: '2',
employeeId: '2',
period: '2025-01',
grossSalary: 38000,
amBidrag: 3040,
aSkat: 9800,
atp: 99.65,
pension: 3800,
netSalary: 21260.35,
otherDeductions: 0,
status: 'approved',
},
{
id: '3',
employeeId: '3',
period: '2025-01',
grossSalary: 42000,
amBidrag: 3360,
aSkat: 11200,
atp: 99.65,
pension: 4200,
netSalary: 23140.35,
otherDeductions: 0,
status: 'approved',
},
{
id: '4',
employeeId: '4',
period: '2025-01',
grossSalary: 48000,
amBidrag: 3840,
aSkat: 13500,
atp: 99.65,
pension: 4800,
netSalary: 25760.35,
otherDeductions: 0,
status: 'approved',
},
];
// Monthly breakdown for chart
const mockMonthlyData = [
{ month: 'Aug 2024', grossTotal: 165000, netTotal: 91000 },
{ month: 'Sep 2024', grossTotal: 168000, netTotal: 92500 },
{ month: 'Okt 2024', grossTotal: 170000, netTotal: 93800 },
{ month: 'Nov 2024', grossTotal: 172000, netTotal: 94600 },
{ month: 'Dec 2024', grossTotal: 175000, netTotal: 96000 },
{ month: 'Jan 2025', grossTotal: 173000, netTotal: 94460 },
];
export default function Loenforstaelse() {
const { company } = useCompany();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(dayjs().startOf('month'));
const [selectedEmployee, setSelectedEmployee] = useState<string | null>(null);
// Calculate totals
const totalGross = mockPayroll.reduce((sum, p) => sum + p.grossSalary, 0);
const totalAMBidrag = mockPayroll.reduce((sum, p) => sum + p.amBidrag, 0);
const totalASkat = mockPayroll.reduce((sum, p) => sum + p.aSkat, 0);
const totalATP = mockPayroll.reduce((sum, p) => sum + p.atp, 0);
const totalPension = mockPayroll.reduce((sum, p) => sum + p.pension, 0);
const totalNet = mockPayroll.reduce((sum, p) => sum + p.netSalary, 0);
// Chart config
const chartData = mockMonthlyData.flatMap((d) => [
{ month: d.month, type: 'Bruttoløn', value: d.grossTotal },
{ month: d.month, type: 'Nettoløn', value: d.netTotal },
]);
const chartConfig = {
data: chartData,
isGroup: true,
xField: 'month',
yField: 'value',
seriesField: 'type',
color: [accountingColors.balance, accountingColors.credit],
label: {
position: 'top' as const,
formatter: (datum: { value: number }) =>
`${(datum.value / 1000).toFixed(0)}k`,
},
yAxis: {
label: {
formatter: (v: string) => `${parseInt(v) / 1000}k`,
},
},
height: 250,
};
const payrollColumns = [
{
dataIndex: 'employeeId',
title: 'Medarbejder',
render: (id: string) => {
const emp = mockEmployees.find((e) => e.id === id);
return (
<Space>
<UserOutlined />
<div>
<Text strong>{emp?.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{emp?.position} {emp?.department}
</Text>
</div>
</Space>
);
},
},
{
dataIndex: 'grossSalary',
title: 'Bruttoløn',
align: 'right' as const,
render: (v: number) => <span className="tabular-nums">{formatCurrency(v)}</span>,
},
{
dataIndex: 'amBidrag',
title: 'AM-bidrag (8%)',
align: 'right' as const,
render: (v: number) => (
<span className="tabular-nums" style={{ color: accountingColors.debit }}>
-{formatCurrency(v)}
</span>
),
},
{
dataIndex: 'aSkat',
title: 'A-skat',
align: 'right' as const,
render: (v: number) => (
<span className="tabular-nums" style={{ color: accountingColors.debit }}>
-{formatCurrency(v)}
</span>
),
},
{
dataIndex: 'atp',
title: 'ATP',
align: 'right' as const,
render: (v: number) => (
<span className="tabular-nums" style={{ color: accountingColors.debit }}>
-{formatCurrency(v)}
</span>
),
},
{
dataIndex: 'pension',
title: 'Pension',
align: 'right' as const,
render: (v: number) => (
<span className="tabular-nums" style={{ color: accountingColors.debit }}>
-{formatCurrency(v)}
</span>
),
},
{
dataIndex: 'netSalary',
title: 'Nettoløn',
align: 'right' as const,
render: (v: number) => (
<span
className="tabular-nums"
style={{ fontWeight: 'bold', color: accountingColors.credit }}
>
{formatCurrency(v)}
</span>
),
},
{
dataIndex: 'status',
title: 'Status',
width: 100,
render: (status: string) =>
status === 'approved' ? (
<Tag color="green">Godkendt</Tag>
) : status === 'paid' ? (
<Tag color="blue">Udbetalt</Tag>
) : (
<Tag color="orange">Kladde</Tag>
),
},
];
return (
<div>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
Lønforståelse
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Space>
<Button icon={<DownloadOutlined />}>Eksporter lønsedler</Button>
</Space>
</div>
{/* Period Selection */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Text>Periode:</Text>
<DatePicker
picker="month"
value={selectedPeriod}
onChange={(date) => date && setSelectedPeriod(date)}
format="MMMM YYYY"
/>
<Select
placeholder="Vælg medarbejder"
style={{ width: 200 }}
allowClear
value={selectedEmployee}
onChange={setSelectedEmployee}
options={mockEmployees.map((emp) => ({
value: emp.id,
label: emp.name,
}))}
/>
</Space>
</Card>
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="Medarbejdere"
value={mockEmployees.length}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="Bruttoløn i alt"
value={totalGross}
precision={2}
formatter={(value) => formatCurrency(value as number)}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="AM-bidrag"
value={totalAMBidrag}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.debit }}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="A-skat"
value={totalASkat}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.debit }}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="ATP + Pension"
value={totalATP + totalPension}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.debit }}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="Nettoløn i alt"
value={totalNet}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.credit }}
/>
</Card>
</Col>
</Row>
{/* Main Content */}
<Row gutter={16}>
<Col xs={24} lg={16}>
<Card title="Lønspecifikation" size="small">
<Table
dataSource={mockPayroll}
columns={payrollColumns}
rowKey="id"
size="small"
pagination={false}
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row style={{ backgroundColor: '#fafafa' }}>
<Table.Summary.Cell index={0}>
<Text strong>I alt</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong className="tabular-nums">
{formatCurrency(totalGross)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text
strong
className="tabular-nums"
style={{ color: accountingColors.debit }}
>
-{formatCurrency(totalAMBidrag)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="right">
<Text
strong
className="tabular-nums"
style={{ color: accountingColors.debit }}
>
-{formatCurrency(totalASkat)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="right">
<Text
strong
className="tabular-nums"
style={{ color: accountingColors.debit }}
>
-{formatCurrency(totalATP)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="right">
<Text
strong
className="tabular-nums"
style={{ color: accountingColors.debit }}
>
-{formatCurrency(totalPension)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="right">
<Text
strong
className="tabular-nums"
style={{ color: accountingColors.credit }}
>
{formatCurrency(totalNet)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7}></Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
</Card>
{/* Løn Forklaring */}
<Card title="Sådan beregnes lønnen" size="small" style={{ marginTop: 16 }}>
<Collapse
items={[
{
key: '1',
label: 'AM-bidrag (Arbejdsmarkedsbidrag)',
children: (
<div>
<Text>
AM-bidraget er <Text strong>8%</Text> af bruttolønnen og trækkes
før A-skat beregnes.
</Text>
<br />
<Text type="secondary">
Eksempel: Bruttoløn 45.000 kr. × 8% = 3.600 kr.
</Text>
</div>
),
},
{
key: '2',
label: 'A-skat',
children: (
<div>
<Text>
A-skat beregnes af (bruttoløn - AM-bidrag) efter medarbejderens
skattekort. Skatteprocenten afhænger af personfradrag og
kommuneskat.
</Text>
</div>
),
},
{
key: '3',
label: 'ATP (Arbejdsmarkedets Tillægspension)',
children: (
<div>
<Text>
ATP-bidraget er pt. <Text strong>99,65 kr.</Text> pr. måned for
fuldtidsansatte. Arbejdsgiver betaler 2/3, lønmodtager betaler 1/3.
</Text>
</div>
),
},
{
key: '4',
label: 'Pension',
children: (
<div>
<Text>
Pensionsbidraget afhænger af overenskomst eller individuel aftale.
Typisk mellem 8-17% af bruttolønnen, hvoraf arbejdsgiver ofte
betaler 2/3.
</Text>
</div>
),
},
]}
/>
</Card>
</Col>
<Col xs={24} lg={8}>
{/* Trend Chart */}
<Card title="Lønudvikling (6 måneder)" size="small" style={{ marginBottom: 16 }}>
<Column {...chartConfig} />
</Card>
{/* Cost Breakdown */}
<Card title="Lønomkostninger for arbejdsgiver" size="small">
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Ud over bruttolønnen betaler arbejdsgiver:</Text>
</div>
<Descriptions column={1} size="small">
<Descriptions.Item label="Feriepenge (12,5%)">
{formatCurrency(totalGross * 0.125)}
</Descriptions.Item>
<Descriptions.Item label="ATP (arbejdsgiver 2/3)">
{formatCurrency(totalATP * 2)}
</Descriptions.Item>
<Descriptions.Item label="Pension (arbejdsgiver)">
{formatCurrency(totalPension * 2)}
</Descriptions.Item>
<Descriptions.Item label="AES/AUB bidrag (ca.)">
{formatCurrency(mockEmployees.length * 450)}
</Descriptions.Item>
</Descriptions>
<div
style={{
marginTop: 16,
padding: '8px 16px',
backgroundColor: '#f5f5f5',
borderRadius: 4,
}}
>
<Text strong>Total lønomkostning (estimat): </Text>
<Text strong style={{ color: accountingColors.debit }}>
{formatCurrency(
totalGross * 1.125 + totalATP * 2 + totalPension * 2 + mockEmployees.length * 450
)}
</Text>
</div>
</Card>
</Col>
</Row>
</div>
);
}

View file

@ -0,0 +1,574 @@
import { useState } from 'react';
import {
Typography,
Card,
Row,
Col,
Select,
DatePicker,
Button,
Table,
Statistic,
Tag,
Space,
Divider,
Alert,
Modal,
Descriptions,
message,
} 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 { accountingColors } from '@/styles/theme';
const { Title, Text } = Typography;
// Danish VAT boxes (Rubrikker)
interface VATBox {
boxNumber: number;
nameDanish: string;
nameEnglish: string;
description: string;
amount: number;
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 [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);
const inputVAT = mockVATReport
.filter((box) => box.boxNumber === 4)
.reduce((sum, box) => sum + box.amount, 0);
const energyDuties = mockVATReport
.filter((box) => [5, 6, 7, 8, 9].includes(box.boxNumber))
.reduce((sum, box) => sum + box.amount, 0);
const netVAT = outputVAT - inputVAT - energyDuties;
// 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 pieConfig = {
data: pieData,
angleField: 'value',
colorField: 'type',
radius: 0.8,
innerRadius: 0.6,
label: {
type: 'outer',
formatter: (datum: { type: string; value: number }) =>
`${datum.type}: ${formatCurrency(datum.value)}`,
},
legend: {
position: 'bottom' as const,
},
height: 250,
};
const columns = [
{
dataIndex: 'boxNumber',
title: 'Rubrik',
width: 80,
render: (value: number) => <Text strong>{value}</Text>,
},
{
dataIndex: 'nameDanish',
title: 'Felt',
render: (value: string, record: VATBox) => (
<div>
<Text strong>{value}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{record.description}
</Text>
</div>
),
},
{
dataIndex: 'basis',
title: 'Grundlag',
align: 'right' as const,
width: 140,
render: (value: number | undefined) =>
value !== undefined ? (
<span className="tabular-nums">{formatCurrency(value)}</span>
) : (
'-'
),
},
{
dataIndex: 'amount',
title: 'Moms/Afgift',
align: 'right' as const,
width: 140,
render: (value: number, record: VATBox) => {
const isDeductible = record.boxNumber >= 4;
return (
<span
className="tabular-nums"
style={{
color: isDeductible ? accountingColors.credit : accountingColors.debit,
fontWeight: 'bold',
}}
>
{isDeductible ? '-' : ''}
{formatCurrency(value)}
</span>
);
},
},
];
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>;
}
};
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>
{/* Period Selection */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Text>Periode:</Text>
<Select
value={periodType}
onChange={setPeriodType}
style={{ width: 120 }}
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
]}
/>
<DatePicker
picker={periodType === 'quarterly' ? 'quarter' : 'month'}
value={selectedPeriod}
onChange={(date) => date && setSelectedPeriod(date)}
format={periodType === 'quarterly' ? '[Q]Q YYYY' : 'MMMM YYYY'}
/>
<Tag color="blue">
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
</Tag>
</Space>
</Card>
{/* Summary Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Udgående moms"
value={outputVAT}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.debit }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Indgående moms (fradrag)"
value={inputVAT}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{ color: accountingColors.credit }}
/>
</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}>
<Card size="small">
<Statistic
title={netVAT >= 0 ? 'Moms til betaling' : 'Moms til gode'}
value={Math.abs(netVAT)}
precision={2}
formatter={(value) => formatCurrency(value as number)}
valueStyle={{
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
/>
</Card>
</Col>
</Row>
{/* Main Content */}
<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>
)}
/>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="Fordeling" size="small" style={{ marginBottom: 16 }}>
<Pie {...pieConfig} />
</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>
))}
</Card>
</Col>
</Row>
{/* Preview Modal */}
<Modal
title="Forhåndsvisning af momsangivelse"
open={isPreviewOpen}
onCancel={() => setIsPreviewOpen(false)}
width={700}
footer={[
<Button key="cancel" onClick={() => setIsPreviewOpen(false)}>
Luk
</Button>,
<Button key="export" icon={<DownloadOutlined />}>
Download PDF
</Button>,
<Button
key="submit"
type="primary"
icon={<SendOutlined />}
onClick={() => {
setIsPreviewOpen(false);
handleSubmit();
}}
>
Indsend til SKAT
</Button>,
]}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Virksomhed">{company?.name}</Descriptions.Item>
<Descriptions.Item label="CVR">{company?.cvr}</Descriptions.Item>
<Descriptions.Item label="Periode">
{formatPeriod(selectedPeriod.toDate())}
</Descriptions.Item>
<Descriptions.Item label="Periodens afslutning">
{selectedPeriod.endOf('month').format('D. MMMM YYYY')}
</Descriptions.Item>
</Descriptions>
<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"
/>
<Divider />
<div style={{ textAlign: 'right' }}>
<Text strong style={{ fontSize: 18 }}>
{netVAT >= 0 ? 'Moms til betaling: ' : 'Moms til gode: '}
<span
style={{
color: netVAT >= 0 ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(Math.abs(netVAT))}
</span>
</Text>
</div>
</Modal>
</div>
);
}

View file

@ -0,0 +1,422 @@
import {
Typography,
Card,
Row,
Col,
Form,
Input,
Select,
Button,
Tabs,
Switch,
Divider,
message,
Space,
Tag,
} from 'antd';
import {
SaveOutlined,
BuildOutlined,
UserOutlined,
BankOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useCompany } from '@/hooks/useCompany';
const { Title, Text } = Typography;
export default function Settings() {
const { company } = useCompany();
const [companyForm] = Form.useForm();
const [preferencesForm] = Form.useForm();
const handleSaveCompany = async () => {
try {
const values = await companyForm.validateFields();
console.log('Saving company:', values);
message.success('Virksomhedsoplysninger gemt');
} catch (error) {
console.error('Validation failed:', error);
}
};
const handleSavePreferences = async () => {
try {
const values = await preferencesForm.validateFields();
console.log('Saving preferences:', values);
message.success('Præferencer gemt');
} catch (error) {
console.error('Validation failed:', error);
}
};
const tabItems = [
{
key: 'company',
label: (
<span>
<BuildOutlined /> Virksomhed
</span>
),
children: (
<Card>
<Form
form={companyForm}
layout="vertical"
initialValues={{
name: company?.name,
cvr: company?.cvr,
address: company?.address,
city: company?.city,
postalCode: company?.postalCode,
fiscalYearStart: company?.fiscalYearStart || 1,
currency: company?.currency || 'DKK',
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Virksomhedsnavn"
rules={[{ required: true, message: 'Indtast virksomhedsnavn' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="cvr"
label="CVR-nummer"
rules={[
{ required: true, message: 'Indtast CVR-nummer' },
{ pattern: /^\d{8}$/, message: 'CVR skal være 8 cifre' },
]}
>
<Input maxLength={8} />
</Form.Item>
</Col>
</Row>
<Divider>Adresse</Divider>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="address" label="Adresse">
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="postalCode" label="Postnummer">
<Input maxLength={4} />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item name="city" label="By">
<Input />
</Form.Item>
</Col>
</Row>
<Divider>Regnskab</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="fiscalYearStart"
label="Regnskabsår starter"
tooltip="Hvilken måned starter jeres regnskabsår?"
>
<Select
options={[
{ value: 1, label: 'Januar' },
{ value: 2, label: 'Februar' },
{ value: 3, label: 'Marts' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Maj' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="currency" label="Valuta">
<Select
options={[
{ value: 'DKK', label: 'DKK - Danske kroner' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'USD', label: 'USD - US Dollar' },
]}
/>
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'right' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveCompany}>
Gem ændringer
</Button>
</div>
</Form>
</Card>
),
},
{
key: 'preferences',
label: (
<span>
<SettingOutlined /> Præferencer
</span>
),
children: (
<Card>
<Form
form={preferencesForm}
layout="vertical"
initialValues={{
vatPeriod: 'quarterly',
autoReconcile: true,
emailNotifications: true,
defaultPageSize: 20,
}}
>
<Title level={5}>Moms</Title>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="vatPeriod"
label="Momsperiode"
tooltip="Hvor ofte indberetter I moms?"
>
<Select
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
{ value: 'half-yearly', label: 'Halvårlig' },
{ value: 'yearly', label: 'Årlig' },
]}
/>
</Form.Item>
</Col>
</Row>
<Divider />
<Title level={5}>Automatisering</Title>
<Form.Item
name="autoReconcile"
label="Automatisk afstemningsforslag"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Text type="secondary" style={{ display: 'block', marginTop: -16, marginBottom: 16 }}>
Systemet vil automatisk foreslå matches mellem bank og bogføring
</Text>
<Divider />
<Title level={5}>Notifikationer</Title>
<Form.Item
name="emailNotifications"
label="Email-notifikationer"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Text type="secondary" style={{ display: 'block', marginTop: -16, marginBottom: 16 }}>
Modtag påmindelser om frister og vigtige handlinger
</Text>
<Divider />
<Title level={5}>Visning</Title>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="defaultPageSize" label="Standard antal rækker i tabeller">
<Select
options={[
{ value: 10, label: '10 rækker' },
{ value: 20, label: '20 rækker' },
{ value: 50, label: '50 rækker' },
{ value: 100, label: '100 rækker' },
]}
/>
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'right' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSavePreferences}>
Gem præferencer
</Button>
</div>
</Form>
</Card>
),
},
{
key: 'bankAccounts',
label: (
<span>
<BankOutlined /> Bankkonti
</span>
),
children: (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Title level={5} style={{ margin: 0 }}>
Tilknyttede bankkonti
</Title>
<Button type="primary">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>
))}
</Space>
</Card>
),
},
{
key: 'users',
label: (
<span>
<UserOutlined /> Brugere
</span>
),
children: (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Title level={5} style={{ margin: 0 }}>
Brugere med adgang
</Title>
<Button type="primary">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>
))}
</Space>
</Card>
),
},
];
return (
<div>
{/* Header */}
<div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
Indstillinger
</Title>
<Text type="secondary">{company?.name}</Text>
</div>
<Tabs items={tabItems} />
</div>
);
}

59
frontend/src/routes.tsx Normal file
View file

@ -0,0 +1,59 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import { Spin } from 'antd';
// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Kassekladde = lazy(() => import('./pages/Kassekladde'));
const HurtigBogforing = lazy(() => import('./pages/HurtigBogforing'));
const Kontooversigt = lazy(() => import('./pages/Kontooversigt'));
const Bankafstemning = lazy(() => import('./pages/Bankafstemning'));
const Momsindberetning = lazy(() => import('./pages/Momsindberetning'));
const Loenforstaelse = lazy(() => import('./pages/Loenforstaelse'));
const Settings = lazy(() => import('./pages/Settings'));
// Loading fallback component
function PageLoader() {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
minHeight: 400,
}}
>
<Spin size="large" tip="Indlaeser..." />
</div>
);
}
export default function AppRoutes() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Dashboard */}
<Route path="/" element={<Dashboard />} />
{/* Accounting */}
<Route path="/kassekladde" element={<Kassekladde />} />
<Route path="/hurtig-bogforing" element={<HurtigBogforing />} />
<Route path="/kontooversigt" element={<Kontooversigt />} />
{/* Bank */}
<Route path="/bankafstemning" element={<Bankafstemning />} />
{/* Reporting */}
<Route path="/momsindberetning" element={<Momsindberetning />} />
<Route path="/loenforstaelse" element={<Loenforstaelse />} />
{/* Settings */}
<Route path="/indstillinger" element={<Settings />} />
{/* Fallback redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}

View file

@ -0,0 +1,53 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Company } from '@/types/accounting';
interface CompanyState {
// Current active company
activeCompany: Company | null;
// List of available companies
companies: Company[];
// Loading state
isLoading: boolean;
// Actions
setActiveCompany: (company: Company) => void;
setCompanies: (companies: Company[]) => void;
setLoading: (loading: boolean) => void;
clearActiveCompany: () => void;
}
export const useCompanyStore = create<CompanyState>()(
persist(
(set) => ({
activeCompany: null,
companies: [],
isLoading: false,
setActiveCompany: (company) =>
set({ activeCompany: company }),
setCompanies: (companies) =>
set({ companies }),
setLoading: (isLoading) =>
set({ isLoading }),
clearActiveCompany: () =>
set({ activeCompany: null }),
}),
{
name: 'books-company-storage',
partialize: (state) => ({
activeCompany: state.activeCompany,
}),
}
)
);
// Selector hooks for convenience
export const useActiveCompany = () =>
useCompanyStore((state) => state.activeCompany);
export const useCompanies = () =>
useCompanyStore((state) => state.companies);

View file

@ -0,0 +1,407 @@
// Period Store - Zustand store for fiscal period management
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
FiscalYear,
AccountingPeriod,
VATPeriod,
PeriodContext,
PeriodSettings,
} from '@/types/periods';
interface PeriodState {
// Fiscal years
fiscalYears: FiscalYear[];
currentFiscalYear: FiscalYear | null;
selectedFiscalYear: FiscalYear | null; // User-selected fiscal year (persisted)
// Accounting periods
periods: AccountingPeriod[];
currentPeriod: AccountingPeriod | null;
selectedPeriod: AccountingPeriod | null;
// VAT periods
vatPeriods: VATPeriod[];
selectedVATPeriod: VATPeriod | null;
// Comparison
comparisonPeriod: AccountingPeriod | null;
comparisonType: 'previous-period' | 'previous-year' | 'custom' | null;
// Settings
periodSettings: PeriodSettings | null;
// Loading states
isLoading: boolean;
// Actions - Fiscal Years
setFiscalYears: (years: FiscalYear[]) => void;
setCurrentFiscalYear: (year: FiscalYear | null) => void;
setSelectedFiscalYear: (year: FiscalYear | null) => void;
selectFiscalYear: (yearId: string) => void;
addFiscalYear: (year: FiscalYear) => void;
updateFiscalYear: (id: string, updates: Partial<FiscalYear>) => void;
closeFiscalYear: (id: string, closedBy: string) => void;
reopenFiscalYear: (id: string, reopenedBy: string) => void;
lockFiscalYear: (id: string, lockedBy: string) => void;
// Actions - Accounting Periods
setPeriods: (periods: AccountingPeriod[]) => void;
setCurrentPeriod: (period: AccountingPeriod | null) => void;
setSelectedPeriod: (period: AccountingPeriod | null) => void;
updatePeriod: (id: string, updates: Partial<AccountingPeriod>) => void;
closePeriod: (id: string, closedBy: string) => void;
reopenPeriod: (id: string, reopenedBy: string) => void;
lockPeriod: (id: string, lockedBy: string) => void;
// Actions - VAT Periods
setVATPeriods: (periods: VATPeriod[]) => void;
setSelectedVATPeriod: (period: VATPeriod | null) => void;
updateVATPeriod: (id: string, updates: Partial<VATPeriod>) => void;
// Actions - Comparison
setComparisonPeriod: (period: AccountingPeriod | null, type: PeriodState['comparisonType']) => void;
clearComparison: () => void;
// Actions - Settings
setPeriodSettings: (settings: PeriodSettings) => void;
// Actions - Loading
setLoading: (loading: boolean) => void;
// Computed/derived
getContext: () => PeriodContext;
getPeriodById: (id: string) => AccountingPeriod | undefined;
getVATPeriodById: (id: string) => VATPeriod | undefined;
getFiscalYearById: (id: string) => FiscalYear | undefined;
getFiscalYearForDate: (date: string) => FiscalYear | undefined;
getPeriodsForYear: (fiscalYearId: string) => AccountingPeriod[];
getOpenPeriods: () => AccountingPeriod[];
getOpenFiscalYears: () => FiscalYear[];
getEffectiveFiscalYear: () => FiscalYear | null;
canPostToDate: (date: string) => { allowed: boolean; reason?: string; reasonDanish?: string };
// Reset
resetPeriodState: () => void;
}
const initialState = {
fiscalYears: [],
currentFiscalYear: null,
selectedFiscalYear: null,
periods: [],
currentPeriod: null,
selectedPeriod: null,
vatPeriods: [],
selectedVATPeriod: null,
comparisonPeriod: null,
comparisonType: null as PeriodState['comparisonType'],
periodSettings: null,
isLoading: false,
};
export const usePeriodStore = create<PeriodState>()(
persist(
(set, get) => ({
...initialState,
// Fiscal Years Actions
setFiscalYears: (years) => set({ fiscalYears: years }),
setCurrentFiscalYear: (year) => set({ currentFiscalYear: year }),
addFiscalYear: (year) =>
set((state) => ({
fiscalYears: [...state.fiscalYears, year],
})),
setSelectedFiscalYear: (year) => set({ selectedFiscalYear: year }),
selectFiscalYear: (yearId) => {
const year = get().fiscalYears.find((y) => y.id === yearId);
if (year) {
set({ selectedFiscalYear: year, currentFiscalYear: year });
}
},
updateFiscalYear: (id, updates) =>
set((state) => ({
fiscalYears: state.fiscalYears.map((y) =>
y.id === id ? { ...y, ...updates } : y
),
currentFiscalYear:
state.currentFiscalYear?.id === id
? { ...state.currentFiscalYear, ...updates }
: state.currentFiscalYear,
selectedFiscalYear:
state.selectedFiscalYear?.id === id
? { ...state.selectedFiscalYear, ...updates }
: state.selectedFiscalYear,
})),
closeFiscalYear: (id, closedBy) => {
const now = new Date().toISOString();
get().updateFiscalYear(id, {
status: 'closed',
closingDate: now,
closedBy,
});
},
reopenFiscalYear: (id, _reopenedBy) => {
get().updateFiscalYear(id, {
status: 'open',
closingDate: undefined,
closedBy: undefined,
});
},
lockFiscalYear: (id, lockedBy) => {
const now = new Date().toISOString();
get().updateFiscalYear(id, {
status: 'locked',
closingDate: now,
closedBy: lockedBy,
});
},
// Accounting Periods Actions
setPeriods: (periods) => set({ periods }),
setCurrentPeriod: (period) => set({ currentPeriod: period }),
setSelectedPeriod: (period) => set({ selectedPeriod: period }),
updatePeriod: (id, updates) =>
set((state) => ({
periods: state.periods.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
currentPeriod:
state.currentPeriod?.id === id
? { ...state.currentPeriod, ...updates }
: state.currentPeriod,
selectedPeriod:
state.selectedPeriod?.id === id
? { ...state.selectedPeriod, ...updates }
: state.selectedPeriod,
})),
closePeriod: (id, closedBy) => {
const now = new Date().toISOString();
get().updatePeriod(id, {
status: 'closed',
closedAt: now,
closedBy,
});
},
reopenPeriod: (id, reopenedBy) => {
const now = new Date().toISOString();
get().updatePeriod(id, {
status: 'open',
reopenedAt: now,
reopenedBy,
closedAt: undefined,
closedBy: undefined,
});
},
lockPeriod: (id, lockedBy) => {
const now = new Date().toISOString();
get().updatePeriod(id, {
status: 'locked',
lockedAt: now,
lockedBy,
});
},
// VAT Periods Actions
setVATPeriods: (periods) => set({ vatPeriods: periods }),
setSelectedVATPeriod: (period) => set({ selectedVATPeriod: period }),
updateVATPeriod: (id, updates) =>
set((state) => ({
vatPeriods: state.vatPeriods.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
selectedVATPeriod:
state.selectedVATPeriod?.id === id
? { ...state.selectedVATPeriod, ...updates }
: state.selectedVATPeriod,
})),
// Comparison Actions
setComparisonPeriod: (period, type) =>
set({
comparisonPeriod: period,
comparisonType: type,
}),
clearComparison: () =>
set({
comparisonPeriod: null,
comparisonType: null,
}),
// Settings Actions
setPeriodSettings: (settings) => set({ periodSettings: settings }),
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
// Computed/Derived
getContext: () => {
const state = get();
return {
currentFiscalYear: state.currentFiscalYear,
currentPeriod: state.currentPeriod,
selectedPeriod: state.selectedPeriod,
selectedVATPeriod: state.selectedVATPeriod,
comparisonPeriod: state.comparisonPeriod,
comparisonType: state.comparisonType,
};
},
getPeriodById: (id) => get().periods.find((p) => p.id === id),
getVATPeriodById: (id) => get().vatPeriods.find((p) => p.id === id),
getFiscalYearById: (id) => get().fiscalYears.find((y) => y.id === id),
getFiscalYearForDate: (date) => {
const targetDate = new Date(date);
return get().fiscalYears.find((y) => {
const start = new Date(y.startDate);
const end = new Date(y.endDate);
return targetDate >= start && targetDate <= end;
});
},
getPeriodsForYear: (fiscalYearId) =>
get().periods.filter((p) => p.fiscalYearId === fiscalYearId),
getOpenPeriods: () =>
get().periods.filter((p) => p.status === 'open'),
getOpenFiscalYears: () =>
get().fiscalYears.filter((y) => y.status === 'open'),
getEffectiveFiscalYear: () => {
const state = get();
return state.selectedFiscalYear || state.currentFiscalYear;
},
canPostToDate: (date) => {
const state = get();
const dateParts = date.split('-');
const targetDate = new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2])
);
// Find the period for this date
const period = state.periods.find((p) => {
const start = new Date(p.startDate);
const end = new Date(p.endDate);
return targetDate >= start && targetDate <= end;
});
if (!period) {
return {
allowed: false,
reason: 'No period found for this date',
reasonDanish: 'Ingen periode fundet for denne dato',
};
}
if (period.status === 'locked') {
return {
allowed: false,
reason: 'Period is locked',
reasonDanish: 'Perioden er laast',
};
}
if (
period.status === 'closed' &&
state.periodSettings?.preventPostingToClosedPeriods
) {
return {
allowed: false,
reason: 'Period is closed',
reasonDanish: 'Perioden er lukket',
};
}
if (
period.status === 'future' &&
state.periodSettings?.preventPostingToFuturePeriods
) {
return {
allowed: false,
reason: 'Cannot post to future periods',
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder',
};
}
return { allowed: true };
},
// Reset
resetPeriodState: () => set(initialState),
}),
{
name: 'books-period-storage',
partialize: (state) => ({
selectedFiscalYear: state.selectedFiscalYear,
selectedPeriod: state.selectedPeriod,
selectedVATPeriod: state.selectedVATPeriod,
comparisonType: state.comparisonType,
}),
}
)
);
// =====================================================
// SELECTOR HOOKS
// =====================================================
export const useCurrentFiscalYear = () =>
usePeriodStore((state) => state.currentFiscalYear);
export const useSelectedFiscalYear = () =>
usePeriodStore((state) => state.selectedFiscalYear);
export const useEffectiveFiscalYear = () =>
usePeriodStore((state) => state.getEffectiveFiscalYear());
export const useFiscalYears = () =>
usePeriodStore((state) => state.fiscalYears);
export const useOpenFiscalYears = () =>
usePeriodStore((state) => state.getOpenFiscalYears());
export const useCurrentPeriod = () =>
usePeriodStore((state) => state.currentPeriod);
export const useSelectedPeriod = () =>
usePeriodStore((state) => state.selectedPeriod);
export const useSelectedVATPeriod = () =>
usePeriodStore((state) => state.selectedVATPeriod);
export const usePeriodContext = () =>
usePeriodStore((state) => state.getContext());
export const useOpenPeriods = () =>
usePeriodStore((state) => state.getOpenPeriods());
export const usePeriodSettings = () =>
usePeriodStore((state) => state.periodSettings);
export const useCanPostToDate = (date: string) =>
usePeriodStore((state) => state.canPostToDate(date));

View file

@ -0,0 +1,172 @@
import { create } from 'zustand';
import type { BankTransaction, Transaction } from '@/types/accounting';
import type { MatchSuggestion } from '@/types/ui';
interface ReconciliationState {
// Selection state
selectedBankTransactions: string[];
selectedLedgerTransactions: string[];
// Match suggestions from backend
matchSuggestions: MatchSuggestion[];
// Current bank account being reconciled
activeBankAccountId: string | null;
// Period being reconciled
reconciliationPeriod: { start: string; end: string } | null;
// View state
viewMode: 'list' | 'side-by-side';
// Pending matches (before save)
pendingMatches: PendingMatch[];
// Actions
selectBankTransaction: (id: string) => void;
deselectBankTransaction: (id: string) => void;
toggleBankTransaction: (id: string) => void;
clearBankSelection: () => void;
selectLedgerTransaction: (id: string) => void;
deselectLedgerTransaction: (id: string) => void;
toggleLedgerTransaction: (id: string) => void;
clearLedgerSelection: () => void;
clearAllSelections: () => void;
setMatchSuggestions: (suggestions: MatchSuggestion[]) => void;
setActiveBankAccount: (id: string | null) => void;
setReconciliationPeriod: (period: { start: string; end: string } | null) => void;
setViewMode: (mode: 'list' | 'side-by-side') => void;
addPendingMatch: (match: PendingMatch) => void;
removePendingMatch: (bankTransactionId: string) => void;
clearPendingMatches: () => void;
// Reset entire state
resetReconciliation: () => void;
}
interface PendingMatch {
bankTransactionId: string;
bankTransaction?: BankTransaction;
ledgerTransactionId?: string;
ledgerTransaction?: Transaction;
matchType: 'existing' | 'new';
newTransaction?: {
description: string;
accountId: string;
};
}
const initialState = {
selectedBankTransactions: [],
selectedLedgerTransactions: [],
matchSuggestions: [],
activeBankAccountId: null,
reconciliationPeriod: null,
viewMode: 'side-by-side' as const,
pendingMatches: [],
};
export const useReconciliationStore = create<ReconciliationState>()((set) => ({
...initialState,
// Bank transaction selection
selectBankTransaction: (id) =>
set((state) => ({
selectedBankTransactions: [...state.selectedBankTransactions, id],
})),
deselectBankTransaction: (id) =>
set((state) => ({
selectedBankTransactions: state.selectedBankTransactions.filter((i) => i !== id),
})),
toggleBankTransaction: (id) =>
set((state) => ({
selectedBankTransactions: state.selectedBankTransactions.includes(id)
? state.selectedBankTransactions.filter((i) => i !== id)
: [...state.selectedBankTransactions, id],
})),
clearBankSelection: () =>
set({ selectedBankTransactions: [] }),
// Ledger transaction selection
selectLedgerTransaction: (id) =>
set((state) => ({
selectedLedgerTransactions: [...state.selectedLedgerTransactions, id],
})),
deselectLedgerTransaction: (id) =>
set((state) => ({
selectedLedgerTransactions: state.selectedLedgerTransactions.filter((i) => i !== id),
})),
toggleLedgerTransaction: (id) =>
set((state) => ({
selectedLedgerTransactions: state.selectedLedgerTransactions.includes(id)
? state.selectedLedgerTransactions.filter((i) => i !== id)
: [...state.selectedLedgerTransactions, id],
})),
clearLedgerSelection: () =>
set({ selectedLedgerTransactions: [] }),
clearAllSelections: () =>
set({ selectedBankTransactions: [], selectedLedgerTransactions: [] }),
// Other state management
setMatchSuggestions: (matchSuggestions) =>
set({ matchSuggestions }),
setActiveBankAccount: (activeBankAccountId) =>
set({ activeBankAccountId }),
setReconciliationPeriod: (reconciliationPeriod) =>
set({ reconciliationPeriod }),
setViewMode: (viewMode) =>
set({ viewMode }),
addPendingMatch: (match) =>
set((state) => ({
pendingMatches: [...state.pendingMatches, match],
selectedBankTransactions: state.selectedBankTransactions.filter(
(id) => id !== match.bankTransactionId
),
selectedLedgerTransactions: match.ledgerTransactionId
? state.selectedLedgerTransactions.filter(
(id) => id !== match.ledgerTransactionId
)
: state.selectedLedgerTransactions,
})),
removePendingMatch: (bankTransactionId) =>
set((state) => ({
pendingMatches: state.pendingMatches.filter(
(m) => m.bankTransactionId !== bankTransactionId
),
})),
clearPendingMatches: () =>
set({ pendingMatches: [] }),
resetReconciliation: () =>
set(initialState),
}));
// Selector hooks
export const useSelectedBankTransactions = () =>
useReconciliationStore((state) => state.selectedBankTransactions);
export const useSelectedLedgerTransactions = () =>
useReconciliationStore((state) => state.selectedLedgerTransactions);
export const useMatchSuggestions = () =>
useReconciliationStore((state) => state.matchSuggestions);
export const usePendingMatches = () =>
useReconciliationStore((state) => state.pendingMatches);

View file

@ -0,0 +1,364 @@
// Simple Booking Store - Zustand store for quick/simple booking workflow
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { VATCode } from '@/types/vat';
import type {
BankTransactionInput,
GeneratedTransaction,
SplitBookingLine,
} from '@/lib/accounting';
// =====================================================
// TYPES
// =====================================================
/**
* Bank transaction with booking status
*/
export interface PendingBankTransaction extends BankTransactionInput {
isBooked: boolean;
bookedAt?: string;
transactionId?: string; // Reference to created accounting transaction
}
/**
* Favorite/quick account for fast selection
*/
export interface FavoriteAccount {
id: string;
accountId: string;
accountNumber: string;
accountName: string;
defaultVATCode: VATCode;
usageCount: number;
lastUsed?: string;
}
/**
* Booking modal state
*/
export interface BookingModalState {
isOpen: boolean;
type: 'simple' | 'split' | null;
bankTransaction: PendingBankTransaction | null;
}
/**
* Split booking working state
*/
export interface SplitBookingState {
lines: SplitBookingLine[];
remainingAmount: number;
}
// =====================================================
// STORE STATE
// =====================================================
interface SimpleBookingState {
// Bank transactions
pendingTransactions: PendingBankTransaction[];
selectedBankAccountId: string | null;
// Favorites
favoriteAccounts: FavoriteAccount[];
// Modal state
modal: BookingModalState;
// Split booking state
splitState: SplitBookingState;
// Preview
preview: GeneratedTransaction | null;
// Loading states
isLoading: boolean;
isSaving: boolean;
// Actions - Bank Transactions
setPendingTransactions: (transactions: PendingBankTransaction[]) => void;
markAsBooked: (transactionId: string, accountingTransactionId: string) => void;
setSelectedBankAccount: (accountId: string | null) => void;
// Actions - Favorites
addFavoriteAccount: (account: Omit<FavoriteAccount, 'id' | 'usageCount'>) => void;
removeFavoriteAccount: (accountId: string) => void;
incrementFavoriteUsage: (accountId: string) => void;
updateFavoriteVATCode: (accountId: string, vatCode: VATCode) => void;
// Actions - Modal
openSimpleBooking: (transaction: PendingBankTransaction) => void;
openSplitBooking: (transaction: PendingBankTransaction) => void;
closeModal: () => void;
// Actions - Split Booking
addSplitLine: (line: SplitBookingLine) => void;
updateSplitLine: (index: number, line: Partial<SplitBookingLine>) => void;
removeSplitLine: (index: number) => void;
clearSplitLines: () => void;
// Actions - Preview
setPreview: (preview: GeneratedTransaction | null) => void;
// Actions - Loading
setLoading: (loading: boolean) => void;
setSaving: (saving: boolean) => void;
// Computed
getUnbookedTransactions: () => PendingBankTransaction[];
getTopFavorites: (limit?: number) => FavoriteAccount[];
getSplitRemainingAmount: () => number;
// Reset
resetStore: () => void;
}
// =====================================================
// INITIAL STATE
// =====================================================
const initialModalState: BookingModalState = {
isOpen: false,
type: null,
bankTransaction: null,
};
const initialSplitState: SplitBookingState = {
lines: [],
remainingAmount: 0,
};
const initialState = {
pendingTransactions: [],
selectedBankAccountId: null,
favoriteAccounts: [],
modal: initialModalState,
splitState: initialSplitState,
preview: null,
isLoading: false,
isSaving: false,
};
// =====================================================
// STORE IMPLEMENTATION
// =====================================================
export const useSimpleBookingStore = create<SimpleBookingState>()(
persist(
(set, get) => ({
...initialState,
// Bank Transactions
setPendingTransactions: (transactions) =>
set({ pendingTransactions: transactions }),
markAsBooked: (transactionId, accountingTransactionId) =>
set((state) => ({
pendingTransactions: state.pendingTransactions.map((tx) =>
tx.id === transactionId
? {
...tx,
isBooked: true,
bookedAt: new Date().toISOString(),
transactionId: accountingTransactionId,
}
: tx
),
})),
setSelectedBankAccount: (accountId) =>
set({ selectedBankAccountId: accountId }),
// Favorites
addFavoriteAccount: (account) =>
set((state) => ({
favoriteAccounts: [
...state.favoriteAccounts,
{
...account,
id: `fav-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
usageCount: 0,
},
],
})),
removeFavoriteAccount: (accountId) =>
set((state) => ({
favoriteAccounts: state.favoriteAccounts.filter((f) => f.accountId !== accountId),
})),
incrementFavoriteUsage: (accountId) =>
set((state) => ({
favoriteAccounts: state.favoriteAccounts.map((f) =>
f.accountId === accountId
? {
...f,
usageCount: f.usageCount + 1,
lastUsed: new Date().toISOString(),
}
: f
),
})),
updateFavoriteVATCode: (accountId, vatCode) =>
set((state) => ({
favoriteAccounts: state.favoriteAccounts.map((f) =>
f.accountId === accountId ? { ...f, defaultVATCode: vatCode } : f
),
})),
// Modal
openSimpleBooking: (transaction) =>
set({
modal: {
isOpen: true,
type: 'simple',
bankTransaction: transaction,
},
preview: null,
}),
openSplitBooking: (transaction) =>
set({
modal: {
isOpen: true,
type: 'split',
bankTransaction: transaction,
},
splitState: {
lines: [],
remainingAmount: Math.abs(transaction.amount),
},
preview: null,
}),
closeModal: () =>
set({
modal: initialModalState,
splitState: initialSplitState,
preview: null,
}),
// Split Booking
addSplitLine: (line) =>
set((state) => {
const newLines = [...state.splitState.lines, line];
const totalAllocated = newLines.reduce((sum, l) => sum + Math.abs(l.amount), 0);
const totalAmount = state.modal.bankTransaction
? Math.abs(state.modal.bankTransaction.amount)
: 0;
return {
splitState: {
lines: newLines,
remainingAmount: Math.max(0, totalAmount - totalAllocated),
},
};
}),
updateSplitLine: (index, updates) =>
set((state) => {
const newLines = [...state.splitState.lines];
if (index >= 0 && index < newLines.length) {
newLines[index] = { ...newLines[index], ...updates };
}
const totalAllocated = newLines.reduce((sum, l) => sum + Math.abs(l.amount), 0);
const totalAmount = state.modal.bankTransaction
? Math.abs(state.modal.bankTransaction.amount)
: 0;
return {
splitState: {
lines: newLines,
remainingAmount: Math.max(0, totalAmount - totalAllocated),
},
};
}),
removeSplitLine: (index) =>
set((state) => {
const newLines = state.splitState.lines.filter((_, i) => i !== index);
const totalAllocated = newLines.reduce((sum, l) => sum + Math.abs(l.amount), 0);
const totalAmount = state.modal.bankTransaction
? Math.abs(state.modal.bankTransaction.amount)
: 0;
return {
splitState: {
lines: newLines,
remainingAmount: Math.max(0, totalAmount - totalAllocated),
},
};
}),
clearSplitLines: () =>
set((state) => ({
splitState: {
lines: [],
remainingAmount: state.modal.bankTransaction
? Math.abs(state.modal.bankTransaction.amount)
: 0,
},
})),
// Preview
setPreview: (preview) => set({ preview }),
// Loading
setLoading: (isLoading) => set({ isLoading }),
setSaving: (isSaving) => set({ isSaving }),
// Computed
getUnbookedTransactions: () =>
get().pendingTransactions.filter((tx) => !tx.isBooked),
getTopFavorites: (limit = 5) =>
[...get().favoriteAccounts]
.sort((a, b) => b.usageCount - a.usageCount)
.slice(0, limit),
getSplitRemainingAmount: () => get().splitState.remainingAmount,
// Reset
resetStore: () => set(initialState),
}),
{
name: 'simple-booking-favorites',
// Only persist favoriteAccounts - transient state like modal/preview should not be persisted
partialize: (state) => ({
favoriteAccounts: state.favoriteAccounts,
}),
}
)
);
// =====================================================
// SELECTOR HOOKS
// =====================================================
export const usePendingTransactions = () =>
useSimpleBookingStore((state) => state.pendingTransactions);
export const useUnbookedTransactions = () =>
useSimpleBookingStore((state) => state.getUnbookedTransactions());
export const useFavoriteAccounts = () =>
useSimpleBookingStore((state) => state.favoriteAccounts);
export const useTopFavorites = (limit?: number) =>
useSimpleBookingStore((state) => state.getTopFavorites(limit));
export const useBookingModal = () =>
useSimpleBookingStore((state) => state.modal);
export const useSplitState = () =>
useSimpleBookingStore((state) => state.splitState);
export const useBookingPreview = () =>
useSimpleBookingStore((state) => state.preview);
export const useIsBookingSaving = () =>
useSimpleBookingStore((state) => state.isSaving);

View file

@ -0,0 +1,82 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
// Sidebar
sidebarCollapsed: boolean;
// Theme
darkMode: boolean;
// Notifications
notifications: Notification[];
// Actions
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleDarkMode: () => void;
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
clearNotifications: () => void;
}
interface Notification {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
description?: string;
timestamp: number;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
darkMode: false,
notifications: [],
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) =>
set({ sidebarCollapsed: collapsed }),
toggleDarkMode: () =>
set((state) => ({ darkMode: !state.darkMode })),
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{
...notification,
id: `notification-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: Date.now(),
},
],
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
clearNotifications: () =>
set({ notifications: [] }),
}),
{
name: 'books-ui-storage',
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
darkMode: state.darkMode,
}),
}
)
);
// Selector hooks
export const useSidebarCollapsed = () =>
useUIStore((state) => state.sidebarCollapsed);
export const useDarkMode = () =>
useUIStore((state) => state.darkMode);

View file

@ -0,0 +1,111 @@
/* Global styles for bookkeeping system */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Tabular figures for number alignment */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Amount colors */
.amount-positive {
color: #52c41a;
}
.amount-negative {
color: #ff4d4f;
}
.amount-zero {
color: #8c8c8c;
}
/* Right-aligned numbers in tables */
.text-right {
text-align: right;
}
/* Currency formatting */
.currency {
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Sticky table headers */
.ant-table-sticky-holder {
z-index: 3;
}
/* High-density table rows */
.ant-table-tbody > tr > td {
padding: 8px 12px !important;
}
/* Sidebar navigation improvements */
.ant-layout-sider {
overflow: auto;
height: 100vh;
position: fixed !important;
left: 0;
top: 0;
bottom: 0;
}
/* Main content area */
.ant-layout-content {
min-height: calc(100vh - 64px);
padding: 16px;
overflow: auto;
}
/* Card shadows for depth */
.ant-card {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px rgba(0, 0, 0, 0.02);
}
/* Form labels */
.ant-form-item-label > label {
font-weight: 500;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* Print styles */
@media print {
.ant-layout-sider,
.ant-layout-header {
display: none !important;
}
.ant-layout-content {
margin: 0 !important;
padding: 0 !important;
}
}

View file

@ -0,0 +1,78 @@
import type { ThemeConfig } from 'antd';
export const theme: ThemeConfig = {
token: {
// Primary colors
colorPrimary: '#1677ff',
colorSuccess: '#52c41a',
colorError: '#ff4d4f',
colorWarning: '#faad14',
colorInfo: '#1677ff',
// Typography - compact for high density
fontSize: 13,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
// Spacing - tighter for density
borderRadius: 4,
padding: 12,
paddingSM: 8,
paddingXS: 4,
// Layout
controlHeight: 32,
controlHeightSM: 24,
controlHeightLG: 40,
},
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 12,
headerBg: '#fafafa',
rowHoverBg: '#f5f5f5',
borderColor: '#f0f0f0',
},
Layout: {
siderBg: '#001529',
headerBg: '#fff',
bodyBg: '#f5f5f5',
},
Menu: {
darkItemBg: '#001529',
darkSubMenuItemBg: '#000c17',
},
Form: {
labelFontSize: 13,
verticalLabelPadding: '0 0 4px',
},
Card: {
paddingLG: 16,
},
Statistic: {
titleFontSize: 12,
contentFontSize: 20,
},
},
};
// Danish locale configuration
export const daLocale = {
locale: 'da',
// Number formatting for Danish
numberFormat: {
decimalSeparator: ',',
thousandSeparator: '.',
currencySymbol: 'kr.',
currencyPosition: 'after' as const,
},
};
// Color utilities for accounting
export const accountingColors = {
debit: '#ff4d4f', // Red
credit: '#52c41a', // Green
neutral: '#8c8c8c', // Gray
balance: '#1677ff', // Blue
warning: '#faad14', // Yellow/Orange
};

View file

@ -0,0 +1,193 @@
// Core accounting types for the bookkeeping system
export interface Company {
id: string;
name: string;
cvr: string; // Danish CVR number
address?: string;
city?: string;
postalCode?: string;
country: string;
vatNumber?: string;
fiscalYearStart: number; // Month (1-12)
currency: string;
createdAt: string;
updatedAt: string;
}
export interface Account {
id: string;
companyId: string;
accountNumber: string; // e.g., "1000", "2100"
name: string;
type: AccountType;
parentId?: string;
isActive: boolean;
description?: string;
vatCode?: string;
balance: number;
createdAt: string;
updatedAt: string;
}
export type AccountType =
| 'asset' // Aktiver (1000-1999)
| 'liability' // Passiver (2000-2999)
| 'equity' // Egenkapital (3000-3999)
| 'revenue' // Indtægter (4000-4999)
| 'cogs' // Vareforbrug (5000-5999)
| 'expense' // Driftsomkostninger (6000-6999)
| 'personnel' // Personaleomkostninger (7000-7999)
| 'financial' // Finansielle poster (8000-8999)
| 'extraordinary'; // Ekstraordinære poster (9000-9999)
export interface Transaction {
id: string;
companyId: string;
transactionNumber: string;
date: string;
description: string;
lines: TransactionLine[];
isReconciled: boolean;
isVoided: boolean;
attachments: Attachment[];
createdAt: string;
updatedAt: string;
createdBy: string;
}
export interface TransactionLine {
id: string;
transactionId: string;
accountId: string;
account?: Account;
description?: string;
debit: number;
credit: number;
vatCode?: string;
vatAmount?: number;
}
export interface Attachment {
id: string;
filename: string;
mimeType: string;
url: string;
size: number;
uploadedAt: string;
}
export interface BankAccount {
id: string;
companyId?: string;
name: string;
bankName: string;
accountNumber: string;
iban?: string;
currency: string;
ledgerAccountId?: string; // Linked to an Account
isActive: boolean;
balance?: number;
lastSyncedAt?: string;
}
export interface BankTransaction {
id: string;
bankAccountId: string;
date: string;
valueDate: string;
description: string;
amount: number;
balance: number;
reference?: string;
counterparty?: string;
isReconciled: boolean;
matchedTransactionId?: string;
importedAt: string;
}
export interface ReconciliationMatch {
bankTransactionId: string;
ledgerTransactionId: string;
matchType: 'auto' | 'manual';
confidence?: number; // For auto-matches (0-1)
matchedAt: string;
matchedBy: string;
}
export interface VATReport {
companyId: string;
period: DateRange;
boxes: VATBox[];
totalVATDue: number;
totalVATDeductible: number;
netVAT: number;
status: 'draft' | 'submitted' | 'accepted' | 'rejected';
submittedAt?: string;
}
export interface VATBox {
boxNumber: number;
name: string;
nameDanish: string;
amount: number;
basis?: number;
}
export interface DateRange {
start: string;
end: string;
}
export interface Employee {
id: string;
companyId: string;
name: string;
cpr?: string; // Danish CPR (masked)
employeeNumber: string;
department?: string;
position?: string;
startDate: string;
endDate?: string;
isActive: boolean;
}
export interface PayrollEntry {
id: string;
employeeId: string;
employee?: Employee;
period: string; // YYYY-MM
grossSalary: number;
amBidrag: number; // AM-bidrag (8%)
aSkat: number; // A-skat
atp: number; // ATP contribution
pension: number;
netSalary: number;
otherDeductions: number;
status: 'draft' | 'approved' | 'paid';
}
// Utility types
export interface PaginatedResponse<T> {
items: T[];
total: number;
pageInfo: PageInfo;
}
export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string;
endCursor?: string;
}
export interface SortConfig {
field: string;
direction: 'asc' | 'desc';
}
export interface FilterConfig {
field: string;
operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'in';
value: unknown;
}

150
frontend/src/types/api.ts Normal file
View file

@ -0,0 +1,150 @@
// GraphQL API types
import type {
Company,
Account,
Transaction,
BankAccount,
BankTransaction,
VATReport,
Employee,
PayrollEntry,
PaginatedResponse,
DateRange,
} from './accounting';
// Query response types
export interface CompaniesQueryResponse {
companies: Company[];
}
export interface TransactionsQueryResponse {
transactions: PaginatedResponse<Transaction>;
}
export interface AccountsQueryResponse {
accounts: Account[];
}
export interface BankAccountsQueryResponse {
bankAccounts: BankAccount[];
}
export interface BankTransactionsQueryResponse {
bankTransactions: BankTransaction[];
}
export interface VATReportQueryResponse {
vatReport: VATReport;
}
export interface EmployeesQueryResponse {
employees: Employee[];
}
export interface PayrollQueryResponse {
payrollEntries: PayrollEntry[];
}
// Query variables
export interface TransactionsQueryVariables {
companyId: string;
first?: number;
after?: string;
filter?: TransactionFilter;
sort?: TransactionSort;
}
export interface TransactionFilter {
dateFrom?: string;
dateTo?: string;
accountId?: string;
minAmount?: number;
maxAmount?: number;
description?: string;
isReconciled?: boolean;
}
export interface TransactionSort {
field: 'date' | 'transactionNumber' | 'description' | 'amount';
direction: 'ASC' | 'DESC';
}
export interface BankTransactionsQueryVariables {
companyId: string;
bankAccountId: string;
period: DateRange;
isReconciled?: boolean;
}
export interface VATReportQueryVariables {
companyId: string;
period: DateRange;
}
// Mutation inputs
export interface CreateTransactionInput {
companyId: string;
date: string;
description: string;
lines: CreateTransactionLineInput[];
attachmentIds?: string[];
}
export interface CreateTransactionLineInput {
accountId: string;
description?: string;
debit: number;
credit: number;
vatCode?: string;
}
export interface MatchBankTransactionInput {
bankTransactionId: string;
ledgerTransactionId?: string;
createTransaction?: CreateTransactionInput;
}
export interface VoidTransactionInput {
transactionId: string;
reason: string;
}
// Mutation response types
export interface CreateTransactionResponse {
createTransaction: Transaction;
}
export interface MatchBankTransactionResponse {
matchBankTransaction: {
success: boolean;
bankTransaction: BankTransaction;
matchedTransaction?: Transaction;
};
}
// Dashboard/Analytics types
export interface DashboardMetrics {
cashPosition: number;
accountsReceivable: number;
accountsPayable: number;
monthlyRevenue: number;
monthlyExpenses: number;
vatLiability: number;
unreconciledTransactions: number;
}
export interface CashFlowData {
date: string;
inflow: number;
outflow: number;
balance: number;
}
export interface AccountBalanceData {
accountId: string;
accountName: string;
accountNumber: string;
balance: number;
type: string;
}

View file

@ -0,0 +1,264 @@
// Fiscal Period Types for Danish Accounting (Regnskabsperioder)
/**
* Period frequency - how often accounting periods are defined
*/
export type PeriodFrequency =
| 'monthly' // Maanedlig
| 'quarterly' // Kvartalsvis
| 'half-yearly' // Halvaarlig
| 'yearly'; // Aarlig
/**
* Period status according to Danish accounting requirements
*/
export type PeriodStatus =
| 'future' // Fremtidig - not yet started
| 'open' // Aaben - current working period
| 'closed' // Lukket - closed but can be reopened
| 'locked'; // Laast - permanently locked (after arsafslutning)
/**
* VAT Period frequency (can differ from accounting periods)
* Based on SKAT requirements
*/
export type VATPeriodicitet =
| 'monthly' // Maanedlig (omsaetning > 50M DKK)
| 'quarterly' // Kvartalsvis (default for most)
| 'half-yearly' // Halvaarlig (omsaetning < 1M DKK, optional)
| 'yearly'; // Aarlig (omsaetning < 300K DKK, optional)
/**
* Fiscal Year (Regnskabsaar)
*/
export interface FiscalYear {
id: string;
companyId: string;
// Year identification
name: string; // e.g., "2024/2025" or "2025"
startDate: string; // ISO date
endDate: string; // ISO date
// Status
status: 'open' | 'closed' | 'locked';
// Year-end closing
closingDate?: string; // When year-end was performed
closedBy?: string; // User who closed the year
openingBalancePosted: boolean;
// Metadata
createdAt: string;
updatedAt: string;
}
/**
* Accounting Period (Regnskabsperiode)
*/
export interface AccountingPeriod {
id: string;
companyId: string;
fiscalYearId: string;
// Period identification
periodNumber: number; // 1-12 for monthly, 1-4 for quarterly, etc.
name: string; // e.g., "Januar 2025" or "Q1 2025"
shortName: string; // e.g., "Jan 2025" or "Q1"
// Date range
startDate: string; // ISO date
endDate: string; // ISO date
// Status
status: PeriodStatus;
// Status change tracking
closedAt?: string;
closedBy?: string;
lockedAt?: string;
lockedBy?: string;
reopenedAt?: string;
reopenedBy?: string;
// Metadata
createdAt: string;
updatedAt: string;
}
/**
* VAT Period (Momsperiode) for SKAT reporting
*/
export interface VATPeriod {
id: string;
companyId: string;
// Period identification
periodicitet: VATPeriodicitet;
year: number;
periodNumber: number; // 1-12 for monthly, 1-4 for quarterly, etc.
name: string; // e.g., "Q4 2024"
// Date range
startDate: string;
endDate: string;
// SKAT deadlines
deadline: string; // Frist for indberetning
paymentDeadline?: string; // Frist for betaling
// Status
status: 'future' | 'open' | 'closed' | 'draft' | 'submitted' | 'accepted' | 'rejected';
submittedAt?: string;
submissionReference?: string; // SKAT reference number
// Calculated amounts (from transactions)
netVAT?: number; // Moms til betaling/tilgode
// Metadata
createdAt: string;
updatedAt: string;
}
/**
* Period Settings per Company
*/
export interface PeriodSettings {
companyId: string;
// Accounting periods
accountingFrequency: PeriodFrequency;
fiscalYearStartMonth: number; // 1-12
// VAT periods
vatFrequency: VATPeriodicitet;
// Auto-close settings
autoClosePeriods: boolean;
autoCloseDelayDays: number; // Days after period end to auto-close
// Validation settings
preventPostingToClosedPeriods: boolean;
preventPostingToFuturePeriods: boolean;
requirePeriodCloseApproval: boolean;
}
/**
* Period Context - for components that need period awareness
*/
export interface PeriodContext {
currentFiscalYear: FiscalYear | null;
currentPeriod: AccountingPeriod | null;
selectedPeriod: AccountingPeriod | null;
selectedVATPeriod: VATPeriod | null;
// Comparison period for reports
comparisonPeriod?: AccountingPeriod | null;
comparisonType: 'previous-period' | 'previous-year' | 'custom' | null;
}
/**
* Year-end closing entry types
*/
export type ClosingEntryType =
| 'revenue-close' // Close revenue accounts to result
| 'expense-close' // Close expense accounts to result
| 'result-transfer' // Transfer result to equity
| 'opening-balance'; // Opening balances for new year
/**
* Year-end closing entry
*/
export interface ClosingEntry {
id: string;
fiscalYearId: string;
type: ClosingEntryType;
transactionId: string; // Reference to created transaction
amount: number;
description: string;
createdAt: string;
}
/**
* Period validation result
*/
export interface PeriodValidationResult {
isValid: boolean;
canPost: boolean;
errors: PeriodValidationError[];
warnings: PeriodValidationWarning[];
}
export interface PeriodValidationError {
code: string;
message: string;
messageDanish: string;
field?: string;
}
export interface PeriodValidationWarning {
code: string;
message: string;
messageDanish: string;
}
/**
* Danish month names for display
*/
export const DANISH_MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
] as const;
/**
* Danish short month names
*/
export const DANISH_MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'
] as const;
/**
* Period frequency display names
*/
export const PERIOD_FREQUENCY_NAMES: Record<PeriodFrequency, { danish: string; english: string }> = {
'monthly': { danish: 'Maanedlig', english: 'Monthly' },
'quarterly': { danish: 'Kvartalsvis', english: 'Quarterly' },
'half-yearly': { danish: 'Halvaarslig', english: 'Half-yearly' },
'yearly': { danish: 'Aarlig', english: 'Yearly' },
};
/**
* Period status display names and colors
*/
export const PERIOD_STATUS_CONFIG: Record<PeriodStatus, {
danish: string;
english: string;
color: string;
icon: string;
}> = {
'future': {
danish: 'Fremtidig',
english: 'Future',
color: 'default',
icon: 'clock-circle'
},
'open': {
danish: 'Aaben',
english: 'Open',
color: 'green',
icon: 'check-circle'
},
'closed': {
danish: 'Lukket',
english: 'Closed',
color: 'orange',
icon: 'minus-circle'
},
'locked': {
danish: 'Laast',
english: 'Locked',
color: 'red',
icon: 'lock'
},
};

126
frontend/src/types/ui.ts Normal file
View file

@ -0,0 +1,126 @@
// UI component types
import type { TableColumnType } from 'antd';
import type { DocumentNode } from 'graphql';
// DataTable configuration
export interface DataTableColumn<T> extends Omit<TableColumnType<T>, 'render' | 'dataIndex'> {
dataIndex: string | string[];
title: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: unknown, record: T, index: number) => React.ReactNode;
// Custom column types
columnType?: 'text' | 'number' | 'currency' | 'date' | 'boolean' | 'actions';
// Currency/number formatting
decimalPlaces?: number;
showSign?: boolean;
}
export interface DataTableProps<T extends { id: string }> {
// Data source configuration
queryKey: string[];
query: DocumentNode;
variables?: Record<string, unknown>;
dataPath: string; // Path to items array in response
totalPath: string; // Path to total count in response
// Columns
columns: DataTableColumn<T>[];
// Pagination
pageSize?: number;
pageSizeOptions?: number[];
// Features
rowSelection?: 'single' | 'multiple' | false;
onRowClick?: (record: T) => void;
onSelectionChange?: (selectedRowKeys: string[], selectedRows: T[]) => void;
exportable?: boolean;
exportFilename?: string;
// Row styling
rowClassName?: (record: T, index: number) => string;
// Actions
toolbarActions?: React.ReactNode;
// Empty state
emptyText?: string;
// Loading
loading?: boolean;
}
export interface FilterFormConfig {
fields: FilterFieldConfig[];
onFilter: (values: Record<string, unknown>) => void;
onReset: () => void;
}
export interface FilterFieldConfig {
name: string;
label: string;
type: 'text' | 'number' | 'date' | 'dateRange' | 'select' | 'checkbox';
placeholder?: string;
options?: { label: string; value: string | number }[];
defaultValue?: unknown;
}
// Navigation types
export interface MenuItem {
key: string;
label: string;
icon?: React.ReactNode;
path?: string;
children?: MenuItem[];
}
// Form types
export interface FormFieldError {
field: string;
message: string;
}
// Modal types
export interface ModalState {
visible: boolean;
mode: 'create' | 'edit' | 'view';
data?: unknown;
}
// Notification types
export interface NotificationConfig {
type: 'success' | 'error' | 'info' | 'warning';
message: string;
description?: string;
duration?: number;
}
// Reconciliation UI types
export interface ReconciliationViewState {
selectedBankTransactions: string[];
selectedLedgerTransactions: string[];
matchSuggestions: MatchSuggestion[];
viewMode: 'list' | 'side-by-side';
}
export interface MatchSuggestion {
bankTransactionId: string;
ledgerTransactionId: string;
confidence: number;
reason: string;
}
// Chart data types
export interface ChartDataPoint {
label: string;
value: number;
color?: string;
}
export interface TimeSeriesDataPoint {
date: string;
value: number;
category?: string;
}

360
frontend/src/types/vat.ts Normal file
View file

@ -0,0 +1,360 @@
// VAT (Moms) Types for Danish SKAT Compliance
import type { VATPeriodicitet } from './periods';
// =====================================================
// SKAT VAT CODES (Momskoder)
// =====================================================
/**
* VAT codes used in Danish bookkeeping
*/
export type VATCode =
| 'S25' // Salgsmoms 25% (udgaaende moms)
| 'K25' // Koebsmoms 25% (indgaaende moms)
| 'EU_VARE' // EU-varekoeb (reverse charge)
| 'EU_YDELSE' // EU-ydelseskoeb (reverse charge)
| 'MOMSFRI' // Momsfritaget (healthcare, education, etc.)
| 'EKSPORT' // Eksport (0%)
| 'NONE'; // Ingen moms
/**
* VAT code type classification
*/
export type VATCodeType =
| 'output' // Udgaaende moms (salg)
| 'input' // Indgaaende moms (koeb)
| 'reverse_charge' // Omvendt betalingspligt
| 'exempt' // Momsfritaget
| 'none'; // Ingen moms
/**
* VAT code configuration with calculation rules
*/
export interface VATCodeConfig {
code: VATCode;
nameDanish: string;
nameEnglish: string;
rate: number; // VAT rate (0.25 for 25%)
type: VATCodeType;
affectsBoxes: {
vatBox?: VATBoxId; // Which VAT box this affects
basisBox?: BasisBoxId; // Which basis/turnover box this affects
};
reverseCharge: boolean; // Is this reverse charge VAT?
deductible: boolean; // Is this VAT deductible?
description: string; // Danish description for UI
}
// =====================================================
// SKAT VAT BOXES (Rubrikker)
// =====================================================
/**
* SKAT VAT Box identifiers
* Rubrik A-D: VAT amounts (momsbeloeb)
* Rubrik 1-4: Turnover/basis amounts (omsaetning/grundlag)
*/
export type VATBoxId = 'A' | 'B' | 'C' | 'D';
export type BasisBoxId = '1' | '2' | '3' | '4';
/**
* SKAT VAT Box (Rubrik) definition
*/
export interface SKATVATBox {
id: VATBoxId | BasisBoxId;
type: 'vat' | 'basis';
nameDanish: string;
nameEnglish: string;
description: string;
skippable: boolean; // Can be omitted if zero
isDeductible: boolean; // Is this a deductible amount?
}
/**
* Calculated VAT box with amount
*/
export interface CalculatedVATBox extends SKATVATBox {
amount: number;
basis?: number; // For VAT boxes, the underlying basis amount
transactionCount: number; // Number of transactions contributing
transactionIds: string[]; // IDs of contributing transactions
}
// =====================================================
// VAT REPORT
// =====================================================
/**
* VAT report status
*/
export type VATReportStatus =
| 'future' // Period hasn't started yet
| 'open' // Period is open for transactions
| 'closed' // Period closed, not yet reported
| 'draft' // Report drafted but not submitted
| 'submitted' // Submitted to SKAT
| 'accepted' // Accepted by SKAT
| 'rejected' // Rejected by SKAT
| 'corrected'; // Correction submitted
/**
* VAT Report Period (matches VATPeriod in periods.ts but specific to reports)
*/
export interface VATReportPeriod {
id: string;
companyId: string;
periodicitet: VATPeriodicitet;
year: number;
periodNumber: number;
startDate: string;
endDate: string;
deadline: string;
status: VATReportStatus;
}
/**
* Complete VAT report for a period
*/
export interface VATReport {
id: string;
companyId: string;
period: VATReportPeriod;
// SKAT boxes with calculated amounts
boxes: {
// VAT amounts (Momsbeloeb)
A: CalculatedVATBox; // Salgsmoms (udgaaende moms)
B: CalculatedVATBox; // Koebsmoms (indgaaende moms - fradrag)
C: CalculatedVATBox; // EU-varekoeb moms
D: CalculatedVATBox; // Ydelseskoeb fra udland moms
// Basis/turnover amounts (Omsaetning)
'1': CalculatedVATBox; // Salg med moms (momsgrundlag)
'2': CalculatedVATBox; // Salg uden moms (momsfrit/eksport)
'3': CalculatedVATBox; // EU-varekoeb
'4': CalculatedVATBox; // Ydelseskoeb fra udland
};
// Summary calculations
totalOutputVAT: number; // A + C + D
totalInputVAT: number; // B (fradragsberettiget)
netVAT: number; // totalOutputVAT - totalInputVAT
// Energy duty refunds (Afgiftsgodtgoerelse) - optional
energyDuties?: {
oilGas: number; // Olie- og flaskegasafgift
electricity: number; // Elafgift
naturalGas: number; // Naturgas- og bygasafgift
coal: number; // Kulafgift
co2: number; // CO2-afgift
};
// Metadata
status: VATReportStatus;
createdAt: string;
updatedAt: string;
submittedAt?: string;
submittedBy?: string;
skatReferenceNumber?: string; // Reference from SKAT
// Audit trail
adjustments: VATAdjustment[];
history: VATReportHistory[];
}
/**
* VAT adjustment entry (for corrections)
*/
export interface VATAdjustment {
id: string;
reportId: string;
boxId: VATBoxId | BasisBoxId;
previousAmount: number;
newAmount: number;
reason: string;
adjustedAt: string;
adjustedBy: string;
}
/**
* VAT report history entry
*/
export interface VATReportHistory {
id: string;
action: 'created' | 'calculated' | 'adjusted' | 'submitted' | 'accepted' | 'rejected' | 'corrected';
timestamp: string;
userId: string;
details?: string;
}
// =====================================================
// VAT CALCULATION
// =====================================================
/**
* Transaction line with VAT information for calculation
*/
export interface VATTransactionLine {
transactionId: string;
transactionLineId: string;
transactionNumber: string;
transactionDate: string;
accountId: string;
accountNumber: string;
accountName: string;
description: string;
debit: number;
credit: number;
netAmount: number; // Amount excluding VAT
vatCode: VATCode;
vatAmount: number; // Calculated VAT amount
vatRate: number; // Applied VAT rate
isReverseCharge: boolean;
}
/**
* VAT calculation result
*/
export interface VATCalculationResult {
period: VATReportPeriod;
transactions: VATTransactionLine[];
boxes: Record<VATBoxId | BasisBoxId, {
amount: number;
transactionCount: number;
transactionIds: string[];
}>;
summary: {
totalOutputVAT: number;
totalInputVAT: number;
netVAT: number;
};
warnings: VATCalculationWarning[];
errors: VATCalculationError[];
}
export interface VATCalculationWarning {
type: 'missing_vat_code' | 'unusual_amount' | 'period_mismatch' | 'account_mismatch';
message: string;
messageDanish: string;
transactionId?: string;
severity: 'low' | 'medium' | 'high';
}
export interface VATCalculationError {
type: 'invalid_vat_code' | 'calculation_error' | 'data_integrity';
message: string;
messageDanish: string;
transactionId?: string;
}
// =====================================================
// SKAT EXPORT FORMAT
// =====================================================
/**
* SKAT CSV export format
*/
export interface SKATExportCSV {
cvr: string;
periode: string; // Format: YYYYMM or YYYYQQ
rubrikA: number;
rubrikB: number;
rubrikC: number;
rubrikD: number;
felt1: number;
felt2: number;
felt3: number;
felt4: number;
}
/**
* SKAT XML export format (for API submission)
*/
export interface SKATExportXML {
version: string;
cvr: string;
periodeStart: string;
periodeSlut: string;
angivelse: {
salgsmoms: number; // Rubrik A
koebsmoms: number; // Rubrik B
euVarekoebMoms: number; // Rubrik C
ydelseskoebMoms: number; // Rubrik D
salgMedMoms: number; // Felt 1
salgUdenMoms: number; // Felt 2
euVarekoeb: number; // Felt 3
ydelseskoeb: number; // Felt 4
};
afgifter?: {
olie: number;
el: number;
naturgas: number;
kul: number;
co2: number;
};
}
// =====================================================
// SUBMISSION TRACKING
// =====================================================
/**
* VAT submission record
*/
export interface VATSubmission {
id: string;
reportId: string;
companyId: string;
period: VATReportPeriod;
// Submission details
submittedAt: string;
submittedBy: string;
method: 'manual' | 'api'; // Manual upload or API
// SKAT response
skatStatus: 'pending' | 'accepted' | 'rejected';
skatReferenceNumber?: string;
skatResponseAt?: string;
skatErrorMessage?: string;
// Amounts submitted
submittedAmounts: {
boxA: number;
boxB: number;
boxC: number;
boxD: number;
basis1: number;
basis2: number;
basis3: number;
basis4: number;
netVAT: number;
};
// Files
exportFile?: {
type: 'csv' | 'xml';
filename: string;
url: string;
};
}
// =====================================================
// PERIODICITET CONFIG
// =====================================================
/**
* VAT period configuration per type
*/
export interface VATPeriodicitetConfig {
type: VATPeriodicitet;
nameDanish: string;
nameEnglish: string;
deadlineDaysAfterPeriod: number; // Days after period end for deadline
periodsPerYear: number;
threshold?: {
min?: number; // Minimum annual revenue
max?: number; // Maximum annual revenue
};
}

14
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GRAPHQL_ENDPOINT: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module '*.css' {
const content: string;
export default content;
}

25
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./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/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}

16
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
open: true,
},
});