feat(reports,settings): report library + focused report routes, settings modal (#629)
* feat(reports,settings): report library + focused report routes, settings modal Reports - Replace the monolithic /reports tab-switcher with a calm, grouped report library landing (ReportLibrary + RecentReportsShelf) driven by a new lib/reports/catalog.ts. - Each report opens a focused /reports/[slug] route (FocusedReport) with a shared fiscal-year selector, optional date-range, and URL-based account drill-down into the general ledger. - Extract every report view into components/reports/views, add a reusable ReportExportMenu, and remove the old ReportsNav. Settings - Add an intercepting @settingsModal parallel route so in-app navigation to /settings opens as a modal over the current page; hard loads still resolve to the full page. - Share one SettingsShell (rail + content) between page and modal, extract each section into components/settings/sections/*Content, add a settings hotkey and command-palette entry, and remove the old SettingsSidebar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports,settings): remove dead salary-journal entry, fix border token Addresses PR review feedback (#629): - Remove the unreachable `salary-journal` report from the catalog. It had needsEmployees + no route + no FocusedView handler and `hasEmployees` was never plumbed through, so it never appeared in the library and a direct /reports/salary-journal URL rendered a blank frame. The report was never on the old page and has no view component; the API + generator stay in place for a proper follow-up. Drops its two now-unused i18n keys. - Replace opacity-suffixed `border-border/8` section dividers with full-opacity `border-border` across the extracted settings section components, per the design system (no opacity-suffixed border tokens on surfaces). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: re-trigger checks (pg-real hit a Docker Hub registry timeout) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f6ee0c2a82
commit
ff01640f60
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
|
||||
import { DATE_RANGE_SLUGS, getReport } from '@/lib/reports/catalog'
|
||||
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
|
||||
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
|
||||
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
|
||||
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
|
||||
import {
|
||||
TrialBalanceView,
|
||||
IncomeStatementView,
|
||||
BalanceSheetView,
|
||||
ResultatrapportView,
|
||||
BalansrapportView,
|
||||
VatDeclarationView,
|
||||
SupplierLedgerView,
|
||||
GeneralLedgerView,
|
||||
JournalRegisterView,
|
||||
ARLedgerView,
|
||||
} from '@/components/reports/views'
|
||||
|
||||
/**
|
||||
* The focused single-report experience at /reports/[slug]. Carries one report:
|
||||
* a back link to the library, the shared fiscal-year selector (restored from
|
||||
* localStorage so it matches the year picked on the landing), the report's
|
||||
* optional date-range control, and the report body. Drilling into an account
|
||||
* navigates to /reports/huvudbok?account=… — drill state lives in the URL.
|
||||
*/
|
||||
function FocusedReportInner({ slug }: { slug: string }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('reports')
|
||||
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({})
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
const report = getReport(slug)
|
||||
// Calendar (VAT family) and param-less reports don't need a fiscal period.
|
||||
const isPeriodless = report?.params === 'calendar' || report?.params === 'none'
|
||||
const reportName = report ? t(report.labelKey) : slug
|
||||
const accountFilter = searchParams.get('account')
|
||||
|
||||
const isEnskildFirma = company?.entity_type === 'enskild_firma'
|
||||
const isAktiebolag = company?.entity_type === 'aktiebolag'
|
||||
|
||||
// Drilling from a report into the general ledger is a route change, so the
|
||||
// account lands in the URL and the browser back button returns to the report.
|
||||
const navigateToAccount = (accountNumber: string) => {
|
||||
router.push(`/reports/huvudbok?account=${encodeURIComponent(accountNumber)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('back_to_library')}
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={reportName}
|
||||
action={
|
||||
<FiscalYearSelector
|
||||
value={selectedPeriod || null}
|
||||
onChange={(id, period) => {
|
||||
setSelectedPeriod(id || '')
|
||||
setSelectedPeriodBounds(
|
||||
period ? { start: period.period_start, end: period.period_end } : null,
|
||||
)
|
||||
setDateRange({})
|
||||
}}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
onReady={() => setIsReady(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{DATE_RANGE_SLUGS.has(slug) && selectedPeriodBounds && (
|
||||
<ReportDateRange
|
||||
periodStart={selectedPeriodBounds.start}
|
||||
periodEnd={selectedPeriodBounds.end}
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isReady && !isPeriodless ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-64" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isPeriodless || selectedPeriod ? (
|
||||
<FocusedView
|
||||
slug={slug}
|
||||
periodId={selectedPeriod}
|
||||
periodBounds={selectedPeriodBounds}
|
||||
dateRange={dateRange}
|
||||
accountFilter={accountFilter}
|
||||
isEnskildFirma={isEnskildFirma}
|
||||
isAktiebolag={isAktiebolag}
|
||||
onNavigateToAccount={navigateToAccount}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Inget räkenskapsår valt"
|
||||
description="Skapa ett räkenskapsår för att kunna se rapporter."
|
||||
actionLabel="Gå till inställningar"
|
||||
actionHref="/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FocusedView({
|
||||
slug,
|
||||
periodId,
|
||||
periodBounds,
|
||||
dateRange,
|
||||
accountFilter,
|
||||
isEnskildFirma,
|
||||
isAktiebolag,
|
||||
onNavigateToAccount,
|
||||
}: {
|
||||
slug: string
|
||||
periodId: string
|
||||
periodBounds: { start: string; end: string } | null
|
||||
dateRange: DateRangeValue
|
||||
accountFilter: string | null
|
||||
isEnskildFirma: boolean
|
||||
isAktiebolag: boolean
|
||||
onNavigateToAccount: (account: string) => void
|
||||
}) {
|
||||
switch (slug) {
|
||||
case 'resultatrapport':
|
||||
return <ResultatrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'balansrapport':
|
||||
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'trial-balance':
|
||||
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'income-statement':
|
||||
return <IncomeStatementView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'balance-sheet':
|
||||
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'vat-declaration':
|
||||
return <VatDeclarationView fiscalPeriodId={periodId} fiscalPeriodBounds={periodBounds} />
|
||||
case 'periodisk-sammanstallning':
|
||||
return <PeriodiskSammanstallningView />
|
||||
case 'ne-declaration':
|
||||
return isEnskildFirma ? <NEDeclarationView periodId={periodId} /> : null
|
||||
case 'ink2-declaration':
|
||||
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
|
||||
case 'huvudbok':
|
||||
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} />
|
||||
case 'grundbok':
|
||||
return <JournalRegisterView periodId={periodId} />
|
||||
case 'kundreskontra':
|
||||
return <ARLedgerView periodId={periodId} />
|
||||
case 'supplier-ledger':
|
||||
return <SupplierLedgerView periodId={periodId} />
|
||||
case 'bank-reconciliation':
|
||||
return <BankReconciliationView />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function FocusedReport({ slug }: { slug: string }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="space-y-8" />}>
|
||||
<FocusedReportInner slug={slug} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'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="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { Download, FileSpreadsheet, FileText } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { ReportExportFormat } from '@/lib/reports/catalog'
|
||||
|
||||
export interface ReportExportItem {
|
||||
format: ReportExportFormat
|
||||
href: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The single "Exportera" affordance for a report. Replaces the scattered
|
||||
* per-format download buttons that used to float inside report card bodies.
|
||||
* `children` lets a report append a sibling action (e.g. the VAT review agent).
|
||||
*/
|
||||
export function ReportExportMenu({
|
||||
items,
|
||||
children,
|
||||
}: {
|
||||
items?: ReportExportItem[]
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTranslations('reports')
|
||||
const hasItems = !!items && items.length > 0
|
||||
if (!hasItems && !children) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{hasItems && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('export')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{items!.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.href}
|
||||
onSelect={() => window.open(item.href, '_blank')}
|
||||
>
|
||||
{item.format === 'pdf' ? (
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{item.format === 'pdf' ? t('download_pdf') : t('download_excel')}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
DataList,
|
||||
DataListMeta,
|
||||
DataListPrimary,
|
||||
DataListRow,
|
||||
} from '@/components/ui/data-list'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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.
|
||||
*/
|
||||
export function ReportLibrary({
|
||||
entityType,
|
||||
hasEmployees,
|
||||
onOpen,
|
||||
}: {
|
||||
entityType?: EntityType
|
||||
hasEmployees?: boolean
|
||||
onOpen: (slug: string) => void
|
||||
}) {
|
||||
const t = useTranslations('reports')
|
||||
const sections = getLibrarySections(entityType, hasEmployees)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{sections.map((section) => (
|
||||
<div key={section.category} className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function EntityBadge({ item }: { item: ReportDescriptor }) {
|
||||
if (item.entityType === 'enskild_firma') return <Badge variant="outline">EF</Badge>
|
||||
if (item.entityType === 'aktiebolag') return <Badge variant="outline">AB</Badge>
|
||||
return null
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface ReportItem {
|
||||
value: string
|
||||
labelKey: string
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
interface ReportCategory {
|
||||
labelKey: string
|
||||
items: ReportItem[]
|
||||
}
|
||||
|
||||
const CATEGORIES: ReportCategory[] = [
|
||||
{
|
||||
labelKey: 'group_interim',
|
||||
items: [
|
||||
{ value: 'resultatrapport', labelKey: 'name_resultatrapport' },
|
||||
{ value: 'balansrapport', labelKey: 'name_balansrapport' },
|
||||
{ value: 'trial-balance', labelKey: 'name_trial_balance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: 'group_year_end',
|
||||
items: [
|
||||
{ value: 'income-statement', labelKey: 'name_income_statement' },
|
||||
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
|
||||
{ value: 'kassaflodesanalys', labelKey: 'name_kassaflodesanalys' },
|
||||
{ value: 'arsredovisning', labelKey: 'name_arsredovisning', entityType: 'aktiebolag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: 'group_tax_vat',
|
||||
items: [
|
||||
{ value: 'vat-declaration', labelKey: 'name_vat_declaration' },
|
||||
{ value: 'periodisk-sammanstallning', labelKey: 'name_periodisk_sammanstallning' },
|
||||
{ value: 'ne-declaration', labelKey: 'name_ne_declaration', entityType: 'enskild_firma' },
|
||||
{ value: 'ink2-declaration', labelKey: 'name_ink2_declaration', entityType: 'aktiebolag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: 'group_ledgers',
|
||||
items: [
|
||||
{ value: 'huvudbok', labelKey: 'name_huvudbok' },
|
||||
{ value: 'grundbok', labelKey: 'name_grundbok' },
|
||||
{ value: 'kundreskontra', labelKey: 'name_kundreskontra' },
|
||||
{ value: 'supplier-ledger', labelKey: 'name_supplier_ledger' },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: 'group_reconciliation',
|
||||
items: [
|
||||
{ value: 'bank-reconciliation', labelKey: 'name_bank_reconciliation' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface ReportsNavProps {
|
||||
active: string
|
||||
onChange: (value: string) => void
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
const t = useTranslations('reports')
|
||||
const filtered = CATEGORIES
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
items: cat.items.filter(item => !item.entityType || item.entityType === entityType),
|
||||
}))
|
||||
.filter(cat => cat.items.length > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: grouped select */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={active} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filtered.map(cat => (
|
||||
<SelectGroup key={cat.labelKey}>
|
||||
<SelectLabel>{t(cat.labelKey)}</SelectLabel>
|
||||
{cat.items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: vertical left rail */}
|
||||
<nav
|
||||
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
|
||||
aria-label={t('categories_aria')}
|
||||
>
|
||||
<ul className="space-y-6">
|
||||
{filtered.map(cat => (
|
||||
<li key={cat.labelKey}>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
|
||||
{t(cat.labelKey)}
|
||||
</p>
|
||||
<ul className="space-y-px">
|
||||
{cat.items.map(item => {
|
||||
const isActive = active === item.value
|
||||
return (
|
||||
<li key={item.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-1.5 rounded-md text-[13px] transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Tracks the last few report slugs the user opened, per company, in
|
||||
* localStorage. Mirrors the `gnubok:<key>:<companyId>` convention used by
|
||||
* FiscalYearSelector (STORAGE_KEY_PREFIX). Powers the "Senast öppnade" shelf
|
||||
* so returning users skip the library hop.
|
||||
*/
|
||||
const STORAGE_KEY_PREFIX = 'gnubok:report-recents:'
|
||||
const MAX_RECENTS = 4
|
||||
|
||||
export function useRecentReports(companyId: string | null | undefined) {
|
||||
const [recents, setRecents] = useState<string[]>([])
|
||||
|
||||
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).
|
||||
Promise.resolve().then(() => {
|
||||
if (cancelled) return
|
||||
if (!companyId) {
|
||||
setRecents([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY_PREFIX + companyId)
|
||||
setRecents(raw ? (JSON.parse(raw) as string[]) : [])
|
||||
} catch {
|
||||
setRecents([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
const pushRecent = useCallback(
|
||||
(slug: string) => {
|
||||
if (!companyId) return
|
||||
setRecents((prev) => {
|
||||
const next = [slug, ...prev.filter((s) => s !== slug)].slice(0, MAX_RECENTS)
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY_PREFIX + companyId, JSON.stringify(next))
|
||||
} catch {
|
||||
/* localStorage unavailable — keep in-memory only */
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[companyId],
|
||||
)
|
||||
|
||||
return { recents, pushRecent }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user