diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index e444a22c..511ab126 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -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() {
+

{t('help_text')}

+ + } action={ - setSelectedPeriod(id || '')} includeAllOption={false} @@ -62,14 +68,10 @@ export default function ReportsPage() { /> {isLoadingInit ? ( -
- - - - - - - +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))}
) : !selectedPeriod ? ( ) : ( -
- + <> -
+

+ {t('catalog_footnote')} +

+ )}
) diff --git a/components/reports/RecentReportsShelf.tsx b/components/reports/RecentReportsShelf.tsx deleted file mode 100644 index a30026e8..00000000 --- a/components/reports/RecentReportsShelf.tsx +++ /dev/null @@ -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 => !!r) - .filter((r) => !r.entityType || r.entityType === entityType) - .filter((r) => !r.needsEmployees || hasEmployees) - - if (items.length === 0) return null - - return ( -
-

- {t('recent_heading')} -

-
- {items.map((item) => ( - - ))} -
-
- ) -} diff --git a/components/reports/ReportLibrary.tsx b/components/reports/ReportLibrary.tsx index 1d859e0c..fa579dca 100644 --- a/components/reports/ReportLibrary.tsx +++ b/components/reports/ReportLibrary.tsx @@ -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 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 ( -
- {sections.map((section) => ( -
-

- {t(section.labelKey)} -

- - {section.items.map((item) => ( - onOpen(item.slug)} - trailing={ - <> - - {item.params === 'calendar' && ( - {t('calendar_badge')} - )} - - - } - > - {t(item.labelKey)} - {t(item.descKey)} - - ))} - -
- ))} +
+ + + + + + + + + + {sections.map((section) => ( + + ))} + +
{t('col_report')}{t('col_description')}{t('col_last_opened')}
) } -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 ( + <> + + + {label} + + + {items.map((item) => ( + onOpen(item.slug)} + > + + + {t(item.labelKey)} + + {item.params === 'calendar' && ( + + {t('calendar_badge')} + + )} + + + {t(item.descKey)} + + {lastOpenedLabel(item.slug)} + + + ))} + + ) +} + +function EntityMark({ item }: { item: ReportDescriptor }) { if (item.entityType === 'enskild_firma') return EF if (item.entityType === 'aktiebolag') diff --git a/components/reports/useRecentReports.ts b/components/reports/useRecentReports.ts index 3ca1c19b..0f383f5e 100644 --- a/components/reports/useRecentReports.ts +++ b/components/reports/useRecentReports.ts @@ -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::` 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::` 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([]) + const [openedAt, setOpenedAt] = useState>({}) 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 = {} + 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 } } diff --git a/messages/en.json b/messages/en.json index 4f989385..ee3af37c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index add4e410..462eee90 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",