Files
accounted/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

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

394 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 { formatAmount, 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'
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 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 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 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>
)
}