Files
accounted/app/(dashboard)/kpi/page.tsx
T
Jakob WennbergandClaude Fable 5 da859d7236 refactor(ui): design-system consistency pass over dense pages + i18n de-bloat (#961)
* refactor(ui): normalize dense pages to the locked design system

Sweep of the info-dense surfaces against .claude/rules/design.md; no
behavior changes, classNames and primitive adoption only.

- Replace hand-rolled h1s with PageHeader (import, suppliers, kpi,
  salary/employees, skattekonto, settings layout) and drop the one
  double title (SalarySettingsContent under the settings h1)
- Replace hand-rolled empty states with EmptyState (skattekonto,
  banking/api-keys/oauth/counterparty settings) and hand-rolled
  pulse divs with Skeleton (deadlines, report view loaders)
- Remove semantic colors used as chrome: amber/emerald banners in
  AGIPanel and SkatteverketPanel, success/warning tints in
  kassaflodesanalys, arsredovisning and import become neutral
  surfaces with the tint kept on the icon only
- Full-opacity borders everywhere (border-border/30-60,
  border-destructive/20-40, border-foreground/30, text-destructive/80)
- Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to
  scale values); KPI metric tiles p-6 to p-4 per the tile rule
- Remove the mobile Select that duplicated the invoices status Tabs
  (TabsList already scrolls horizontally); single Tabs now serves
  both breakpoints
- supplier-invoices: shared formatCurrency instead of a local
  formatAmount helper; skattekonto: formatDate/formatDateLong/
  formatDateTime instead of raw dates and toLocaleString
- arsredovisning flerarsoversikt converted to the Table primitive
  with right-aligned tabular-nums cells
- Settings: CardTitle text-base on section cards, one heading idiom
  in AccountSettingsContent, h3 to h2 in CompanyProfileView

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(i18n): trim text bloat and fix an untranslated sv string

- Fix invoice_credit.create_failed_fallback: sv catalog carried the
  English "Failed to create credit note"; now "Kunde inte skapa
  kreditfaktura". Translate new_user_checklist.step3_title in en
- Drop descriptions that paraphrase their own title (design.md
  forbidden pattern): invoice_detail.credited_description,
  invoice_credit.original_card_description, invoice_editor
  customer/notes card descriptions (keys deleted from both
  catalogs, zero remaining usages); the transaction booking
  DialogDescription becomes sr-only so screen readers keep it
- Trim redundant sentences from settings_salary.info_payroll_scope,
  settings_backup.intro, ext_cloud_backup_long_description,
  settings.name_description, salary_payments.open_payments_note and
  shorten invoice_credit.reason_card_description; statutory BFL/tax
  prose untouched
- Normalize toast punctuation (dimensions/self_billing
  created_description lose the trailing period like their siblings)
- common.delete "Radera" to "Ta bort" (zero live call sites; Radera
  stays reserved for irreversible account/company deletion)

Catalogs verified key-identical (4795 keys each) and JSON-parseable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:56:30 +02:00

174 lines
5.4 KiB
TypeScript

'use client'
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 { 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 { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
import type { KPIReport, KPIPreferences } from '@/types'
export default function KpiPage() {
const t = useTranslations('kpi')
const [selectedPeriod, setSelectedPeriod] = useState<string>('')
const [report, setReport] = useState<KPIReport | null>(null)
const [preferences, setPreferences] = useState<KPIPreferences>(getDefaultPreferences())
const [isLoadingReport, setIsLoadingReport] = useState(false)
const [isSavingPrefs, setIsSavingPrefs] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/kpi/preferences')
const { data } = await res.json()
if (!cancelled && data) setPreferences(data)
} catch {
// Silently fall back to defaults
}
})()
return () => { cancelled = true }
}, [])
const fetchReport = useCallback(async (periodId: string) => {
setIsLoadingReport(true)
setError(null)
try {
const res = await fetch(`/api/reports/kpi?period_id=${periodId}`)
if (!res.ok) throw new Error(t('fetch_failed'))
const { data } = await res.json()
setReport(data)
} catch {
setError(t('fetch_failed'))
} finally {
setIsLoadingReport(false)
}
}, [t])
useEffect(() => {
if (!selectedPeriod) return
let cancelled = false
fetchReport(selectedPeriod).then(() => {
if (cancelled) setReport(null)
})
return () => { cancelled = true }
}, [selectedPeriod, fetchReport])
async function handleSavePreferences(prefs: KPIPreferences) {
setIsSavingPrefs(true)
try {
const res = await fetch('/api/kpi/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
})
if (!res.ok) throw new Error()
const { data } = await res.json()
setPreferences(data)
if (selectedPeriod) await fetchReport(selectedPeriod)
} catch {
// Silently fail: user can retry
} finally {
setIsSavingPrefs(false)
}
}
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
saving={isSavingPrefs}
/>
}
/>
<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>
)}
{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>
)
}
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>
)
}