Files
accounted/components/kpi/KPISettingsDialog.tsx
T
a7dcaac6ad feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)
The KPI payload has carried income, expenses and net per month since the
aggregates RPC, but after the Recharts trend chart was dropped only the net
column was rendered (the bars pane). A fiscal year's month-by-month sums
were therefore fetched and never shown (#2196).

- New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad,
  Intakter, Kostnader, Resultat) with the period totals as the last row,
  rendered between the panes and the cost story. Rows and totals come from
  the pure helper components/kpi/months-table.ts.
- New preference showMonthlyTable (default true) on KPIPreferences: filled
  by mergeWithDefaults on read, accepted by the preferences route, sent
  whole by the dialog, required by readPreferencesBody. A boolean, not a
  KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every
  existing company.
- One Switch row in the Anpassa dialog after the KPI list.
- Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total
  and the two settings keys in sv and en.
- Tests: helper rows/totals/inactive flags, defaults + merge, route accepts
  false and rejects a string; fixtures updated for the new field.

Closes #2196


Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 21:19:19 +02:00

258 lines
9.6 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Settings2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
DialogClose,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions'
import type { KPIPreferences } from '@/types'
interface KPISettingsDialogProps {
preferences: KPIPreferences
/**
* Resolves true when the layout was actually stored. The dialog awaits it, so
* `saving` gets a chance to render, and stays open on false so the draft is
* still there to retry with.
*/
onSave: (prefs: KPIPreferences) => Promise<boolean>
saving: boolean
}
export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDialogProps) {
const t = useTranslations('kpi')
const tCommon = useTranslations('common')
const [draft, setDraft] = useState<KPIPreferences>(preferences)
const [expandedKpi, setExpandedKpi] = useState<string | null>(null)
const [open, setOpen] = useState(false)
function handleOpen(isOpen: boolean) {
// Esc and click-outside must not discard a draft that is mid-save: the save
// could still fail, and the draft is the only copy of what the user picked.
// Bounded wait, the request carries a 15s deadline.
if (!isOpen && saving) return
if (isOpen) setDraft(preferences)
setOpen(isOpen)
}
function toggleKpi(id: string) {
setDraft((prev) => {
const visible = prev.visibleKpis.includes(id)
? prev.visibleKpis.filter((k) => k !== id)
: [...prev.visibleKpis, id]
return { ...prev, visibleKpis: visible }
})
}
function setAccountOverride(kpiId: string, value: string) {
const accounts = value
.split(',')
.map((s) => s.trim())
.filter((s) => /^\d{4}$/.test(s))
setDraft((prev) => ({
...prev,
accountOverrides: {
...prev.accountOverrides,
[kpiId]: accounts,
},
}))
}
function clearAccountOverride(kpiId: string) {
setDraft((prev) => {
const overrides = { ...prev.accountOverrides }
delete overrides[kpiId]
return { ...prev, accountOverrides: overrides }
})
}
function handleReset() {
setDraft(getDefaultPreferences())
}
/**
* Await the save and close only if it landed.
*
* Closing first made the parent's `saving` state unrenderable and, worse, made
* a failed save look identical to a successful one: the dialog vanished, the
* grid showed the draft, and the layout was gone on the next page load. On
* failure the parent shows the one toast that says why and this dialog stays
* open with the draft intact.
*/
async function handleSave() {
// Closes the double-click race before React has re-rendered the disabled
// button; a second PUT would race the first over the same row.
if (saving) return
const saved = await onSave(draft)
if (saved) setOpen(false)
}
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
{t('customize')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('settings_title')}</DialogTitle>
<DialogDescription>
{t('settings_subtitle')}
</DialogDescription>
</DialogHeader>
<div className="space-y-1 mt-2">
{KPI_DEFINITIONS.map((def) => {
const isVisible = draft.visibleKpis.includes(def.id)
const isExpanded = expandedKpi === def.id
const hasOverride =
def.customizableAccounts &&
draft.accountOverrides[def.id] &&
draft.accountOverrides[def.id].length > 0
const overrideValue =
draft.accountOverrides[def.id]?.join(', ') ?? ''
return (
<div
key={def.id}
className="rounded-lg border border-border p-3"
>
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-2 text-left flex-1 min-w-0"
onClick={() =>
setExpandedKpi(isExpanded ? null : def.id)
}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{t(`def_${def.id}_label`)}
{hasOverride && (
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
{t('settings_custom_suffix')}
</span>
)}
</p>
</div>
</button>
<Switch
checked={isVisible}
onCheckedChange={() => toggleKpi(def.id)}
/>
</div>
{isExpanded && (
<div className="mt-3 ml-5.5 space-y-2 text-xs text-muted-foreground">
<p>{t(`def_${def.id}_description`)}</p>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
{t('settings_formula_label')}
</p>
<p className="font-mono text-[11px] bg-muted/50 rounded-sm px-2 py-1">
{t(`def_${def.id}_formula`)}
</p>
</div>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
{t('settings_accounts_label')}
</p>
<p>{t(`def_${def.id}_accounts`)}</p>
</div>
{def.customizableAccounts && (
<div className="pt-1">
<label className="font-medium text-foreground/80 block mb-1">
{t('settings_customize_accounts')}
</label>
<input
type="text"
value={overrideValue}
onChange={(e) =>
setAccountOverride(def.id, e.target.value)
}
placeholder={def.defaultAccounts.join(', ')}
className="w-full rounded-lg border border-input bg-background px-2.5 py-1.5 text-xs font-mono tabular-nums placeholder:text-muted-foreground/50"
/>
<p className="mt-1 text-[10px] text-muted-foreground/70">
{t('settings_account_hint', { example: def.defaultAccounts.slice(0, 3).join(', ') })}
</p>
{hasOverride && (
<button
type="button"
onClick={() => clearAccountOverride(def.id)}
className="mt-1 text-[10px] text-primary hover:underline"
>
{t('settings_reset_field')}
</button>
)}
</div>
)}
</div>
)}
</div>
)
})}
{/* The month table is a layout flag, not a metric: its own row
after the KPI list, no expand panel, no account override. */}
<div className="rounded-lg border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{t('settings_monthly_table_label')}</p>
<p className="text-xs text-muted-foreground">
{t('settings_monthly_table_description')}
</p>
</div>
<Switch
checked={draft.showMonthlyTable}
onCheckedChange={(checked) =>
setDraft((prev) => ({ ...prev, showMonthlyTable: checked }))
}
aria-label={t('settings_monthly_table_label')}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t mt-2">
<button
type="button"
onClick={handleReset}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="h-3 w-3" />
{t('settings_reset_all')}
</button>
<div className="flex gap-2">
<DialogClose asChild>
<Button variant="outline" size="sm" disabled={saving}>
{tCommon('cancel')}
</Button>
</DialogClose>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? tCommon('saving') : tCommon('save')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}