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:
co-authored by
Claude Fable 5
parent
9dfa6c6708
commit
6911f657e9
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user