feat: Add 4 major features to BOC Dashboard

1. Balance Sheet Chart — grafisk visning med progress bars
   - Assets (grön), Liabilities (röd), Equity (blå)
   - Balance check längst ner

2. Period-väljare — dropdown för månadsval
   - 2026-02, 2026-03, 2026-04

3. Drill-down — klicka på konto för transaktionshistorik
   - Modal med Date, Description, Debit, Credit, Balance
   - Mock data (ersätt med API-anrop)

4. Export-knappar — SIE4, PDF, CSV
   - Download-funktionalitet
   - Loading-tillstånd

Alla komponenter bygger och integreras i DashboardPage.
This commit is contained in:
Bernt
2026-07-29 20:17:34 +00:00
parent f793fc46a4
commit dd145bc3d9
9 changed files with 791 additions and 467 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-ieB3Tw4c.js"></script> <script type="module" crossorigin src="/assets/index-BFxN1zn5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oHhMI01Z.css"> <link rel="stylesheet" crossorigin href="/assets/index-CU2pE4Ox.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -0,0 +1,105 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
interface Transaction {
id: string
date: string
description: string
debit: number
credit: number
balance: number
}
interface AccountDetailModalProps {
accountCode: string
accountName: string
onClose: () => void
}
export function AccountDetailModal({ accountCode, accountName, onClose }: AccountDetailModalProps) {
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Mock data - replace with actual API call
const mockTransactions: Transaction[] = [
{ id: '1', date: '2026-02-01', description: 'Opening balance', debit: 0, credit: 0, balance: 44313 },
{ id: '2', date: '2026-02-15', description: 'Purchase - Dell laptops', debit: 25000, credit: 0, balance: 69313 },
{ id: '3', date: '2026-02-20', description: 'Depreciation', debit: 0, credit: 25000, balance: 44313 },
]
setTransactions(mockTransactions)
setLoading(false)
}, [accountCode])
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-lg w-full max-w-4xl max-h-[80vh] overflow-auto">
<Card>
<CardHeader
title={`${accountCode} - ${accountName}`}
subtitle="Transaction History"
action={
<button
onClick={onClose}
className="text-sm text-primary hover:underline"
>
Close
</button>
}
/>
<div className="p-6">
{loading ? (
<div className="text-center py-8">Loading...</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Debit</TableHead>
<TableHead className="text-right">Credit</TableHead>
<TableHead className="text-right">Balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>{tx.date}</TableCell>
<TableCell>{tx.description}</TableCell>
<TableCell className="text-right text-success">
{tx.debit > 0 ? formatCurrency(tx.debit) : '-'}
</TableCell>
<TableCell className="text-right text-danger">
{tx.credit > 0 ? formatCurrency(tx.credit) : '-'}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(tx.balance)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
</div>
</div>
)
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
+116
View File
@@ -0,0 +1,116 @@
import { useMemo } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
interface BalanceSheetData {
assets: { code: string; name: string; balance: number }[]
liabilities: { code: string; name: string; balance: number }[]
equity: { code: string; name: string; balance: number }[]
total_assets: number
total_liabilities: number
total_equity: number
}
export function BalanceSheetChart({ data }: { data: BalanceSheetData }) {
const maxValue = useMemo(() => {
return Math.max(
Math.abs(data.total_assets),
Math.abs(data.total_liabilities),
Math.abs(data.total_equity),
1
)
}, [data])
const barWidth = (value: number) => {
return `${(Math.abs(value) / maxValue) * 100}%`
}
return (
<Card>
<CardHeader title="Balance Sheet (Balansräkning)" subtitle="Assets vs Liabilities & Equity" />
<div className="p-6 space-y-6">
{/* Assets */}
<div>
<div className="flex justify-between mb-2">
<span className="font-semibold text-success">Assets (Tillgångar)</span>
<span className="font-bold">{formatCurrency(data.total_assets)}</span>
</div>
<div className="w-full bg-surface-2 rounded-full h-4">
<div
className="bg-success h-4 rounded-full transition-all"
style={{ width: barWidth(data.total_assets) }}
/>
</div>
<div className="mt-2 space-y-1">
{data.assets.slice(0, 5).map((a) => (
<div key={a.code} className="flex justify-between text-sm">
<span>{a.name}</span>
<span>{formatCurrency(a.balance)}</span>
</div>
))}
</div>
</div>
{/* Liabilities */}
<div>
<div className="flex justify-between mb-2">
<span className="font-semibold text-danger">Liabilities (Skulder)</span>
<span className="font-bold">{formatCurrency(data.total_liabilities)}</span>
</div>
<div className="w-full bg-surface-2 rounded-full h-4">
<div
className="bg-danger h-4 rounded-full transition-all"
style={{ width: barWidth(data.total_liabilities) }}
/>
</div>
<div className="mt-2 space-y-1">
{data.liabilities.slice(0, 5).map((l) => (
<div key={l.code} className="flex justify-between text-sm">
<span>{l.name}</span>
<span>{formatCurrency(l.balance)}</span>
</div>
))}
</div>
</div>
{/* Equity */}
{data.equity && data.equity.length > 0 && (
<div>
<div className="flex justify-between mb-2">
<span className="font-semibold text-primary">Equity (Eget kapital)</span>
<span className="font-bold">{formatCurrency(data.total_equity)}</span>
</div>
<div className="w-full bg-surface-2 rounded-full h-4">
<div
className="bg-primary h-4 rounded-full transition-all"
style={{ width: barWidth(data.total_equity) }}
/>
</div>
</div>
)}
{/* Balance check */}
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<span className="text-sm text-muted">Balance check:</span>
<span className={`font-mono ${
Math.abs(data.total_assets + data.total_liabilities + data.total_equity) < 0.01
? 'text-success'
: 'text-danger'
}`}>
{formatCurrency(data.total_assets + data.total_liabilities + data.total_equity)}
</span>
</div>
</div>
</div>
</Card>
)
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
interface ExportButtonsProps {
period: string
}
export function ExportButtons({ period }: ExportButtonsProps) {
const [exporting, setExporting] = useState<string | null>(null)
const handleExport = async (format: string) => {
setExporting(format)
try {
const response = await fetch(`/api/v1/finance/export?period=${period}&format=${format}`)
if (!response.ok) throw new Error('Export failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `ledger-${period}.${format.toLowerCase()}`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
console.error('Export error:', err)
alert(`Failed to export ${format}`)
} finally {
setExporting(null)
}
}
return (
<Card>
<CardHeader title="Export Reports" subtitle="Download ledger data in various formats" />
<div className="p-6 flex gap-4">
<button
onClick={() => handleExport('SIE4')}
disabled={exporting === 'SIE4'}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark disabled:opacity-50"
>
{exporting === 'SIE4' ? 'Exporting...' : 'Export SIE4'}
</button>
<button
onClick={() => handleExport('PDF')}
disabled={exporting === 'PDF'}
className="px-4 py-2 bg-danger text-white rounded hover:bg-danger-dark disabled:opacity-50"
>
{exporting === 'PDF' ? 'Exporting...' : 'Export PDF'}
</button>
<button
onClick={() => handleExport('CSV')}
disabled={exporting === 'CSV'}
className="px-4 py-2 bg-success text-white rounded hover:bg-success-dark disabled:opacity-50"
>
{exporting === 'CSV' ? 'Exporting...' : 'Export CSV'}
</button>
</div>
</Card>
)
}
+48 -7
View File
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'
import { KPICard } from '@/components/KPICard' import { KPICard } from '@/components/KPICard'
import { RevenueChart } from '@/components/RevenueChart' import { RevenueChart } from '@/components/RevenueChart'
import { ActivityFeed } from '@/components/ActivityFeed' import { ActivityFeed } from '@/components/ActivityFeed'
import { BalanceSheetChart } from '@/components/BalanceSheetChart'
import { AccountDetailModal } from '@/components/AccountDetailModal'
import { ExportButtons } from '@/components/ExportButtons'
import { Card, CardHeader } from '@/components/ui/Card' import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton' import { Skeleton } from '@/components/ui/Skeleton'
import { import {
@@ -67,6 +70,9 @@ export function DashboardPage() {
const [ledgerEquity, setLedgerEquity] = useState(0) const [ledgerEquity, setLedgerEquity] = useState(0)
const [netIncome, setNetIncome] = useState(0) const [netIncome, setNetIncome] = useState(0)
const [ledgerAccounts, setLedgerAccounts] = useState<LedgerAccount[]>([]) const [ledgerAccounts, setLedgerAccounts] = useState<LedgerAccount[]>([])
const [balanceSheetData, setBalanceSheetData] = useState<any>(null)
const [selectedPeriod, setSelectedPeriod] = useState('2026-02')
const [selectedAccount, setSelectedAccount] = useState<{code: string, name: string} | null>(null)
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -99,6 +105,7 @@ export function DashboardPage() {
setLedgerAssets(ledgerBs.total_assets || 0) setLedgerAssets(ledgerBs.total_assets || 0)
setLedgerLiabilities(ledgerBs.total_liabilities || 0) setLedgerLiabilities(ledgerBs.total_liabilities || 0)
setLedgerEquity(ledgerBs.total_equity || 0) setLedgerEquity(ledgerBs.total_equity || 0)
setBalanceSheetData(ledgerBs)
} }
if (ledgerIs) { if (ledgerIs) {
setNetIncome(ledgerIs.net_income || 0) setNetIncome(ledgerIs.net_income || 0)
@@ -215,6 +222,20 @@ export function DashboardPage() {
))} ))}
</div> </div>
{/* Period Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Period:</label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="border rounded px-3 py-1 text-sm"
>
<option value="2026-02">2026-02</option>
<option value="2026-03">2026-03</option>
<option value="2026-04">2026-04</option>
</select>
</div>
{/* Ledger KPI Cards */} {/* Ledger KPI Cards */}
<div className="mt-8"> <div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Ledger (Bokföring)</h2> <h2 className="text-lg font-semibold mb-4">Ledger (Bokföring)</h2>
@@ -225,6 +246,11 @@ export function DashboardPage() {
</div> </div>
</div> </div>
{/* Balance Sheet Chart */}
{balanceSheetData && (
<BalanceSheetChart data={balanceSheetData} />
)}
{/* Infrastructure Health — Grafana Integration */} {/* Infrastructure Health — Grafana Integration */}
<Card> <Card>
<CardHeader <CardHeader
@@ -303,15 +329,18 @@ export function DashboardPage() {
) : ( ) : (
ledgerAccounts.map((account) => ( ledgerAccounts.map((account) => (
<TableRow key={account.code}> <TableRow key={account.code}>
<TableCell className="font-medium">{account.code}</TableCell> <TableCell colSpan={4} className="p-0">
<TableCell>{account.name}</TableCell> <div
<TableCell> className="flex items-center px-4 py-3 cursor-pointer hover:bg-surface-2"
<Badge>{account.account_type}</Badge> onClick={() => setSelectedAccount({ code: account.code, name: account.name })}
</TableCell> >
<TableCell className="text-right"> <span className="font-medium w-16">{account.code}</span>
<span className={account.balance >= 0 ? 'text-success' : 'text-danger'}> <span className="flex-1">{account.name}</span>
<span className="w-24"><Badge>{account.account_type}</Badge></span>
<span className={`w-32 text-right ${account.balance >= 0 ? 'text-success' : 'text-danger'}`}>
{formatCurrency(account.balance)} {formatCurrency(account.balance)}
</span> </span>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -385,6 +414,18 @@ export function DashboardPage() {
</div> </div>
</Card> </Card>
{/* Export Buttons */}
<ExportButtons period={selectedPeriod} />
{/* Account Detail Modal */}
{selectedAccount && (
<AccountDetailModal
accountCode={selectedAccount.code}
accountName={selectedAccount.name}
onClose={() => setSelectedAccount(null)}
/>
)}
{/* Recent Deals */} {/* Recent Deals */}
<Card> <Card>
<CardHeader <CardHeader