feat(kpi): Nyckeltal as Instrumentbrädan — instrument panes, monthly bars, cost list (#1148)
* feat(kpi): Nyckeltal as Berattelsen (serif month hero + metric rail + quiet cost rows) The founder-picked concept variant: the month's result as a serif hero with a +/- delta sentence against the previous month, a single sage net area chart (income/expenses ride in the hover tooltip), and a hairline metric rail on the right still driven by the user's KPI preferences (Anpassa, formula tooltips, all seven definitions supported). The cost story renders as quiet bar rows: expense classes 4xxx-7xxx and top five suppliers. Replaces the four-tile + three-Recharts-card layout; KPIHeroCards, KPITrendChart, KPIExpenseMixChart and KPITopSuppliersChart are deleted. FyPicker replaces FiscalYearSelector; help behind ?. No API changes: everything derives from the existing KPIReport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(kpi): switch Nyckeltal to Instrumentbradan (founder pick v2) Berattelsen replaced by the instrument-pane grid on founder review: monthly result bars as plain SVG (muted months, latest in sage or terracotta when negative, compact endpoint label, per-bar tooltips) plus one bordered pane per visible preference KPI, with the receivables pane carrying a two-segment not-due/overdue strip. The cost story rows below are unchanged. Recharts leaves this page entirely (KPIResultChart deleted). Anpassa, formula tooltips and all seven KPI definitions still supported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(kpi): concept-true cost list and cash runway note Founder review against the concept: the report now carries topExpenseAccounts (top five BAS 4-7 accounts for the period, computed from the trial-balance rows the route already holds) and the page renders them as the full-width Storsta kostnaderna rows with account numbers, exactly like the concept. The Kassa pane derives its 'Tacker cirka N dagars utgifter' note from the period's daily burn so far. Class-composition and supplier columns leave the UI (data stays on the API). Route test extended for the new field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6911f657e9
commit
d0fb72dc63
@@ -2,32 +2,21 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { KPIHeroCards } from '@/components/kpi/KPIHeroCards'
|
||||
|
||||
// Recharts is ~180KB: defer the chart components so the KPI page shell and
|
||||
// hero cards render without waiting for the charting bundle.
|
||||
const chartFallback = () => <Skeleton className="h-[300px] w-full" />
|
||||
const KPITrendChart = dynamic(
|
||||
() => import('@/components/kpi/KPITrendChart').then((m) => m.KPITrendChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const KPIExpenseMixChart = dynamic(
|
||||
() => import('@/components/kpi/KPIExpenseMixChart').then((m) => m.KPIExpenseMixChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const KPITopSuppliersChart = dynamic(
|
||||
() => import('@/components/kpi/KPITopSuppliersChart').then((m) => m.KPITopSuppliersChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { KPIPanes, KPIBreakdown } from '@/components/kpi/KPIStory'
|
||||
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
|
||||
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
/**
|
||||
* Nyckeltal in the founder-picked "Instrumentbrädan" layout: a grid of
|
||||
* bordered instrument panes (monthly result bars + the preference-driven
|
||||
* KPIs) with the cost story as quiet rows below. Plain SVG bars: no
|
||||
* charting bundle on this page anymore.
|
||||
*/
|
||||
export default function KpiPage() {
|
||||
const t = useTranslations('kpi')
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>('')
|
||||
@@ -98,41 +87,39 @@ export default function KpiPage() {
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
help={
|
||||
<HelpPopover>
|
||||
<p>{t('help_text')}</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
action={
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
saving={isSavingPrefs}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
saving={isSavingPrefs}
|
||||
/>
|
||||
<FyPicker
|
||||
value={selectedPeriod || null}
|
||||
onChange={(id) => setSelectedPeriod(id || '')}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiscalYearSelector
|
||||
value={selectedPeriod || null}
|
||||
onChange={(id) => setSelectedPeriod(id || '')}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
<p>{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{error}</p>
|
||||
)}
|
||||
|
||||
{isLoadingReport && <LoadingSkeleton />}
|
||||
|
||||
{!isLoadingReport && !error && report && (
|
||||
<>
|
||||
<KPIHeroCards report={report} preferences={preferences} />
|
||||
{report.months.length > 0 && <KPITrendChart months={report.months} />}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<KPIExpenseMixChart composition={report.expenseComposition} />
|
||||
<KPITopSuppliersChart suppliers={report.topSuppliers} />
|
||||
</div>
|
||||
</>
|
||||
<div className="stagger-enter space-y-10">
|
||||
<KPIPanes report={report} preferences={preferences} />
|
||||
<KPIBreakdown report={report} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -140,34 +127,10 @@ export default function KpiPage() {
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-56" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-40 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,9 @@ describe('GET /api/reports/kpi', () => {
|
||||
],
|
||||
period: { start: '2026-01-01', end: '2026-03-31' },
|
||||
expenseComposition: { class4: 0, class5: 3000, class6: 0, class7: 0 },
|
||||
topExpenseAccounts: [
|
||||
{ account_number: '5010', account_name: 'Lokalhyra', total: 3000 },
|
||||
],
|
||||
topSuppliers: [
|
||||
{ supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 },
|
||||
{ supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 },
|
||||
|
||||
@@ -238,6 +238,20 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
{ class4: 0, class5: 0, class6: 0, class7: 0 }
|
||||
)
|
||||
|
||||
// Top expense accounts (classes 4-7) for the period: the concept's
|
||||
// "Största kostnaderna" list. Same debit-normal reading as the class
|
||||
// composition above.
|
||||
const topExpenseAccounts = (filteredTrialBalance ?? trialBalanceResult).rows
|
||||
.filter((r) => r.account_class >= 4 && r.account_class <= 7)
|
||||
.map((r) => ({
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
total: Math.round((r.closing_debit - r.closing_credit) * 100) / 100,
|
||||
}))
|
||||
.filter((r) => r.total > 0)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5)
|
||||
|
||||
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
|
||||
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
|
||||
// null): they would otherwise inflate a supplier's total with raw
|
||||
@@ -293,6 +307,7 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
|
||||
class6: Math.round(expenseComposition.class6 * 100) / 100,
|
||||
class7: Math.round(expenseComposition.class7 * 100) / 100,
|
||||
},
|
||||
topExpenseAccounts,
|
||||
topSuppliers,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface KPIExpenseMixChartProps {
|
||||
composition: {
|
||||
class4: number
|
||||
class5: number
|
||||
class6: number
|
||||
class7: number
|
||||
}
|
||||
}
|
||||
|
||||
const SEGMENT_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-4))',
|
||||
]
|
||||
|
||||
export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const { class4, class5, class6, class7 } = composition
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ name: t('expense_mix_class4'), value: class4 },
|
||||
{ name: t('expense_mix_class5'), value: class5 },
|
||||
{ name: t('expense_mix_class6'), value: class6 },
|
||||
{ name: t('expense_mix_class7'), value: class7 },
|
||||
].filter((s) => s.value > 0),
|
||||
[class4, class5, class6, class7, t]
|
||||
)
|
||||
|
||||
const total = class4 + class5 + class6 + class7
|
||||
const totalCompact =
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(total) + ' kr'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t('expense_mix_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-muted-foreground">
|
||||
{t('expense_mix_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={58}
|
||||
outerRadius={84}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={index} fill={SEGMENT_COLORS[index % SEGMENT_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), '']}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[180px] flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('expense_mix_total')}
|
||||
</span>
|
||||
<span
|
||||
className="font-display text-lg tabular-nums"
|
||||
title={formatCurrency(total)}
|
||||
>
|
||||
{totalCompact}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-[11px] text-muted-foreground">
|
||||
{chartData.map((seg, i) => (
|
||||
<div key={seg.name} className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-[2px]"
|
||||
style={{ backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length] }}
|
||||
/>
|
||||
<span>{seg.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
interface KPIHeroCardsProps {
|
||||
report: KPIReport
|
||||
preferences?: KPIPreferences
|
||||
}
|
||||
|
||||
function getKPISubtitleKey(
|
||||
report: KPIReport,
|
||||
id: string,
|
||||
): { key: string; args?: Record<string, string | number> } {
|
||||
switch (id) {
|
||||
case 'netResult':
|
||||
return { key: 'sub_netto' }
|
||||
case 'cashPosition':
|
||||
return { key: 'sub_likvida_medel' }
|
||||
case 'outstandingReceivables':
|
||||
if (report.overdueReceivables > 0) {
|
||||
return { key: 'sub_overdue', args: { amount: formatCurrency(report.overdueReceivables) } }
|
||||
}
|
||||
return { key: 'sub_utestaende' }
|
||||
case 'vatLiability':
|
||||
if (report.vatLiability > 0) return { key: 'sub_att_betala' }
|
||||
if (report.vatLiability < 0) return { key: 'sub_att_aterfa' }
|
||||
return { key: 'sub_jamnt' }
|
||||
case 'grossMargin':
|
||||
return { key: 'sub_av_intakter' }
|
||||
case 'expenseRatio':
|
||||
return { key: 'sub_av_intakter' }
|
||||
case 'avgPaymentDays':
|
||||
return { key: 'sub_snitt' }
|
||||
default:
|
||||
return { key: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function getKPIValue(report: KPIReport, id: string): number | null {
|
||||
switch (id) {
|
||||
case 'netResult': return report.netResult
|
||||
case 'cashPosition': return report.cashPosition
|
||||
case 'outstandingReceivables': return report.outstandingReceivables
|
||||
case 'vatLiability': return report.vatLiability
|
||||
case 'grossMargin': return report.grossMargin
|
||||
case 'expenseRatio': return report.expenseRatio
|
||||
case 'avgPaymentDays': return report.avgPaymentDays
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatKPIValue(value: number | null, format: string, id: string, daysSuffix: string): string {
|
||||
if (value === null) return '-'
|
||||
if (format === 'currency') {
|
||||
if (id === 'vatLiability') return formatCurrency(Math.abs(value))
|
||||
return formatCurrency(value)
|
||||
}
|
||||
if (format === 'percentage') return `${value}%`
|
||||
if (format === 'days') return `${value} ${daysSuffix}`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getValueColor(value: number | null, colorLogic: string): string {
|
||||
if (value === null) return 'text-muted-foreground'
|
||||
if (colorLogic === 'neutral') return ''
|
||||
if (colorLogic === 'positive-good') {
|
||||
return value >= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
|
||||
}
|
||||
if (colorLogic === 'negative-good') {
|
||||
return value <= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const prefs = preferences ?? getDefaultPreferences()
|
||||
|
||||
const visibleDefs = prefs.kpiOrder
|
||||
.map((id) => KPI_DEFINITIONS.find((d) => d.id === id))
|
||||
.filter((d) => d && prefs.visibleKpis.includes(d.id)) as typeof KPI_DEFINITIONS
|
||||
|
||||
if (visibleDefs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
{t('empty_no_kpis_chosen')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
visibleDefs.length <= 2
|
||||
? 'grid-cols-2'
|
||||
: visibleDefs.length === 3
|
||||
? 'grid-cols-2 md:grid-cols-3'
|
||||
: 'grid-cols-2 md:grid-cols-4'
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols} gap-4`}>
|
||||
{visibleDefs.map((def) => {
|
||||
const value = getKPIValue(report, def.id)
|
||||
const sub = getKPISubtitleKey(report, def.id)
|
||||
const formatted = formatKPIValue(value, def.format, def.id, t('value_days_suffix'))
|
||||
const color = getValueColor(value, def.colorLogic)
|
||||
const hasOverride =
|
||||
prefs.accountOverrides[def.id] &&
|
||||
prefs.accountOverrides[def.id].length > 0
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<p className="text-foreground/90">{t(`def_${def.id}_description`)}</p>
|
||||
<div>
|
||||
<span className="font-medium">{t('tooltip_formula')} </span>
|
||||
<span className="font-mono">{t(`def_${def.id}_formula`)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('tooltip_accounts')} </span>
|
||||
{t(`def_${def.id}_accounts`)}
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<div className="text-primary">
|
||||
<span className="font-medium">{t('tooltip_overrides')} </span>
|
||||
<span className="font-mono">
|
||||
{prefs.accountOverrides[def.id].join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card key={def.id}>
|
||||
<CardContent className="p-4">
|
||||
<InfoTooltip
|
||||
content={tooltipContent}
|
||||
side="top"
|
||||
maxWidth="320px"
|
||||
iconClassName="h-3 w-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{t(`def_${def.id}_label`)}</p>
|
||||
</InfoTooltip>
|
||||
<p
|
||||
className={`font-display text-2xl tabular-nums tracking-tight mt-2 ${color}`}
|
||||
>
|
||||
{formatted}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{sub.key ? t(sub.key, sub.args) : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
/**
|
||||
* Nyckeltal as the founder-picked "Instrumentbrädan" layout: a grid of
|
||||
* bordered instrument panes — monthly result bars first, then one pane per
|
||||
* visible KPI from the user's preferences — with the cost story as quiet
|
||||
* rows below. Pure presentation: everything derives from the existing
|
||||
* KPIReport.
|
||||
*/
|
||||
|
||||
const SAGE = 'hsl(155 25% 40%)'
|
||||
|
||||
type TFn = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function compactKr(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
||||
}
|
||||
|
||||
/** Shared pane chrome: hairline border, compact metric padding. */
|
||||
function Pane({
|
||||
title,
|
||||
annotation,
|
||||
tooltip,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
annotation?: React.ReactNode
|
||||
tooltip?: React.ReactNode
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const label = (
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border p-4', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{tooltip ? (
|
||||
<InfoTooltip content={tooltip} side="top" iconClassName="h-3 w-3">
|
||||
{label}
|
||||
</InfoTooltip>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{annotation && (
|
||||
<span className="text-[11px] text-muted-foreground">{annotation}</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Monthly net result as plain SVG bars: muted months, the latest in sage
|
||||
* (terracotta when negative), a compact value label on the endpoint. */
|
||||
function ResultBarsPane({ report }: { report: KPIReport }) {
|
||||
const t = useTranslations('kpi')
|
||||
const months = report.months
|
||||
if (months.length === 0) return null
|
||||
|
||||
const lastActive = (() => {
|
||||
for (let i = months.length - 1; i >= 0; i--) {
|
||||
const m = months[i]
|
||||
if (m.income !== 0 || m.expenses !== 0 || m.net !== 0) return i
|
||||
}
|
||||
return months.length - 1
|
||||
})()
|
||||
|
||||
const W = 320
|
||||
const H = 120
|
||||
const maxAbs = Math.max(...months.map((m) => Math.abs(m.net)), 1)
|
||||
// Baseline sits lower when no month is negative, so positive bars get the room.
|
||||
const hasNegative = months.some((m) => m.net < 0)
|
||||
const baseline = hasNegative ? H * 0.62 : H - 4
|
||||
const slot = W / months.length
|
||||
const barW = Math.min(30, slot * 0.62)
|
||||
|
||||
return (
|
||||
<Pane title={t('bars_title')} annotation={t('bars_unit')}>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H + 8}`}
|
||||
className="mt-3 h-auto w-full"
|
||||
role="img"
|
||||
aria-label={t('bars_aria', {
|
||||
month: months[lastActive].label,
|
||||
amount: formatCurrency(months[lastActive].net),
|
||||
})}
|
||||
>
|
||||
{months.map((m, i) => {
|
||||
const scaled = (Math.abs(m.net) / maxAbs) * (hasNegative ? H * 0.55 : H - 26)
|
||||
const h = m.net === 0 ? 2 : Math.max(3, scaled)
|
||||
const x = i * slot + (slot - barW) / 2
|
||||
const y = m.net >= 0 ? baseline - h : baseline
|
||||
const isLast = i === lastActive
|
||||
const fill =
|
||||
m.net < 0
|
||||
? isLast
|
||||
? 'hsl(11 45% 52%)'
|
||||
: 'hsl(11 45% 52% / 0.35)'
|
||||
: isLast
|
||||
? SAGE
|
||||
: 'hsl(var(--foreground) / 0.14)'
|
||||
return (
|
||||
<g key={m.label}>
|
||||
<rect x={x} y={y} width={barW} height={h} rx={3} fill={fill}>
|
||||
<title>{`${m.label}: ${formatCurrency(m.net)}`}</title>
|
||||
</rect>
|
||||
{isLast && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={m.net >= 0 ? y - 5 : y + h + 11}
|
||||
textAnchor="middle"
|
||||
style={{ font: '10.5px var(--font-body, ui-sans-serif)', fill: 'hsl(var(--muted-foreground))' }}
|
||||
>
|
||||
{compactKr(m.net)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-1 flex justify-between px-1 text-[10.5px] text-muted-foreground">
|
||||
{months.map((m) => (
|
||||
<span key={m.label}>{m.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</Pane>
|
||||
)
|
||||
}
|
||||
|
||||
type MetricPane = {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
note?: string
|
||||
tooltip?: React.ReactNode
|
||||
destructive?: boolean
|
||||
warn?: boolean
|
||||
aging?: { ok: number; overdue: number }
|
||||
}
|
||||
|
||||
/** Days of expenses the cash covers, from the period's daily burn so far. */
|
||||
function cashRunwayDays(report: KPIReport): number | null {
|
||||
if (report.cashPosition <= 0 || report.totalExpenses <= 0) return null
|
||||
const start = new Date(report.period.start).getTime()
|
||||
const end = Math.min(Date.now(), new Date(report.period.end).getTime())
|
||||
const elapsedDays = Math.max(1, Math.round((end - start) / 86_400_000))
|
||||
const dailyBurn = report.totalExpenses / elapsedDays
|
||||
if (dailyBurn <= 0) return null
|
||||
return Math.round(report.cashPosition / dailyBurn)
|
||||
}
|
||||
|
||||
function metricPane(id: string, report: KPIReport, t: TFn): MetricPane | null {
|
||||
const tooltip = (
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{t(`def_${id}_description`)}</p>
|
||||
<p className="font-mono">{t(`def_${id}_formula`)}</p>
|
||||
</div>
|
||||
)
|
||||
switch (id) {
|
||||
case 'cashPosition': {
|
||||
const days = cashRunwayDays(report)
|
||||
return {
|
||||
id,
|
||||
title: t('def_cashPosition_label'),
|
||||
value: formatCurrency(report.cashPosition),
|
||||
note:
|
||||
days !== null && days < 1000
|
||||
? t('cash_covers_days', { days })
|
||||
: t('sub_likvida_medel'),
|
||||
tooltip,
|
||||
destructive: report.cashPosition < 0,
|
||||
}
|
||||
}
|
||||
case 'vatLiability':
|
||||
return {
|
||||
id,
|
||||
title: t('def_vatLiability_label'),
|
||||
value: formatCurrency(Math.abs(report.vatLiability)),
|
||||
note:
|
||||
report.vatLiability > 0
|
||||
? t('sub_att_betala')
|
||||
: report.vatLiability < 0
|
||||
? t('sub_att_aterfa')
|
||||
: t('sub_jamnt'),
|
||||
tooltip,
|
||||
}
|
||||
case 'outstandingReceivables': {
|
||||
const overdue = report.overdueReceivables
|
||||
const ok = Math.max(0, report.outstandingReceivables - overdue)
|
||||
return {
|
||||
id,
|
||||
title: t('def_outstandingReceivables_label'),
|
||||
value: formatCurrency(report.outstandingReceivables),
|
||||
note:
|
||||
overdue > 0
|
||||
? t('sub_overdue', { amount: formatCurrency(overdue) })
|
||||
: t('sub_utestaende'),
|
||||
tooltip,
|
||||
warn: overdue > 0,
|
||||
aging:
|
||||
report.outstandingReceivables > 0 ? { ok, overdue } : undefined,
|
||||
}
|
||||
}
|
||||
case 'grossMargin':
|
||||
return report.grossMargin === null
|
||||
? null
|
||||
: {
|
||||
id,
|
||||
title: t('def_grossMargin_label'),
|
||||
value: `${report.grossMargin}%`,
|
||||
note: t('sub_av_intakter'),
|
||||
tooltip,
|
||||
}
|
||||
case 'expenseRatio':
|
||||
return report.expenseRatio === null
|
||||
? null
|
||||
: {
|
||||
id,
|
||||
title: t('def_expenseRatio_label'),
|
||||
value: `${report.expenseRatio}%`,
|
||||
note: t('sub_av_intakter'),
|
||||
tooltip,
|
||||
}
|
||||
case 'avgPaymentDays':
|
||||
return report.avgPaymentDays === null
|
||||
? null
|
||||
: {
|
||||
id,
|
||||
title: t('def_avgPaymentDays_label'),
|
||||
value: `${report.avgPaymentDays} ${t('value_days_suffix')}`,
|
||||
note: t('sub_snitt'),
|
||||
tooltip,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The instrument grid: result bars + one pane per visible preference KPI. */
|
||||
export function KPIPanes({
|
||||
report,
|
||||
preferences,
|
||||
}: {
|
||||
report: KPIReport
|
||||
preferences: KPIPreferences
|
||||
}) {
|
||||
const t = useTranslations('kpi')
|
||||
|
||||
const orderedIds = preferences.kpiOrder.filter(
|
||||
(id) => preferences.visibleKpis.includes(id) && id !== 'netResult',
|
||||
)
|
||||
const panes = orderedIds
|
||||
.map((id) => metricPane(id, report, t as TFn))
|
||||
.filter(Boolean) as MetricPane[]
|
||||
|
||||
const total = (p: MetricPane) => (p.aging ? p.aging.ok + p.aging.overdue : 0)
|
||||
|
||||
return (
|
||||
<div className="grid items-stretch gap-4 sm:grid-cols-2">
|
||||
<ResultBarsPane report={report} />
|
||||
{panes.map((pane) => (
|
||||
<Pane key={pane.id} title={pane.title} tooltip={pane.tooltip}>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 font-display text-2xl tabular-nums tracking-tight',
|
||||
pane.destructive && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
{pane.value}
|
||||
</p>
|
||||
{pane.aging && total(pane) > 0 && (
|
||||
<div
|
||||
className="mt-3 flex h-1.5 gap-[2px] overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t('aging_aria', {
|
||||
ok: formatCurrency(pane.aging.ok),
|
||||
overdue: formatCurrency(pane.aging.overdue),
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className="rounded-full bg-[hsl(155_25%_40%_/_0.45)]"
|
||||
style={{ width: `${(pane.aging.ok / total(pane)) * 100}%` }}
|
||||
/>
|
||||
<span
|
||||
className="rounded-full bg-[hsl(38_65%_52%_/_0.75)]"
|
||||
style={{ width: `${(pane.aging.overdue / total(pane)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pane.note && (
|
||||
<p className={cn('mt-2 text-xs leading-5 text-muted-foreground', pane.warn && 'text-attn')}>
|
||||
{pane.note}
|
||||
</p>
|
||||
)}
|
||||
</Pane>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Quiet bar row shared by the two breakdown lists. */
|
||||
function BreakdownRow({
|
||||
label,
|
||||
amount,
|
||||
max,
|
||||
prefix,
|
||||
}: {
|
||||
label: string
|
||||
amount: number
|
||||
max: number
|
||||
prefix?: string
|
||||
}) {
|
||||
const width = max > 0 ? Math.max(3, Math.round((amount / max) * 96)) : 3
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 py-3 text-[13px] last:border-b-0">
|
||||
{prefix && (
|
||||
<span className="w-8 shrink-0 font-mono text-[11px] text-muted-foreground">{prefix}</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span
|
||||
className="h-[3px] shrink-0 rounded-full bg-foreground/15"
|
||||
style={{ width: `${width}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-28 shrink-0 text-right tabular-nums">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The cost story (concept "Största kostnaderna"): the period's largest
|
||||
* expense accounts as quiet bar rows, full width. */
|
||||
export function KPIBreakdown({ report }: { report: KPIReport }) {
|
||||
const t = useTranslations('kpi')
|
||||
const accounts = report.topExpenseAccounts ?? []
|
||||
if (accounts.length === 0) return null
|
||||
const max = Math.max(...accounts.map((a) => a.total), 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-3 px-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('costs_title')}
|
||||
</h2>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
<BreakdownRow
|
||||
key={a.account_number}
|
||||
prefix={a.account_number}
|
||||
label={a.account_name}
|
||||
amount={a.total}
|
||||
max={max}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface KPITopSuppliersChartProps {
|
||||
suppliers: { supplier_id: string; supplier_name: string; total: number }[]
|
||||
}
|
||||
|
||||
const BAR_COLOR = 'hsl(var(--chart-1))'
|
||||
|
||||
export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t('top_suppliers_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{suppliers.length === 0 ? (
|
||||
<div className="flex h-[200px] items-center justify-center text-center text-sm text-muted-foreground px-4">
|
||||
{t('top_suppliers_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, suppliers.length * 32)}>
|
||||
<BarChart
|
||||
data={suppliers}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 16, left: 0, bottom: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) =>
|
||||
new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)
|
||||
}
|
||||
tick={{ fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="supplier_name"
|
||||
tick={{ fontSize: 11 }}
|
||||
width={120}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), t('top_suppliers_spend')]}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
cursor={{ fill: 'hsl(var(--muted) / 0.4)' }}
|
||||
/>
|
||||
<Bar dataKey="total" radius={[0, 4, 4, 0]}>
|
||||
{suppliers.map((s) => (
|
||||
<Cell key={s.supplier_id} fill={BAR_COLOR} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
Line,
|
||||
ComposedChart,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface KPITrendChartProps {
|
||||
months: { label: string; income: number; expenses: number; net: number }[]
|
||||
}
|
||||
|
||||
export function KPITrendChart({ months }: KPITrendChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
if (months.length === 0) return null
|
||||
|
||||
const seriesLabel = (key: string) =>
|
||||
key === 'income' ? t('trend_legend_income')
|
||||
: key === 'expenses' ? t('trend_legend_expenses')
|
||||
: t('trend_legend_net')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t('trend_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart
|
||||
data={months}
|
||||
margin={{ top: 5, right: 10, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
tickFormatter={(v) =>
|
||||
new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)
|
||||
}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
formatCurrency(Number(value)),
|
||||
seriesLabel(String(name)),
|
||||
]}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
/>
|
||||
<Legend formatter={(value: string) => seriesLabel(value)} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.15}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.15}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="net"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+8
-1
@@ -4510,7 +4510,14 @@
|
||||
"settings_customize_accounts": "Customize accounts",
|
||||
"settings_account_hint": "Enter account numbers separated by commas (e.g. {example})",
|
||||
"settings_reset_field": "Reset to default",
|
||||
"settings_reset_all": "Reset all"
|
||||
"settings_reset_all": "Reset all",
|
||||
"help_text": "The key figures are computed from the books for the selected fiscal year. You control the panes via Customize; expense classes and suppliers sum the period's spending.",
|
||||
"costs_title": "Largest expenses",
|
||||
"bars_title": "Result per month",
|
||||
"bars_unit": "kr",
|
||||
"bars_aria": "Result per month; latest {month} at {amount}",
|
||||
"aging_aria": "Receivables: {ok} not due, {overdue} overdue",
|
||||
"cash_covers_days": "Covers roughly {days} days of expenses"
|
||||
},
|
||||
"company_switcher": {
|
||||
"company_label": "Company",
|
||||
|
||||
+8
-1
@@ -4510,7 +4510,14 @@
|
||||
"settings_customize_accounts": "Anpassa konton",
|
||||
"settings_account_hint": "Ange kontonummer separerade med komma (t.ex. {example})",
|
||||
"settings_reset_field": "Återställ till standard",
|
||||
"settings_reset_all": "Återställ allt"
|
||||
"settings_reset_all": "Återställ allt",
|
||||
"help_text": "Nyckeltalen räknas ur bokföringen för det valda räkenskapsåret. Panelerna styr du själv via Anpassa; kostnadsslag och leverantörer summerar periodens utgifter.",
|
||||
"costs_title": "Största kostnaderna",
|
||||
"bars_title": "Resultat per månad",
|
||||
"bars_unit": "kr",
|
||||
"bars_aria": "Resultat per månad; senast {month} med {amount}",
|
||||
"aging_aria": "Kundfordringar: {ok} ej förfallna, {overdue} förfallna",
|
||||
"cash_covers_days": "Täcker cirka {days} dagars utgifter"
|
||||
},
|
||||
"company_switcher": {
|
||||
"company_label": "Företag",
|
||||
|
||||
@@ -3577,6 +3577,8 @@ export interface KPIReport {
|
||||
class6: number
|
||||
class7: number
|
||||
}
|
||||
/** Top expense accounts (BAS classes 4-7) for the period, largest first. */
|
||||
topExpenseAccounts: { account_number: string; account_name: string; total: number }[]
|
||||
topSuppliers: { supplier_id: string; supplier_name: string; total: number }[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user