'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, 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 ( {`${m.label}: ${formatCurrency(m.net)}`} {isLast && ( = 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)} )} ) })}
{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}
) } /** 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) => ( ))}
) }