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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user