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:
Vendored
+455
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
-455
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user