Files
accounted/components/kpi/KPIHeroCards.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

164 lines
5.5 KiB
TypeScript

'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>
)
}