feat(reports): catalog as one dry table with band groups and Senast öppnad (#1147)

The founder-picked Tabellen variant from the rest-of-nav 2 concept:
the report library becomes a single dry table where band rows carry the
accounting taxonomy, each report is one clickable line with its
description in muted ink, and a Senast oppnad column replaces the
recents shelf (RecentReportsShelf deleted). useRecentReports now stores
slug+timestamp pairs (legacy plain-slug entries parse as undated).
FiscalYearSelector swaps to the house FyPicker chip, help moves behind
the ? popover, catalog footnote as pgnote. Entity gating, dimension
gating, route-owning reports and the persisted FY choice all unchanged.

9341 tests pass, lint clean, guards pass. sv+en keys added.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 16:41:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 9dfa6c6708
commit 6911f657e9
6 changed files with 161 additions and 131 deletions
+25 -24
View File
@@ -4,22 +4,23 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { EmptyState } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCompanySettings } from '@/components/settings/useSettings'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { FyPicker } from '@/components/common/FyPicker'
import { ReportLibrary } from '@/components/reports/ReportLibrary'
import { RecentReportsShelf } from '@/components/reports/RecentReportsShelf'
import { useRecentReports } from '@/components/reports/useRecentReports'
import { getReport } from '@/lib/reports/catalog'
/**
* Reports library landing. A calm, grouped index of every report: selecting
* one opens the focused /reports/[slug] route. The fiscal year picked here
* persists (FiscalYearSelector localStorage) and is restored on the focused
* page, so the choice carries across without URL plumbing.
* Reports catalog landing (concept "Tabellen"): one dry table grouped by
* accounting taxonomy, with a "Senast öppnad" column instead of a separate
* recents shelf. Selecting a report opens the focused /reports/[slug] route.
* The fiscal year picked here persists (FyPicker localStorage) and is
* restored on the focused page, so the choice carries across without URL
* plumbing.
*/
export default function ReportsPage() {
const router = useRouter()
@@ -28,7 +29,7 @@ export default function ReportsPage() {
const { company } = useCompany()
const { settings } = useCompanySettings()
const t = useTranslations('reports')
const { recents, pushRecent } = useRecentReports(company?.id)
const { openedAt, pushRecent } = useRecentReports(company?.id)
// Open a report. Route-owning reports (cash flow, annual report, KPI, SIE)
// navigate to their own page; the rest open the focused /reports/[slug] route.
@@ -50,8 +51,13 @@ export default function ReportsPage() {
<div className="space-y-8">
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
action={
<FiscalYearSelector
<FyPicker
value={selectedPeriod || null}
onChange={(id) => setSelectedPeriod(id || '')}
includeAllOption={false}
@@ -62,14 +68,10 @@ export default function ReportsPage() {
/>
{isLoadingInit ? (
<div className="space-y-6">
<Skeleton className="h-4 w-40" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
<div className="space-y-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
) : !selectedPeriod ? (
<EmptyState
@@ -79,18 +81,17 @@ export default function ReportsPage() {
actionHref="/settings"
/>
) : (
<div className="space-y-8">
<RecentReportsShelf
slugs={recents}
entityType={company?.entity_type}
onOpen={openReport}
/>
<>
<ReportLibrary
entityType={company?.entity_type}
dimensionsEnabled={settings?.dimensions_enabled === true}
openedAt={openedAt}
onOpen={openReport}
/>
</div>
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('catalog_footnote')}
</p>
</>
)}
</div>
)
-52
View File
@@ -1,52 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import { getReport } from '@/lib/reports/catalog'
import type { EntityType } from '@/types'
/**
* "Senast öppnade": compact beige chips for the reports the user opened most
* recently. One tap reopens a report straight from the library landing.
* Renders nothing when there is no history (first visit).
*/
export function RecentReportsShelf({
slugs,
entityType,
hasEmployees,
onOpen,
}: {
slugs: string[]
entityType?: EntityType
hasEmployees?: boolean
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const items = slugs
.map((slug) => getReport(slug))
.filter((r): r is NonNullable<typeof r> => !!r)
.filter((r) => !r.entityType || r.entityType === entityType)
.filter((r) => !r.needsEmployees || hasEmployees)
if (items.length === 0) return null
return (
<div className="space-y-3">
<h2 className="font-sans text-sm font-medium">
{t('recent_heading')}
</h2>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<button
key={item.slug}
type="button"
onClick={() => onOpen(item.slug)}
className="inline-flex items-center rounded-md bg-secondary px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary/70"
>
{t(item.labelKey)}
</button>
))}
</div>
</div>
)
}
+89 -39
View File
@@ -1,70 +1,120 @@
'use client'
import { useTranslations } from 'next-intl'
import { ChevronRight } from 'lucide-react'
import {
DataList,
DataListMeta,
DataListPrimary,
DataListRow,
} from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Badge } from '@/components/ui/badge'
import { cn, formatDate } from '@/lib/utils'
import { getLibrarySections, type ReportDescriptor } from '@/lib/reports/catalog'
import type { EntityType } from '@/types'
/**
* The report library: the calm landing for /reports. Reports grouped by
* accounting taxonomy, each section a single DataList. One report = one row =
* one destination; no data preview, so the landing stays a fast index.
* The report catalog as one dry table (concept "Tabellen"): band rows carry
* the accounting taxonomy, each report is a single clickable line with its
* description in muted ink and a "Senast öppnad" column fed from the
* per-company recents. One report = one row = one destination.
*/
export function ReportLibrary({
entityType,
hasEmployees,
dimensionsEnabled,
openedAt,
onOpen,
}: {
entityType?: EntityType
hasEmployees?: boolean
dimensionsEnabled?: boolean
/** slug -> epoch ms for the "Senast öppnad" column. */
openedAt: Record<string, number>
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
const lastOpenedLabel = (slug: string): string => {
const at = openedAt[slug]
if (!at) return ''
const days = Math.floor((Date.now() - at) / 86_400_000)
if (days === 0) return t('opened_today')
if (days === 1) return t('opened_yesterday')
return formatDate(new Date(at))
}
return (
<div className="space-y-8">
{sections.map((section) => (
<div key={section.category} className="space-y-3">
<h2 className="font-sans text-sm font-medium">
{t(section.labelKey)}
</h2>
<DataList>
{section.items.map((item) => (
<DataListRow
key={item.slug}
onClick={() => onOpen(item.slug)}
trailing={
<>
<EntityBadge item={item} />
{item.params === 'calendar' && (
<Badge variant="secondary">{t('calendar_badge')}</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</>
}
>
<DataListPrimary>{t(item.labelKey)}</DataListPrimary>
<DataListMeta>{t(item.descKey)}</DataListMeta>
</DataListRow>
))}
</DataList>
</div>
))}
<div className="overflow-x-auto" role="region" aria-label={t('title')}>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_report')}</th>
<th className={TH_CLASS}>{t('col_description')}</th>
<th className={cn(TH_CLASS, 'w-[130px] text-right')}>{t('col_last_opened')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{sections.map((section) => (
<SectionRows
key={section.category}
label={t(section.labelKey)}
items={section.items}
lastOpenedLabel={lastOpenedLabel}
onOpen={onOpen}
/>
))}
</tbody>
</table>
</div>
)
}
function EntityBadge({ item }: { item: ReportDescriptor }) {
function SectionRows({
label,
items,
lastOpenedLabel,
onOpen,
}: {
label: string
items: ReportDescriptor[]
lastOpenedLabel: (slug: string) => string
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
return (
<>
<tr className="bg-muted/30">
<td
colSpan={3}
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
>
{label}
</td>
</tr>
{items.map((item) => (
<tr
key={item.slug}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => onOpen(item.slug)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<span className="flex items-center gap-2">
{t(item.labelKey)}
<EntityMark item={item} />
{item.params === 'calendar' && (
<Badge variant="secondary" className="font-normal">
{t('calendar_badge')}
</Badge>
)}
</span>
</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{t(item.descKey)}</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums text-muted-foreground')}>
{lastOpenedLabel(item.slug)}
</td>
</tr>
))}
</>
)
}
function EntityMark({ item }: { item: ReportDescriptor }) {
if (item.entityType === 'enskild_firma')
return <span className="text-xs text-muted-foreground">EF</span>
if (item.entityType === 'aktiebolag')
+31 -14
View File
@@ -3,33 +3,43 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Tracks the last few report slugs the user opened, per company, in
* localStorage. Mirrors the `Accounted:<key>:<companyId>` convention used by
* FiscalYearSelector (STORAGE_KEY_PREFIX). Powers the "Senast öppnade" shelf
* so returning users skip the library hop.
* Tracks the last reports the user opened, per company, in localStorage.
* Mirrors the `Accounted:<key>:<companyId>` convention used by
* FiscalYearSelector (STORAGE_KEY_PREFIX). Powers the "Senast öppnad" column
* in the report catalog table.
*
* Storage format: Array<{ s: slug, at: epoch-ms }>. Legacy entries were plain
* slug strings; those parse without a timestamp and simply show no date.
*/
const STORAGE_KEY_PREFIX = 'Accounted:report-recents:'
const MAX_RECENTS = 4
const MAX_RECENTS = 12
type StoredRecent = string | { s: string; at: number }
export function useRecentReports(companyId: string | null | undefined) {
const [recents, setRecents] = useState<string[]>([])
const [openedAt, setOpenedAt] = useState<Record<string, number>>({})
useEffect(() => {
let cancelled = false
// Deferred to a microtask so the read isn't a synchronous setState in the
// effect body (and so the first server/client render agree on an empty
// shelf, avoiding a hydration mismatch).
// list, avoiding a hydration mismatch).
Promise.resolve().then(() => {
if (cancelled) return
if (!companyId) {
setRecents([])
setOpenedAt({})
return
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY_PREFIX + companyId)
setRecents(raw ? (JSON.parse(raw) as string[]) : [])
const parsed = raw ? (JSON.parse(raw) as StoredRecent[]) : []
const map: Record<string, number> = {}
for (const entry of parsed) {
if (typeof entry === 'object' && entry && entry.s) map[entry.s] = entry.at
}
setOpenedAt(map)
} catch {
setRecents([])
setOpenedAt({})
}
})
return () => {
@@ -40,10 +50,17 @@ export function useRecentReports(companyId: string | null | undefined) {
const pushRecent = useCallback(
(slug: string) => {
if (!companyId) return
setRecents((prev) => {
const next = [slug, ...prev.filter((s) => s !== slug)].slice(0, MAX_RECENTS)
setOpenedAt((prev) => {
const next = { ...prev, [slug]: Date.now() }
try {
window.localStorage.setItem(STORAGE_KEY_PREFIX + companyId, JSON.stringify(next))
const stored = Object.entries(next)
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_RECENTS)
.map(([s, at]) => ({ s, at }))
window.localStorage.setItem(
STORAGE_KEY_PREFIX + companyId,
JSON.stringify(stored),
)
} catch {
/* localStorage unavailable: keep in-memory only */
}
@@ -53,5 +70,5 @@ export function useRecentReports(companyId: string | null | undefined) {
[companyId],
)
return { recents, pushRecent }
return { openedAt, pushRecent }
}
+8 -1
View File
@@ -5229,7 +5229,14 @@
"date_range_preset_this_quarter": "This quarter",
"date_range_preset_custom": "Custom",
"date_range_from": "From",
"date_range_to": "To"
"date_range_to": "To",
"col_report": "Report",
"col_description": "Description",
"col_last_opened": "Last opened",
"opened_today": "today",
"opened_yesterday": "yesterday",
"help_text": "Every report opens for the selected fiscal year and can be narrowed to any period inside the report. Last opened remembers what you use the most.",
"catalog_footnote": "Every report opens for any period and can be downloaded as a PDF. The NE attachment replaces INK2 for sole traders."
},
"salary": {
"title": "Payroll",
+8 -1
View File
@@ -5229,7 +5229,14 @@
"date_range_preset_this_quarter": "Detta kvartal",
"date_range_preset_custom": "Anpassat",
"date_range_from": "Från",
"date_range_to": "Till"
"date_range_to": "Till",
"col_report": "Rapport",
"col_description": "Beskrivning",
"col_last_opened": "Senast öppnad",
"opened_today": "idag",
"opened_yesterday": "igår",
"help_text": "Alla rapporter öppnas för det valda räkenskapsåret och kan avgränsas till valfri period inne i rapporten. Senast öppnad kommer ihåg vad du använder mest.",
"catalog_footnote": "Alla rapporter öppnas för valfri period och kan laddas ner som PDF. NE-bilagan visas i stället för INK2 för enskild firma."
},
"salary": {
"title": "Löner",