feat(ui): prev/next record navigation on detail pages (#1530)

* feat(ui): prev/next record navigation on detail pages

Customer feedback: stepping between invoices in a reskontra (56 -> 57 ->
58) required going back to the list for every record.

List pages now write their full ordered id array to sessionStorage when
a row is opened (Accounted:list-context:<scope>:<companyId>), and the
detail pages for kundfakturor, leverantorsfakturor, and verifikat show
a compact prev/next pager (chevrons + 'n av m') next to the back
control. ArrowLeft/ArrowRight step too, except while typing in a text
field or while a dialog is open. Navigation uses router.replace so
'tillbaka' returns to the list in one step. Deep links and new tabs
have no context: the pager hides and pages behave as before.

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

* fix(pager): overlay-aware arrow guard, notes-draft safety, context on Visa detaljer

Review fixes on the detail-record pager:

- The keyboard guard matched any mounted [role=dialog], so the agent
  sheet (which stays mounted display:none once opened) killed arrow
  paging for the rest of the tab session, while open dropdown menus
  did not block at all. The guard now mirrors the AgentSheet Esc
  selector (data-state="open" variants incl. alertdialog and radix
  menu/select/listbox content) and also yields while focus sits inside
  a dialog/menu/listbox container. Extracted as pure functions in
  lib/hooks/detail-pager-guards.ts so the rules are testable in the
  node test environment.
- Arrow keys could unmount the verifikat page and destroy an unsaved
  notes draft once the textarea lost focus. useDetailPager and
  DetailPager now take a keyboard flag, and the verifikat page
  disables keyboard paging while editingNotes is active; the chevron
  buttons stay live.
- The expanded-row Visa detaljer link in JournalEntryList navigated
  without writing the list context, producing stale pager snapshots;
  it now calls rememberListContext like the voucher link.
- ListContext.listPath was written and strictly validated but never
  consumed: removed from the interface, all writers, and the read
  validation. Reads stay tolerant of extra properties so contexts
  stored by older builds still parse.

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

* refactor(pager): quiet wayfinding strip instead of buttons in the title cluster

The pager sat between the back arrow and the H1, which read as a toolbar
of three boxed buttons and made the title jump horizontally per record.
All three detail pages now share the verifikat page's pattern: a muted
back text-link on the left and the pager right-aligned on the same quiet
row. The pager itself drops to 16px glyphs, muted ink, and hides when the
list context holds a single record.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-12 21:07:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent bffa57a565
commit 67febd5097
14 changed files with 810 additions and 20 deletions
+23 -8
View File
@@ -37,6 +37,9 @@ import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
@@ -67,6 +70,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const { id } = use(params)
const router = useRouter()
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const { toast } = useToast()
const t = useTranslations('journal_detail')
const sourceTypeLabels = useSourceTypeLabels()
@@ -409,14 +413,25 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
return (
<div className="space-y-8">
{/* Back link */}
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
{/* Back link + prev/next record pager */}
<div className="flex items-center justify-between gap-4">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
<DetailPager
contextKey={listContextKey('bookkeeping', company?.id)}
basePath="/bookkeeping"
currentId={id}
// Arrow paging unmounts the page and would destroy an unsaved notes
// draft; the textarea only guards arrows while it has focus, so gate
// the keyboard bindings on the editing state itself.
keyboard={!editingNotes}
/>
</div>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
+20 -3
View File
@@ -58,6 +58,8 @@ import {
type InvoiceDeliveryView,
} from '@/components/invoices/InvoiceDeliveryHistory'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import {
Dialog,
DialogContent,
@@ -855,12 +857,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
return (
<div className="space-y-8">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => router.back()}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</button>
<DetailPager
contextKey={listContextKey('invoices', company?.id)}
basePath="/invoices"
currentId={id}
/>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className={cn('font-display text-2xl leading-8 tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}</h1>
+17 -2
View File
@@ -33,6 +33,7 @@ import {
type InvoiceListSort,
type InvoiceListSortColumn,
} from '@/lib/invoices/invoice-list-sort'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import {
ArrowDown,
ArrowUp,
@@ -369,6 +370,14 @@ export default function InvoicesPage() {
)
const visibleInvoices = sortedInvoices.slice(0, visibleCount)
// Detail-pager context: the FULL sorted list (not the visible slice), so
// prev/next on the detail page can walk past the paging boundary.
const rememberListContext = () => {
writeListContext(listContextKey('invoices', company?.id), {
ids: sortedInvoices.map((invoice) => invoice.id),
})
}
const tabCounts = useMemo(() => {
const counts = Object.fromEntries(ALL_TABS.map((tab) => [tab, 0])) as Record<ListTab, number>
for (const invoice of scopedInvoices) {
@@ -772,7 +781,10 @@ export default function InvoicesPage() {
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(invoice.id) && 'bg-secondary/40',
)}
onClick={() => router.push(`/invoices/${invoice.id}`)}
onClick={() => {
rememberListContext()
router.push(`/invoices/${invoice.id}`)
}}
>
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
{showSelection && (
@@ -799,7 +811,10 @@ export default function InvoicesPage() {
<Link
href={`/invoices/${invoice.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{number ?? '·'}
</Link>
@@ -27,6 +27,9 @@ import { useCompanySettings } from '@/components/settings/useSettings'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
interface EditableLine {
@@ -82,8 +85,10 @@ export default function SupplierInvoiceDetailPage() {
const { settings: companySettings } = useCompanySettings()
const params = useParams()
const router = useRouter()
const company = useCompanyOptional()?.company ?? null
const { toast } = useToast()
const t = useTranslations('supplier_invoice_detail')
const tCommon = useTranslations('common')
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
@@ -472,12 +477,29 @@ export default function SupplierInvoiceDetailPage() {
return (
<div className="space-y-8 max-w-4xl">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => router.push('/supplier-invoices')}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('back_aria')}
>
<ArrowLeft className="h-4 w-4" />
{tCommon('back')}
</button>
<DetailPager
contextKey={listContextKey('supplier-invoices', company?.id)}
basePath="/supplier-invoices"
currentId={String(params.id)}
className="shrink-0"
/>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')} aria-label={t('back_aria')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
+19 -2
View File
@@ -23,6 +23,8 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { FiscalPeriod, SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
@@ -90,6 +92,7 @@ export default function SupplierInvoicesPage() {
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const company = useCompanyOptional()?.company ?? null
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<ListTab>('all')
@@ -185,6 +188,14 @@ export default function SupplierInvoicesPage() {
return matchesTab && matchesSearch && matchesFy
})
// Detail-pager context: the filtered list as rendered, written when the
// user navigates into a row.
const rememberListContext = () => {
writeListContext(listContextKey('supplier-invoices', company?.id), {
ids: filteredInvoices.map((inv) => inv.id),
})
}
const registeredCount = invoices.filter((inv) => inv.status === 'registered').length
const toPayCount = invoices.filter(
(inv) => inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue',
@@ -423,7 +434,10 @@ export default function SupplierInvoicesPage() {
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(inv.id) && 'bg-secondary/40',
)}
onClick={() => router.push(`/supplier-invoices/${inv.id}`)}
onClick={() => {
rememberListContext()
router.push(`/supplier-invoices/${inv.id}`)
}}
>
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
{canWrite && (
@@ -453,7 +467,10 @@ export default function SupplierInvoicesPage() {
<Link
href={`/supplier-invoices/${inv.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{inv.supplier_invoice_number}
</Link>