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:
commit
66f6fa138d
126 changed files with 24741 additions and 0 deletions
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
6459
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
18
frontend/src/App.tsx
Normal 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;
|
||||
73
frontend/src/api/client.ts
Normal file
73
frontend/src/api/client.ts
Normal 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;
|
||||
}
|
||||
40
frontend/src/components/layout/AppLayout.tsx
Normal file
40
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/layout/CompanySwitcher.tsx
Normal file
94
frontend/src/components/layout/CompanySwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
frontend/src/components/layout/FiscalYearSelector.tsx
Normal file
242
frontend/src/components/layout/FiscalYearSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/layout/Header.tsx
Normal file
113
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/layout/Sidebar.tsx
Normal file
135
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
521
frontend/src/components/modals/CloseFiscalYearWizard.tsx
Normal file
521
frontend/src/components/modals/CloseFiscalYearWizard.tsx
Normal 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 på <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 på <strong>{formatCurrency(summary.netResult)}</strong> vil blive
|
||||
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} på 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>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/modals/CreateFiscalYearModal.tsx
Normal file
308
frontend/src/components/modals/CreateFiscalYearModal.tsx
Normal 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 på dette.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/simple-booking/AccountQuickPicker.tsx
Normal file
220
frontend/src/components/simple-booking/AccountQuickPicker.tsx
Normal 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;
|
||||
165
frontend/src/components/simple-booking/BankTransactionCard.tsx
Normal file
165
frontend/src/components/simple-booking/BankTransactionCard.tsx
Normal 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;
|
||||
298
frontend/src/components/simple-booking/QuickBookModal.tsx
Normal file
298
frontend/src/components/simple-booking/QuickBookModal.tsx
Normal 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;
|
||||
472
frontend/src/components/simple-booking/SplitBookModal.tsx
Normal file
472
frontend/src/components/simple-booking/SplitBookModal.tsx
Normal 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;
|
||||
6
frontend/src/components/simple-booking/index.ts
Normal file
6
frontend/src/components/simple-booking/index.ts
Normal 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';
|
||||
302
frontend/src/components/tables/DataTable.tsx
Normal file
302
frontend/src/components/tables/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/hooks/useCompany.ts
Normal file
70
frontend/src/hooks/useCompany.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
176
frontend/src/hooks/useDataTable.ts
Normal file
176
frontend/src/hooks/useDataTable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
345
frontend/src/hooks/usePeriod.ts
Normal file
345
frontend/src/hooks/usePeriod.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
586
frontend/src/lib/accounting.ts
Normal file
586
frontend/src/lib/accounting.ts
Normal 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
|
||||
);
|
||||
}
|
||||
611
frontend/src/lib/fiscalYear.ts
Normal file
611
frontend/src/lib/fiscalYear.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
frontend/src/lib/formatters.ts
Normal file
167
frontend/src/lib/formatters.ts
Normal 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
541
frontend/src/lib/periods.ts
Normal 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'))
|
||||
);
|
||||
}
|
||||
419
frontend/src/lib/vatCalculation.ts
Normal file
419
frontend/src/lib/vatCalculation.ts
Normal 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;
|
||||
}
|
||||
378
frontend/src/lib/vatCodes.ts
Normal file
378
frontend/src/lib/vatCodes.ts
Normal 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
25
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
727
frontend/src/pages/Bankafstemning.tsx
Normal file
727
frontend/src/pages/Bankafstemning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
frontend/src/pages/Dashboard.tsx
Normal file
329
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
frontend/src/pages/HurtigBogforing.tsx
Normal file
322
frontend/src/pages/HurtigBogforing.tsx
Normal 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;
|
||||
540
frontend/src/pages/Kassekladde.tsx
Normal file
540
frontend/src/pages/Kassekladde.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
528
frontend/src/pages/Kontooversigt.tsx
Normal file
528
frontend/src/pages/Kontooversigt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
567
frontend/src/pages/Loenforstaelse.tsx
Normal file
567
frontend/src/pages/Loenforstaelse.tsx
Normal 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 på <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>
|
||||
);
|
||||
}
|
||||
574
frontend/src/pages/Momsindberetning.tsx
Normal file
574
frontend/src/pages/Momsindberetning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
frontend/src/pages/Settings.tsx
Normal file
422
frontend/src/pages/Settings.tsx
Normal 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
59
frontend/src/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/stores/companyStore.ts
Normal file
53
frontend/src/stores/companyStore.ts
Normal 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);
|
||||
407
frontend/src/stores/periodStore.ts
Normal file
407
frontend/src/stores/periodStore.ts
Normal 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));
|
||||
172
frontend/src/stores/reconciliationStore.ts
Normal file
172
frontend/src/stores/reconciliationStore.ts
Normal 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);
|
||||
364
frontend/src/stores/simpleBookingStore.ts
Normal file
364
frontend/src/stores/simpleBookingStore.ts
Normal 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);
|
||||
82
frontend/src/stores/uiStore.ts
Normal file
82
frontend/src/stores/uiStore.ts
Normal 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);
|
||||
111
frontend/src/styles/global.css
Normal file
111
frontend/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
78
frontend/src/styles/theme.ts
Normal file
78
frontend/src/styles/theme.ts
Normal 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
|
||||
};
|
||||
193
frontend/src/types/accounting.ts
Normal file
193
frontend/src/types/accounting.ts
Normal 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
150
frontend/src/types/api.ts
Normal 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;
|
||||
}
|
||||
264
frontend/src/types/periods.ts
Normal file
264
frontend/src/types/periods.ts
Normal 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
126
frontend/src/types/ui.ts
Normal 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
360
frontend/src/types/vat.ts
Normal 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
14
frontend/src/vite-env.d.ts
vendored
Normal 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
25
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue