Remove Hurtig Bogforing feature
Delete the quick booking page, components, and store: - HurtigBogforing.tsx page - simple-booking/ components (AccountQuickPicker, BankTransactionCard, QuickBookModal, SplitBookModal) - simpleBookingStore.ts Remove related navigation and shortcuts: - Route from routes.tsx - Menu item from Sidebar - goToHurtigBogforing shortcut and navigation route - Command palette icon reference Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bacbea33b
commit
f826794990
12 changed files with 851 additions and 1855 deletions
|
|
@ -1,3 +1,4 @@
|
|||
{"id":"books-bj6","title":"Test automatisk pickup","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:04:40.572496+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:05:44.401903+01:00","closed_at":"2026-01-30T14:05:44.401903+01:00","close_reason":"completed"}
|
||||
{"id":"books-h6e","title":"fjern hurtig bogføring og den visning der høre dertil","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:14:50.436314+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:15:06.776032+01:00"}
|
||||
{"id":"books-sbm","title":"ændre navnet i venstre side til Books","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:11:13.017202+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:12:14.16594+01:00","closed_at":"2026-01-30T14:12:14.16594+01:00","close_reason":"Closed"}
|
||||
{"id":"books-wqf","title":"Opret en logud knap i topbaren","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:06:06.999508+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:10:52.860045+01:00","closed_at":"2026-01-30T14:10:52.860045+01:00","close_reason":"Closed"}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
BuildOutlined,
|
||||
ShopOutlined,
|
||||
|
|
@ -44,7 +43,6 @@ const menuItems: MenuItem[] = [
|
|||
getItem('Dashboard', '/', <DashboardOutlined />),
|
||||
|
||||
getItem('Bogfoering', 'accounting', <BookOutlined />, [
|
||||
getItem('Hurtig Bogfoering', '/hurtig-bogforing', <ThunderboltOutlined />),
|
||||
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
|
||||
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
|
||||
]),
|
||||
|
|
@ -75,7 +73,7 @@ const menuItems: MenuItem[] = [
|
|||
|
||||
// Determine open keys for submenus based on current path
|
||||
function getOpenKeys(pathname: string): string[] {
|
||||
if (pathname === '/kassekladde' || pathname === '/kontooversigt' || pathname === '/hurtig-bogforing') {
|
||||
if (pathname === '/kassekladde' || pathname === '/kontooversigt') {
|
||||
return ['accounting'];
|
||||
}
|
||||
if (pathname === '/kunder' || pathname === '/produkter' || pathname === '/ordrer' || pathname === '/fakturaer' || pathname === '/kreditnotaer') {
|
||||
|
|
|
|||
495
frontend/src/components/shared/CommandPalette.tsx
Normal file
495
frontend/src/components/shared/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { Modal, Input, Typography, Tag, Empty, theme } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
DashboardOutlined,
|
||||
BookOutlined,
|
||||
FileTextOutlined,
|
||||
BankOutlined,
|
||||
TeamOutlined,
|
||||
ShopOutlined,
|
||||
PercentageOutlined,
|
||||
ExportOutlined,
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
SyncOutlined,
|
||||
HistoryOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
shortcuts,
|
||||
navigationRoutes,
|
||||
formatShortcut,
|
||||
} from '@/lib/keyboardShortcuts';
|
||||
import { useRecentCommands } from '@/stores/hotkeyStore';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useToken } = theme;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onExecute: (commandId: string, route?: string) => void;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: React.ReactNode;
|
||||
shortcut?: string;
|
||||
category: 'recent' | 'navigation' | 'action';
|
||||
route?: string;
|
||||
}
|
||||
|
||||
// Icon mapping for navigation items
|
||||
const navigationIcons: Record<string, React.ReactNode> = {
|
||||
goToDashboard: <DashboardOutlined />,
|
||||
goToKassekladde: <BookOutlined />,
|
||||
goToKontooversigt: <BookOutlined />,
|
||||
goToFakturaer: <FileTextOutlined />,
|
||||
goToKreditnotaer: <FileTextOutlined />,
|
||||
goToBankafstemning: <BankOutlined />,
|
||||
goToKunder: <TeamOutlined />,
|
||||
goToProdukter: <ShopOutlined />,
|
||||
goToMomsindberetning: <PercentageOutlined />,
|
||||
goToIndstillinger: <SettingOutlined />,
|
||||
};
|
||||
|
||||
// Action commands (not navigation)
|
||||
const actionCommands: CommandItem[] = [
|
||||
{
|
||||
id: 'newDraft',
|
||||
label: 'Ny kassekladde',
|
||||
description: 'Opret en ny kassekladde',
|
||||
icon: <PlusOutlined />,
|
||||
shortcut: shortcuts.newDraft?.keys,
|
||||
category: 'action',
|
||||
route: '/kassekladde?action=new',
|
||||
},
|
||||
{
|
||||
id: 'newInvoice',
|
||||
label: 'Ny faktura',
|
||||
description: 'Opret en ny faktura',
|
||||
icon: <PlusOutlined />,
|
||||
shortcut: shortcuts.newInvoice?.keys,
|
||||
category: 'action',
|
||||
route: '/fakturaer?action=new',
|
||||
},
|
||||
{
|
||||
id: 'newCustomer',
|
||||
label: 'Ny kunde',
|
||||
description: 'Opret en ny kunde',
|
||||
icon: <PlusOutlined />,
|
||||
shortcut: shortcuts.newCustomer?.keys,
|
||||
category: 'action',
|
||||
route: '/kunder?action=new',
|
||||
},
|
||||
{
|
||||
id: 'newProduct',
|
||||
label: 'Nyt produkt',
|
||||
description: 'Opret et nyt produkt',
|
||||
icon: <PlusOutlined />,
|
||||
shortcut: shortcuts.newProduct?.keys,
|
||||
category: 'action',
|
||||
route: '/produkter?action=new',
|
||||
},
|
||||
{
|
||||
id: 'syncBank',
|
||||
label: 'Synkroniser bank',
|
||||
description: 'Hent nye transaktioner fra bank',
|
||||
icon: <SyncOutlined />,
|
||||
shortcut: shortcuts.syncBank?.keys,
|
||||
category: 'action',
|
||||
route: '/bankafstemning?action=sync',
|
||||
},
|
||||
{
|
||||
id: 'exportSaft',
|
||||
label: 'Eksporter SAF-T',
|
||||
description: 'Eksporter regnskabsdata til SAF-T format',
|
||||
icon: <ExportOutlined />,
|
||||
category: 'action',
|
||||
route: '/eksport',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* CommandPalette - Cmd+K quick navigation and actions
|
||||
*/
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
onExecute,
|
||||
currentPath,
|
||||
}: CommandPaletteProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { token } = useToken();
|
||||
const recentCommands = useRecentCommands();
|
||||
|
||||
// Build navigation commands from shortcuts
|
||||
const navigationCommands: CommandItem[] = useMemo(() => {
|
||||
return Object.entries(navigationRoutes).map(([shortcutId, route]) => {
|
||||
const shortcut = shortcuts[shortcutId];
|
||||
return {
|
||||
id: shortcutId,
|
||||
label: shortcut?.label || shortcutId,
|
||||
icon: navigationIcons[shortcutId] || <DashboardOutlined />,
|
||||
shortcut: shortcut?.keys,
|
||||
category: 'navigation' as const,
|
||||
route,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build recent commands
|
||||
const recentItems: CommandItem[] = useMemo(() => {
|
||||
return recentCommands.map((cmd) => {
|
||||
// Find if it's a navigation or action command
|
||||
const navCmd = navigationCommands.find((n) => n.id === cmd.id);
|
||||
const actionCmd = actionCommands.find((a) => a.id === cmd.id);
|
||||
const source = navCmd || actionCmd;
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
label: cmd.label,
|
||||
icon: source?.icon || <HistoryOutlined />,
|
||||
shortcut: source?.shortcut,
|
||||
category: 'recent' as const,
|
||||
route: source?.route,
|
||||
};
|
||||
});
|
||||
}, [recentCommands, navigationCommands]);
|
||||
|
||||
// Filter commands based on search
|
||||
const filteredCommands = useMemo(() => {
|
||||
const query = searchValue.toLowerCase().trim();
|
||||
|
||||
// All available commands
|
||||
const allCommands = [
|
||||
...recentItems,
|
||||
...navigationCommands,
|
||||
...actionCommands,
|
||||
];
|
||||
|
||||
if (!query) {
|
||||
// No search - show recent first, then navigation, then actions
|
||||
// But deduplicate (recent items appear first, not duplicated)
|
||||
const recentIds = new Set(recentItems.map((r) => r.id));
|
||||
const dedupedNav = navigationCommands.filter((n) => !recentIds.has(n.id));
|
||||
const dedupedActions = actionCommands.filter((a) => !recentIds.has(a.id));
|
||||
|
||||
return [...recentItems, ...dedupedNav, ...dedupedActions];
|
||||
}
|
||||
|
||||
// Filter by search query (fuzzy match on label)
|
||||
return allCommands.filter((cmd) => {
|
||||
const labelMatch = cmd.label.toLowerCase().includes(query);
|
||||
const descMatch = cmd.description?.toLowerCase().includes(query);
|
||||
return labelMatch || descMatch;
|
||||
});
|
||||
}, [searchValue, recentItems, navigationCommands]);
|
||||
|
||||
// Reset selection when search changes or modal opens
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [searchValue, open]);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchValue('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
const selectedElement = listRef.current.querySelector(
|
||||
`[data-index="${selectedIndex}"]`
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) =>
|
||||
i < filteredCommands.length - 1 ? i + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) =>
|
||||
i > 0 ? i - 1 : filteredCommands.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
const selected = filteredCommands[selectedIndex];
|
||||
if (selected) {
|
||||
handleExecute(selected);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredCommands, selectedIndex, onClose]
|
||||
);
|
||||
|
||||
// Execute command
|
||||
const handleExecute = useCallback(
|
||||
(command: CommandItem) => {
|
||||
onExecute(command.id, command.route);
|
||||
onClose();
|
||||
},
|
||||
[onExecute, onClose]
|
||||
);
|
||||
|
||||
// Category labels
|
||||
const getCategoryLabel = (category: string): string => {
|
||||
switch (category) {
|
||||
case 'recent':
|
||||
return 'Seneste';
|
||||
case 'navigation':
|
||||
return 'Navigation';
|
||||
case 'action':
|
||||
return 'Handlinger';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
// Group commands by category for display
|
||||
const groupedCommands = useMemo(() => {
|
||||
const groups: Record<string, CommandItem[]> = {};
|
||||
filteredCommands.forEach((cmd) => {
|
||||
if (!groups[cmd.category]) {
|
||||
groups[cmd.category] = [];
|
||||
}
|
||||
groups[cmd.category].push(cmd);
|
||||
});
|
||||
return groups;
|
||||
}, [filteredCommands]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={560}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { borderRadius: 12, overflow: 'hidden' },
|
||||
}}
|
||||
maskClosable
|
||||
>
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
{/* Search input */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<any>}
|
||||
placeholder="Søg efter sider eller handlinger..."
|
||||
prefix={<SearchOutlined style={{ color: token.colorTextSecondary }} />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
variant="borderless"
|
||||
size="large"
|
||||
style={{ fontSize: 16 }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
{filteredCommands.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="Ingen resultater"
|
||||
style={{ padding: '32px 0' }}
|
||||
/>
|
||||
) : (
|
||||
Object.entries(groupedCommands).map(([category, commands]) => (
|
||||
<div key={category}>
|
||||
{/* Category header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px 4px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: token.colorTextSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</div>
|
||||
|
||||
{/* Command items */}
|
||||
{commands.map((command) => {
|
||||
const globalIndex = filteredCommands.indexOf(command);
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
const isCurrent = command.route === currentPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${command.category}-${command.id}`}
|
||||
data-index={globalIndex}
|
||||
onClick={() => handleExecute(command)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected
|
||||
? token.colorPrimaryBg
|
||||
: 'transparent',
|
||||
borderLeft: isSelected
|
||||
? `3px solid ${token.colorPrimary}`
|
||||
: '3px solid transparent',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
style={{
|
||||
marginRight: 12,
|
||||
fontSize: 16,
|
||||
color: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{command.icon}
|
||||
</span>
|
||||
|
||||
{/* Label and description */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong={isSelected}
|
||||
style={{
|
||||
color: isSelected
|
||||
? token.colorText
|
||||
: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{command.label}
|
||||
</Text>
|
||||
{isCurrent && (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ margin: 0, fontSize: 10 }}
|
||||
>
|
||||
Aktuel
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{command.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcut badge */}
|
||||
{command.shortcut && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{formatShortcut(command.shortcut)
|
||||
.split(' ')
|
||||
.map((key, i) => (
|
||||
<Tag
|
||||
key={i}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0 6px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Tag style={{ margin: 0, marginRight: 4 }}>↑↓</Tag>
|
||||
Navigér
|
||||
<Tag style={{ margin: '0 4px' }}>↵</Tag>
|
||||
Vælg
|
||||
<Tag style={{ margin: '0 4px' }}>Esc</Tag>
|
||||
Luk
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandPalette;
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// Simple Booking Components - Export barrel
|
||||
|
||||
export { BankTransactionCard, BankTransactionList } from './BankTransactionCard';
|
||||
export { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
|
||||
export { QuickBookModal } from './QuickBookModal';
|
||||
export { SplitBookModal } from './SplitBookModal';
|
||||
327
frontend/src/lib/keyboardShortcuts.ts
Normal file
327
frontend/src/lib/keyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Keyboard Shortcuts Registry and Utilities
|
||||
* Central configuration for all keyboard shortcuts in the application
|
||||
*/
|
||||
|
||||
// Platform detection
|
||||
export const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
/**
|
||||
* Modifier key display based on platform
|
||||
*/
|
||||
export const modifierKey = isMac ? '⌘' : 'Ctrl';
|
||||
export const altKey = isMac ? '⌥' : 'Alt';
|
||||
export const shiftKey = '⇧';
|
||||
|
||||
/**
|
||||
* Shortcut definition
|
||||
*/
|
||||
export interface ShortcutDefinition {
|
||||
id: string;
|
||||
keys: string; // react-hotkeys-hook format: 'mod+k', 'mod+shift+n'
|
||||
label: string; // Danish display name
|
||||
description?: string;
|
||||
category: ShortcutCategory;
|
||||
scope?: ShortcutScope;
|
||||
}
|
||||
|
||||
export type ShortcutCategory =
|
||||
| 'global'
|
||||
| 'navigation'
|
||||
| 'bogforing'
|
||||
| 'faktura'
|
||||
| 'bank'
|
||||
| 'kunder'
|
||||
| 'produkter';
|
||||
|
||||
export type ShortcutScope =
|
||||
| 'global' // Works everywhere
|
||||
| 'page' // Only on specific page
|
||||
| 'modal' // Only in modals
|
||||
| 'table'; // Only when table is focused
|
||||
|
||||
/**
|
||||
* All keyboard shortcuts in the application
|
||||
*/
|
||||
export const shortcuts: Record<string, ShortcutDefinition> = {
|
||||
// Global shortcuts
|
||||
openCommandPalette: {
|
||||
id: 'openCommandPalette',
|
||||
keys: 'mod+k',
|
||||
label: 'Kommandopalette',
|
||||
description: 'Hurtig navigation og actions',
|
||||
category: 'global',
|
||||
scope: 'global',
|
||||
},
|
||||
showShortcuts: {
|
||||
id: 'showShortcuts',
|
||||
keys: 'mod+/',
|
||||
label: 'Vis genveje',
|
||||
description: 'Vis alle tastaturgenveje',
|
||||
category: 'global',
|
||||
scope: 'global',
|
||||
},
|
||||
closeModal: {
|
||||
id: 'closeModal',
|
||||
keys: 'escape',
|
||||
label: 'Luk',
|
||||
description: 'Luk aktiv modal eller palette',
|
||||
category: 'global',
|
||||
scope: 'global',
|
||||
},
|
||||
|
||||
// Navigation shortcuts (G then X pattern)
|
||||
goToDashboard: {
|
||||
id: 'goToDashboard',
|
||||
keys: 'g d',
|
||||
label: 'Dashboard',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToKassekladde: {
|
||||
id: 'goToKassekladde',
|
||||
keys: 'g k',
|
||||
label: 'Kassekladde',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToKontooversigt: {
|
||||
id: 'goToKontooversigt',
|
||||
keys: 'g o',
|
||||
label: 'Kontooversigt',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToFakturaer: {
|
||||
id: 'goToFakturaer',
|
||||
keys: 'g f',
|
||||
label: 'Fakturaer',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToKreditnotaer: {
|
||||
id: 'goToKreditnotaer',
|
||||
keys: 'g c',
|
||||
label: 'Kreditnotaer',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToBankafstemning: {
|
||||
id: 'goToBankafstemning',
|
||||
keys: 'g b',
|
||||
label: 'Bankafstemning',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToKunder: {
|
||||
id: 'goToKunder',
|
||||
keys: 'g u',
|
||||
label: 'Kunder',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToProdukter: {
|
||||
id: 'goToProdukter',
|
||||
keys: 'g p',
|
||||
label: 'Produkter',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToMomsindberetning: {
|
||||
id: 'goToMomsindberetning',
|
||||
keys: 'g m',
|
||||
label: 'Momsindberetning',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
goToIndstillinger: {
|
||||
id: 'goToIndstillinger',
|
||||
keys: 'g i',
|
||||
label: 'Indstillinger',
|
||||
category: 'navigation',
|
||||
scope: 'global',
|
||||
},
|
||||
|
||||
// Bogforing shortcuts (Kassekladde)
|
||||
newDraft: {
|
||||
id: 'newDraft',
|
||||
keys: 'mod+n',
|
||||
label: 'Ny kassekladde',
|
||||
category: 'bogforing',
|
||||
scope: 'page',
|
||||
},
|
||||
postDraft: {
|
||||
id: 'postDraft',
|
||||
keys: 'mod+enter',
|
||||
label: 'Bogfor kladde',
|
||||
category: 'bogforing',
|
||||
scope: 'page',
|
||||
},
|
||||
addLine: {
|
||||
id: 'addLine',
|
||||
keys: 'mod+shift+l',
|
||||
label: 'Tilføj linje',
|
||||
category: 'bogforing',
|
||||
scope: 'page',
|
||||
},
|
||||
saveDraft: {
|
||||
id: 'saveDraft',
|
||||
keys: 'mod+s',
|
||||
label: 'Gem kladde',
|
||||
category: 'bogforing',
|
||||
scope: 'page',
|
||||
},
|
||||
discardDraft: {
|
||||
id: 'discardDraft',
|
||||
keys: 'mod+backspace',
|
||||
label: 'Kasser kladde',
|
||||
category: 'bogforing',
|
||||
scope: 'page',
|
||||
},
|
||||
|
||||
syncBank: {
|
||||
id: 'syncBank',
|
||||
keys: 'mod+r',
|
||||
label: 'Synkroniser bank',
|
||||
description: 'Hent nye transaktioner fra bank',
|
||||
category: 'bank',
|
||||
scope: 'page',
|
||||
},
|
||||
|
||||
// Faktura shortcuts
|
||||
newInvoice: {
|
||||
id: 'newInvoice',
|
||||
keys: 'mod+i',
|
||||
label: 'Ny faktura',
|
||||
category: 'faktura',
|
||||
scope: 'page',
|
||||
},
|
||||
newCreditNote: {
|
||||
id: 'newCreditNote',
|
||||
keys: 'mod+shift+i',
|
||||
label: 'Ny kreditnota',
|
||||
category: 'faktura',
|
||||
scope: 'page',
|
||||
},
|
||||
newCustomer: {
|
||||
id: 'newCustomer',
|
||||
keys: 'mod+shift+k',
|
||||
label: 'Ny kunde',
|
||||
category: 'kunder',
|
||||
scope: 'page',
|
||||
},
|
||||
newProduct: {
|
||||
id: 'newProduct',
|
||||
keys: 'mod+shift+p',
|
||||
label: 'Nyt produkt',
|
||||
category: 'produkter',
|
||||
scope: 'page',
|
||||
},
|
||||
|
||||
// Bank shortcuts
|
||||
matchTransaction: {
|
||||
id: 'matchTransaction',
|
||||
keys: 'mod+m',
|
||||
label: 'Match transaktion',
|
||||
category: 'bank',
|
||||
scope: 'page',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get shortcuts by category
|
||||
*/
|
||||
export function getShortcutsByCategory(category: ShortcutCategory): ShortcutDefinition[] {
|
||||
return Object.values(shortcuts).filter(s => s.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shortcuts grouped by category
|
||||
*/
|
||||
export function getShortcutsGrouped(): Record<ShortcutCategory, ShortcutDefinition[]> {
|
||||
const grouped: Record<ShortcutCategory, ShortcutDefinition[]> = {
|
||||
global: [],
|
||||
navigation: [],
|
||||
bogforing: [],
|
||||
faktura: [],
|
||||
bank: [],
|
||||
kunder: [],
|
||||
produkter: [],
|
||||
};
|
||||
|
||||
Object.values(shortcuts).forEach(shortcut => {
|
||||
grouped[shortcut.category].push(shortcut);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format shortcut keys for display
|
||||
* Converts 'mod+k' to '⌘K' on Mac or 'Ctrl+K' on Windows
|
||||
*/
|
||||
export function formatShortcut(keys: string): string {
|
||||
return keys
|
||||
.split(' ')
|
||||
.map(part =>
|
||||
part
|
||||
.replace(/mod/g, modifierKey)
|
||||
.replace(/shift/g, shiftKey)
|
||||
.replace(/alt/g, altKey)
|
||||
.replace(/\+/g, '')
|
||||
.toUpperCase()
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format shortcut for tooltip display
|
||||
* Returns formatted string like "⌘K" or "Ctrl+K"
|
||||
*/
|
||||
export function formatShortcutForTooltip(shortcutId: string): string | null {
|
||||
const shortcut = shortcuts[shortcutId];
|
||||
if (!shortcut) return null;
|
||||
return formatShortcut(shortcut.keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Category display names in Danish
|
||||
*/
|
||||
export const categoryNames: Record<ShortcutCategory, string> = {
|
||||
global: 'Globale',
|
||||
navigation: 'Navigation',
|
||||
bogforing: 'Bogforing',
|
||||
faktura: 'Fakturering',
|
||||
bank: 'Bank',
|
||||
kunder: 'Kunder',
|
||||
produkter: 'Produkter',
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigation routes mapping
|
||||
*/
|
||||
export const navigationRoutes: Record<string, string> = {
|
||||
goToDashboard: '/',
|
||||
goToKassekladde: '/kassekladde',
|
||||
goToKontooversigt: '/kontooversigt',
|
||||
goToFakturaer: '/fakturaer',
|
||||
goToKreditnotaer: '/kreditnotaer',
|
||||
goToBankafstemning: '/bankafstemning',
|
||||
goToKunder: '/kunder',
|
||||
goToProdukter: '/produkter',
|
||||
goToMomsindberetning: '/momsindberetning',
|
||||
goToIndstillinger: '/indstillinger',
|
||||
};
|
||||
|
||||
/**
|
||||
* Command palette items
|
||||
*/
|
||||
export interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
category: 'navigation' | 'action' | 'recent';
|
||||
action: () => void;
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -5,17 +5,28 @@ 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 Eksport = lazy(() => import('./pages/Eksport'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const UserSettings = lazy(() => import('./pages/UserSettings'));
|
||||
const Admin = lazy(() => import('./pages/Admin'));
|
||||
|
||||
// Invoicing pages
|
||||
const Kunder = lazy(() => import('./pages/Kunder'));
|
||||
const Produkter = lazy(() => import('./pages/Produkter'));
|
||||
const Fakturaer = lazy(() => import('./pages/Fakturaer'));
|
||||
const Kreditnotaer = lazy(() => import('./pages/Kreditnotaer'));
|
||||
const Ordrer = lazy(() => import('./pages/Ordrer'));
|
||||
|
||||
// Loading fallback component
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div
|
||||
<Spin
|
||||
size="large"
|
||||
tip="Indlæser..."
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -24,8 +35,8 @@ function PageLoader() {
|
|||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Indlaeser..." />
|
||||
</div>
|
||||
<div style={{ minHeight: 200 }} />
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -38,18 +49,29 @@ export default function AppRoutes() {
|
|||
|
||||
{/* Accounting */}
|
||||
<Route path="/kassekladde" element={<Kassekladde />} />
|
||||
<Route path="/hurtig-bogforing" element={<HurtigBogforing />} />
|
||||
<Route path="/kontooversigt" element={<Kontooversigt />} />
|
||||
|
||||
{/* Bank */}
|
||||
<Route path="/bankafstemning" element={<Bankafstemning />} />
|
||||
|
||||
{/* Invoicing */}
|
||||
<Route path="/kunder" element={<Kunder />} />
|
||||
<Route path="/produkter" element={<Produkter />} />
|
||||
<Route path="/ordrer" element={<Ordrer />} />
|
||||
<Route path="/fakturaer" element={<Fakturaer />} />
|
||||
<Route path="/kreditnotaer" element={<Kreditnotaer />} />
|
||||
|
||||
{/* Reporting */}
|
||||
<Route path="/momsindberetning" element={<Momsindberetning />} />
|
||||
<Route path="/loenforstaelse" element={<Loenforstaelse />} />
|
||||
<Route path="/eksport" element={<Eksport />} />
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/indstillinger" element={<Settings />} />
|
||||
<Route path="/profil" element={<UserSettings />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
|
||||
{/* Fallback redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -1,364 +0,0 @@
|
|||
// 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue