Files
accounted/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx
T
Jakob WennbergandClaude Opus 5 3d02a74147 refactor(ui): system hygiene sweep from the UI craft audit (#1281)
Raw palette: SalaryCalendar was the only file in the salary cluster still
on bg-red-100 / text-amber-800 style classes, with zero dark: variants, so
every absence pill rendered paper-white on a near-black page. All 30 are
now alpha-over-token on the semantic scale, which needs no dark: variant
(the approach badge.tsx already takes). Eight absence types share four
tones, so fill carries a second axis: filled vs outlined separates the
types whose Lucide icon is identical (Heart is parental, pregnancy and
care_relative; Activity is study and other_leave).

Primitives:
- select and dropdown-menu now scale from their trigger via the Radix
  transform-origin variables instead of from their own middle. Dialog stays
  centred: modals are not anchored to a trigger.
- toast used fade-out-80, the one animate-* class with no @utility in
  globals.css, so --tw-exit-opacity fell back to 1 and the toast slid away
  at full opacity. Enter and exit now also share one path per breakpoint
  (top on mobile where the viewport is a full-width bar, right on desktop
  where it is a corner card) using max-sm:/sm: rather than stacking both,
  which would have produced a diagonal.
- slide-over exited on ease-in, which delays the moment the user has
  already decided to leave; it enters on the drawer curve and now leaves
  on it too.
- progress animated transition-all where only transform changes.
- One app-wide TooltipProvider in the root layout. skipDelayDuration is
  provider-scoped, so a provider per instance meant the grace window could
  never fire and every account number in a huvudbok re-paid the full 200ms.

Also: nine hand-rolled animate-pulse placeholders in three different greys
to the Skeleton primitive, 25 CardTitle text-lg to the locked text-base,
two always-on chips to muted text (a chip on every row is not an
exception), the three extensions titles onto the display face, the help
filter chips onto pill geometry, and the dead border-border/60 on the
/import row.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:33:40 +02:00

401 lines
15 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useEffect, useState, useCallback } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { PageHeader } from '@/components/ui/page-header'
import { FyPicker } from '@/components/common/FyPicker'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
import {
ArrowLeft,
Download,
FileSpreadsheet,
AlertTriangle,
CheckCircle2,
Loader2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
import {
getErrorMessage as getUserErrorMessage,
type ErrorLocale,
} from '@/lib/errors/get-error-message'
function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
interface CashRowProps {
label: string
amount: number
}
function CashRow({ label, amount }: CashRowProps) {
return (
<div className="flex items-center justify-between py-1 text-sm">
<span className="text-foreground">{label}</span>
<span className="tabular-nums text-right">{formatAmount(amount)}</span>
</div>
)
}
interface SubtotalRowProps {
label: string
amount: number
}
function SubtotalRow({ label, amount }: SubtotalRowProps) {
return (
<div className="flex items-center justify-between border-t border-border pt-3 mt-2 text-sm font-medium">
<span>{label}</span>
<span className="tabular-nums text-right">{formatAmount(amount)}</span>
</div>
)
}
export function KassaflodesanalysClient() {
const t = useTranslations('reports')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null)
const [report, setReport] = useState<KassaflodesanalysReport | null>(null)
const [isLoadingPeriods, setIsLoadingPeriods] = useState(true)
const [isLoadingReport, setIsLoadingReport] = useState(false)
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadReport = useCallback(async (periodId: string) => {
setIsLoadingReport(true)
setError(null)
try {
const res = await fetch(`/api/reports/kassaflodesanalys?period_id=${periodId}`)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte hämta kassaflödesanalys')
}
const { data } = await res.json()
setReport(data)
} catch (err) {
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
setReport(null)
} finally {
setIsLoadingReport(false)
}
}, [])
useEffect(() => {
if (selectedPeriod) {
loadReport(selectedPeriod)
} else {
setReport(null)
}
}, [selectedPeriod, loadReport])
/**
* Fetch the statutory kassaflödesanalys PDF and save it only if the server
* actually produced one.
*
* This used to be `window.location.href = <pdf route>`, which has no seam for
* either half of the problem. On success the browser cancels the navigation
* (the route answers Content-Disposition: attachment) and the click gives no
* feedback at all, so nothing marks the render as in flight: a second click
* starts a second full report query plus renderToBuffer. On failure the route
* answers a JSON error envelope with no Content-Disposition, so the browser
* navigates the whole app away and the user is left staring at raw JSON with
* their report page gone. There is no `res` to check and no promise to catch
* on a location assignment; the only fix is to make the download a bounded
* fetch.
*
* The filename mirrors the route's Content-Disposition
* (kassaflodesanalys-<period_start>.pdf): both sides derive it from the same
* fiscal period, and the button is disabled until that report is loaded.
*
* No success toast: the saved file is the feedback. On failure nothing was
* written to disk and exactly one toast says why. Never two, TOAST_LIMIT is 1
* (components/ui/use-toast.tsx), so a second toast in the same tick evicts the
* first and only the last is rendered.
*/
const handleDownloadPdf = useCallback(async () => {
// The button is disabled while a render is in flight; this guard closes the
// double-click / Enter-repeat race before React has re-rendered it.
if (!selectedPeriod || !report || isDownloadingPdf) return
setIsDownloadingPdf(true)
try {
// The shared 15s deadline applies: this is one fiscal year of aggregation
// plus a single-page render with stock fonts, so the realistic worst case
// is a cold start and one round trip. If a healthy render ever needs
// longer, the answer is maxDuration on the route plus a matching
// timeoutMs here, never an unbounded fetch that spins forever.
const result = await downloadFile({
url: `/api/reports/kassaflodesanalys/pdf?period_id=${selectedPeriod}`,
filename: `kassaflodesanalys-${report.period_start}.pdf`,
locale,
})
if (!result.ok) {
toast({
title: t('pdf_download_failed_title'),
description: failureDescription(result, {
timeout: t('pdf_download_timeout'),
network: t('pdf_download_network'),
}),
variant: 'destructive',
})
}
} finally {
setIsDownloadingPdf(false)
}
}, [selectedPeriod, report, isDownloadingPdf, locale, toast, t])
return (
<div className="space-y-8">
<Link
href="/reports"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Rapporter
</Link>
<PageHeader title="Kassaflödesanalys" />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<FyPicker
value={selectedPeriod}
onChange={(id) => setSelectedPeriod(id)}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsLoadingPeriods(false)}
/>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownloadPdf}
disabled={!report || isLoadingReport || isDownloadingPdf}
>
{isDownloadingPdf ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner PDF
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="outline" disabled>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Ladda ner Excel
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Snart tillgänglig</TooltipContent>
</Tooltip>
</div>
</div>
<p className="text-sm text-muted-foreground">
Indirekt metod enligt BFNAR 2012:1 kap 7. Totalsumman ska överensstämma
med förändringen i likvida medel (kontoklass 19) under perioden.
</p>
{isLoadingPeriods ? (
<div className="space-y-4">
<Skeleton className="h-48" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : !selectedPeriod ? (
<EmptyState
title="Välj räkenskapsår"
description="Välj ett räkenskapsår ovan för att generera kassaflödesanalys."
/>
) : isLoadingReport ? (
<div className="space-y-4">
<Skeleton className="h-48" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : error ? (
<Card className="border-destructive">
<CardContent className="p-6 text-sm text-destructive">{error}</CardContent>
</Card>
) : report ? (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Period: {formatDate(report.period_start)} till {formatDate(report.period_end)}
</p>
{/* Section 1: Löpande verksamhet */}
<Card>
<CardHeader>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Den löpande verksamheten
</h2>
<CardTitle className="text-base">
Kassaflöde från löpande verksamhet
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<CashRow
label="Resultat efter finansiella poster"
amount={report.lopande.resultat_efter_finansiella_poster}
/>
<CashRow label="Avskrivningar" amount={report.lopande.avskrivningar} />
<CashRow
label="Övriga ej-kassaflödespåverkande poster"
amount={report.lopande.ovriga_ej_kassaflodesposter}
/>
<CashRow
label="Förändring av kortfristiga fordringar"
amount={report.lopande.delta_kortfristiga_fordringar}
/>
<CashRow
label="Förändring av varulager"
amount={report.lopande.delta_varulager}
/>
<CashRow
label="Förändring av kortfristiga skulder"
amount={report.lopande.delta_kortfristiga_skulder}
/>
<CashRow label="Betald inkomstskatt" amount={report.lopande.skatt_betald} />
<SubtotalRow
label="Summa kassaflöde löpande verksamhet"
amount={report.lopande.total}
/>
</CardContent>
</Card>
{/* Section 2: Investeringsverksamhet */}
<Card>
<CardHeader>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Investeringsverksamheten
</h2>
<CardTitle className="text-base">
Kassaflöde från investeringsverksamhet
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<CashRow
label="Förvärv av anläggningstillgångar"
amount={report.investerings.forvarv_anlaggningar}
/>
<CashRow
label="Avyttring av anläggningstillgångar"
amount={report.investerings.avyttring_anlaggningar}
/>
<SubtotalRow
label="Summa kassaflöde investeringsverksamhet"
amount={report.investerings.total}
/>
</CardContent>
</Card>
{/* Section 3: Finansieringsverksamhet */}
<Card>
<CardHeader>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Finansieringsverksamheten
</h2>
<CardTitle className="text-base">
Kassaflöde från finansieringsverksamhet
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<CashRow
label="Förändring av lån (långfristiga skulder)"
amount={report.finansierings.delta_lan}
/>
<CashRow label="Utdelningar" amount={report.finansierings.utdelningar} />
<CashRow label="Nyemission" amount={report.finansierings.nyemission} />
<CashRow
label="Erhållna aktieägartillskott"
amount={report.finansierings.erhallna_aktieagartillskott}
/>
<SubtotalRow
label="Summa kassaflöde finansieringsverksamhet"
amount={report.finansierings.total}
/>
</CardContent>
</Card>
{/* Total */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<span className="font-display text-lg">Årets kassaflöde</span>
<span className="font-display text-lg tabular-nums">
{formatAmount(report.total_cash_flow)}
</span>
</div>
</CardContent>
</Card>
{/* Reconciliation banner */}
<Card
className={
report.reconciliation.is_reconciled
? 'border-border bg-muted/30'
: 'border-destructive'
}
>
<CardContent className="p-6 space-y-3">
<div className="flex items-center gap-2">
{report.reconciliation.is_reconciled ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<AlertTriangle className="h-5 w-5 text-destructive" />
)}
<span className="font-medium">
{report.reconciliation.is_reconciled
? 'Avstämning OK: kassaflödet stämmer med 19xx'
: 'Avstämning misslyckades: kontrollera bokföringen'}
</span>
</div>
<div className="space-y-1 text-sm">
<CashRow
label="Ingående saldo (19xx)"
amount={report.reconciliation.opening_cash_1xxx}
/>
<CashRow
label="Utgående saldo (19xx)"
amount={report.reconciliation.closing_cash_1xxx}
/>
<CashRow
label="Faktisk förändring i likvida medel"
amount={report.reconciliation.delta_actual}
/>
<CashRow
label="Beräknad förändring (summa kassaflöden)"
amount={report.reconciliation.delta_calculated}
/>
{!report.reconciliation.is_reconciled && (
<div className="flex items-center justify-between border-t border-destructive pt-2 mt-2 text-sm font-medium text-destructive">
<span>Avvikelse</span>
<span className="tabular-nums text-right">
{formatAmount(report.reconciliation.mismatch_amount)}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
)
}