books/frontend/src/pages/Dashboard.tsx

461 lines
16 KiB
TypeScript
Raw Normal View History

import { Row, Col, Card, Statistic, Typography, Space, Tag, Progress, Skeleton, Empty } from 'antd';
import {
BankOutlined,
RiseOutlined,
FallOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { Line, Pie, Column } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { usePeriodStore } from '@/stores/periodStore';
import { useAccountBalances } from '@/api/queries/accountQueries';
import { useInvoices } from '@/api/queries/invoiceQueries';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatDate } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
const { Title, Text } = Typography;
// Types for chart data
interface CashFlowDataPoint {
month: string;
inflow: number;
outflow: number;
balance: number;
}
interface ExpenseBreakdownItem {
category: string;
value: number;
}
interface RecentTransaction {
id: string;
date: string;
description: string;
amount: number;
type: 'income' | 'expense';
}
export default function Dashboard() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore();
// Define date interval
const periodStart = currentFiscalYear?.startDate || dayjs().startOf('year').format('YYYY-MM-DD');
const periodEnd = currentFiscalYear?.endDate || dayjs().endOf('year').format('YYYY-MM-DD');
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
activeCompany?.id,
currentFiscalYear ? {
startDate: dayjs(currentFiscalYear.startDate),
endDate: dayjs(currentFiscalYear.endDate),
} : undefined
);
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(activeCompany?.id);
const { data: vatReport, isLoading: vatLoading } = useVatReport(
activeCompany?.id,
periodStart,
periodEnd
);
const isLoading = balancesLoading || invoicesLoading || vatLoading;
// Calculate metrics from data
const cashAccounts = balances.filter(b => b.accountType === 'asset' && b.accountNumber.startsWith('10'));
const cashPosition = cashAccounts.reduce((sum, acc) => sum + acc.netChange, 0);
const receivableAccounts = balances.filter(b => b.accountNumber.startsWith('11'));
const accountsReceivable = receivableAccounts.reduce((sum, acc) => sum + acc.netChange, 0);
const payableAccounts = balances.filter(b => b.accountNumber.startsWith('20'));
const accountsPayable = Math.abs(payableAccounts.reduce((sum, acc) => sum + acc.netChange, 0));
const revenueAccounts = balances.filter(b => b.accountType === 'revenue');
const monthlyRevenue = Math.abs(revenueAccounts.reduce((sum, acc) => sum + acc.netChange, 0));
const expenseAccounts = balances.filter(b => ['expense', 'cogs', 'personnel'].includes(b.accountType));
const monthlyExpenses = expenseAccounts.reduce((sum, acc) => sum + acc.netChange, 0);
const vatLiability = vatReport?.netVat ?? 0;
const pendingInvoices = invoices.filter(i => i.status === 'sent' || i.status === 'issued').length;
const overdueInvoices = invoices.filter(i =>
(i.status === 'sent' || i.status === 'issued') &&
i.dueDate &&
dayjs(i.dueDate).isBefore(dayjs())
).length;
const metrics = {
cashPosition,
cashChange: 0, // Requires historical data
accountsReceivable,
arChange: 0,
accountsPayable,
apChange: 0,
monthlyRevenue,
revenueChange: 0,
monthlyExpenses,
expenseChange: 0,
vatLiability,
unreconciledCount: 0, // Requires bank reconciliation data
pendingInvoices,
overdueInvoices,
};
// Calculate expense breakdown from balances
const totalExpenses = expenseAccounts.reduce((sum, acc) => sum + Math.abs(acc.netChange), 0);
const calculatePercentage = (accountType: string, accountPrefix?: string): number => {
if (totalExpenses === 0) return 0;
let relevantAccounts = balances.filter(b => b.accountType === accountType);
if (accountPrefix && accountPrefix !== 'other') {
relevantAccounts = relevantAccounts.filter(b => b.accountNumber.startsWith(accountPrefix));
} else if (accountPrefix === 'other') {
// "Other" is everything not in specific categories
const specificPrefixes = ['61', '62', '64'];
relevantAccounts = relevantAccounts.filter(b =>
!specificPrefixes.some(prefix => b.accountNumber.startsWith(prefix))
);
}
const categoryTotal = relevantAccounts.reduce((sum, acc) => sum + Math.abs(acc.netChange), 0);
return Math.round((categoryTotal / totalExpenses) * 100);
};
const personnelExpenses = balances.filter(b => b.accountType === 'personnel');
const personnelTotal = personnelExpenses.reduce((sum, acc) => sum + Math.abs(acc.netChange), 0);
const personnelPercentage = totalExpenses > 0 ? Math.round((personnelTotal / totalExpenses) * 100) : 0;
const expenseBreakdown: ExpenseBreakdownItem[] = [
{ category: 'Personale', value: personnelPercentage },
{ category: 'Lokaler', value: calculatePercentage('expense', '61') },
{ category: 'IT & Software', value: calculatePercentage('expense', '62') },
{ category: 'Marketing', value: calculatePercentage('expense', '64') },
{ category: 'Andet', value: calculatePercentage('expense', 'other') },
].filter(e => e.value > 0);
// For cashFlowData and recentTransactions - show empty charts or loading
const cashFlowData: CashFlowDataPoint[] = [];
const recentTransactions: RecentTransaction[] = [];
// Loading state
if (isLoading) {
return (
<div>
<Skeleton active paragraph={{ rows: 1 }} />
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} sm={12} lg={6}>
<Card size="small"><Skeleton active paragraph={{ rows: 2 }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small"><Skeleton active paragraph={{ rows: 2 }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small"><Skeleton active paragraph={{ rows: 2 }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small"><Skeleton active paragraph={{ rows: 2 }} /></Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}><Skeleton active paragraph={{ rows: 6 }} /></Col>
<Col xs={24} lg={12}><Skeleton active paragraph={{ rows: 6 }} /></Col>
</Row>
</div>
);
}
const cashFlowConfig = {
data: cashFlowData,
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: expenseBreakdown,
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: cashFlowData.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={metrics.cashPosition}
precision={2}
prefix={<BankOutlined />}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag
color={metrics.cashChange >= 0 ? 'green' : 'red'}
icon={metrics.cashChange >= 0 ? <RiseOutlined /> : <FallOutlined />}
>
{metrics.cashChange >= 0 ? '+' : ''}
{(metrics.cashChange * 100).toFixed(1)}% denne maaned
</Tag>
</div>
</Card>
</Col>
{/* Accounts Receivable */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Tilgodehavender"
value={metrics.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">{metrics.pendingInvoices} afventer</Tag>
{metrics.overdueInvoices > 0 && (
<Tag color="red" icon={<WarningOutlined />}>
{metrics.overdueInvoices} forfaldne
</Tag>
)}
</Space>
</div>
</Card>
</Col>
{/* Accounts Payable */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Kreditorer"
value={metrics.accountsPayable}
precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
<Tag color={metrics.apChange >= 0 ? 'orange' : 'green'}>
{metrics.apChange >= 0 ? '+' : ''}
{(metrics.apChange * 100).toFixed(1)}% denne maaned
</Tag>
</div>
</Card>
</Col>
{/* VAT Liability */}
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="Moms til betaling"
value={metrics.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">
{cashFlowData.length > 0 ? (
<Line {...cashFlowConfig} />
) : (
<Empty description="Ingen pengestroemsdata tilgaengelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
{/* Revenue vs Expenses */}
<Col xs={24} lg={12}>
<Card title="Indtaegter vs. Udgifter" size="small">
{cashFlowData.length > 0 ? (
<Column {...revenueExpenseConfig} />
) : (
<Empty description="Ingen historiske data tilgaengelig endnu" style={{ height: 200 }} />
)}
</Card>
</Col>
</Row>
{/* Bottom Row */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* Expense Breakdown */}
<Col xs={24} lg={8}>
<Card title="Udgiftsfordeling" size="small">
{expenseBreakdown.length > 0 ? (
<Pie {...expenseConfig} />
) : (
<Empty description="Ingen udgiftsdata tilgaengelig" style={{ height: 200 }} />
)}
</Card>
</Col>
{/* Reconciliation Status */}
<Col xs={24} lg={8}>
<Card title="Afstemningsstatus" size="small">
<div style={{ padding: '16px 0' }}>
<Statistic
title="Uafstemte transaktioner"
value={metrics.unreconciledCount}
prefix={<FileTextOutlined />}
/>
<Progress
percent={metrics.unreconciledCount === 0 ? 100 : 75}
status="active"
strokeColor={accountingColors.balance}
style={{ marginTop: 16 }}
/>
<Text type="secondary">
{metrics.unreconciledCount === 0 ? '100% afstemt' : 'Bankafstemning ikke implementeret endnu'}
</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' }}>
{recentTransactions.length > 0 ? (
recentTransactions.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>
))
) : (
<Empty description="Ingen seneste transaktioner" style={{ padding: 24 }} />
)}
</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>{metrics.unreconciledCount} transaktioner klar til afstemning</Text>
</Space>
</Col>
<Col>
<Space>
<ClockCircleOutlined style={{ color: accountingColors.warning }} />
<Text>Momsindberetning forfalder om 14 dage</Text>
</Space>
</Col>
{metrics.overdueInvoices > 0 && (
<Col>
<Space>
<WarningOutlined style={{ color: accountingColors.debit }} />
<Text>{metrics.overdueInvoices} fakturaer er forfaldne</Text>
</Space>
</Col>
)}
</Row>
</Card>
</Col>
</Row>
</div>
);
}