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>
258 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|