-
- {[1, 2, 3, 4].map((i) => (
-
-
-
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
- {[1, 2].map((i) => (
-
-
-
-
-
-
- ))}
-
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
)
}
diff --git a/app/api/reports/kpi/__tests__/route.test.ts b/app/api/reports/kpi/__tests__/route.test.ts
index fcdad22b..17b5aa10 100644
--- a/app/api/reports/kpi/__tests__/route.test.ts
+++ b/app/api/reports/kpi/__tests__/route.test.ts
@@ -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 },
diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts
index a5555fb8..5b5885fb 100644
--- a/app/api/reports/kpi/route.ts
+++ b/app/api/reports/kpi/route.ts
@@ -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,
}
diff --git a/components/kpi/KPIExpenseMixChart.tsx b/components/kpi/KPIExpenseMixChart.tsx
deleted file mode 100644
index f5be1227..00000000
--- a/components/kpi/KPIExpenseMixChart.tsx
+++ /dev/null
@@ -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 (
-
-
- {t('expense_mix_title')}
-
-
- {chartData.length === 0 ? (
-
- {t('expense_mix_empty')}
-
- ) : (
-
-
-
-
- {chartData.map((_, index) => (
- |
- ))}
-
- [formatCurrency(Number(value)), '']}
- contentStyle={{
- fontSize: '12px',
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- backgroundColor: 'hsl(var(--card))',
- }}
- />
-
-
-
-
- {t('expense_mix_total')}
-
-
- {totalCompact}
-
-
-
- {chartData.map((seg, i) => (
-
-
- {seg.name}
-
- ))}
-
-
- )}
-
-
- )
-}
diff --git a/components/kpi/KPIHeroCards.tsx b/components/kpi/KPIHeroCards.tsx
deleted file mode 100644
index b97d6da6..00000000
--- a/components/kpi/KPIHeroCards.tsx
+++ /dev/null
@@ -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
} {
- 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 (
-
-
- {t('empty_no_kpis_chosen')}
-
-
- )
- }
-
- 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 (
-
- {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 = (
-
-
{t(`def_${def.id}_description`)}
-
- {t('tooltip_formula')}
- {t(`def_${def.id}_formula`)}
-
-
- {t('tooltip_accounts')}
- {t(`def_${def.id}_accounts`)}
-
- {hasOverride && (
-
- {t('tooltip_overrides')}
-
- {prefs.accountOverrides[def.id].join(', ')}
-
-
- )}
-
- )
-
- return (
-
-
-
- {t(`def_${def.id}_label`)}
-
-
- {formatted}
-
-
- {sub.key ? t(sub.key, sub.args) : ''}
-
-
-
- )
- })}
-
- )
-}
diff --git a/components/kpi/KPIStory.tsx b/components/kpi/KPIStory.tsx
new file mode 100644
index 00000000..77182863
--- /dev/null
+++ b/components/kpi/KPIStory.tsx
@@ -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
+
+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 = (
+
+ {title}
+
+ )
+ return (
+
+
+ {tooltip ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+ {annotation && (
+ {annotation}
+ )}
+
+ {children}
+
+ )
+}
+
+/** 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 (
+
+
+
+ {months.map((m) => (
+ {m.label}
+ ))}
+
+
+ )
+}
+
+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 = (
+
+
{t(`def_${id}_description`)}
+
{t(`def_${id}_formula`)}
+
+ )
+ 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 (
+
+
+ {panes.map((pane) => (
+
+
+ {pane.value}
+
+ {pane.aging && total(pane) > 0 && (
+
+
+
+
+ )}
+ {pane.note && (
+
+ {pane.note}
+
+ )}
+
+ ))}
+
+ )
+}
+
+/** 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 (
+
+ {prefix && (
+ {prefix}
+ )}
+ {label}
+
+ {formatCurrency(amount)}
+
+ )
+}
+
+/** 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 (
+
+
+
+ {t('costs_title')}
+
+
+
+ {accounts.map((a) => (
+
+ ))}
+
+ )
+}
diff --git a/components/kpi/KPITopSuppliersChart.tsx b/components/kpi/KPITopSuppliersChart.tsx
deleted file mode 100644
index 662f3050..00000000
--- a/components/kpi/KPITopSuppliersChart.tsx
+++ /dev/null
@@ -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 (
-
-
- {t('top_suppliers_title')}
-
-
- {suppliers.length === 0 ? (
-
- {t('top_suppliers_empty')}
-
- ) : (
-
-
-
- new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)
- }
- tick={{ fontSize: 11 }}
- axisLine={false}
- tickLine={false}
- />
-
- [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)' }}
- />
-
- {suppliers.map((s) => (
- |
- ))}
-
-
-
- )}
-
-
- )
-}
diff --git a/components/kpi/KPITrendChart.tsx b/components/kpi/KPITrendChart.tsx
deleted file mode 100644
index fac0712d..00000000
--- a/components/kpi/KPITrendChart.tsx
+++ /dev/null
@@ -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 (
-
-
- {t('trend_title')}
-
-
-
-
-
-
-
- new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)
- }
- tick={{ fontSize: 11 }}
- />
- [
- formatCurrency(Number(value)),
- seriesLabel(String(name)),
- ]}
- contentStyle={{
- fontSize: '12px',
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- backgroundColor: 'hsl(var(--card))',
- }}
- />
-
-
-
-
- )
-}
diff --git a/messages/en.json b/messages/en.json
index ee3af37c..67038bc8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/sv.json b/messages/sv.json
index 462eee90..32d2fbaa 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -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",
diff --git a/types/index.ts b/types/index.ts
index 3b09e3ba..93ad80da 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -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 }[]
}