Files
accounted/app/(dashboard)/invoices/page.tsx
T
MattssonandClaude Fable 5 43341aa55c feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes

Click one checkbox, shift-click another, and every row between them
takes the clicked row's new state, the way mail clients work. Turns a
20-row bulk selection into two clicks.

New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor
and applies the range over the rows as currently rendered, so it follows
filtering, sorting and paging rather than the underlying data order. A
shift-click with no valid anchor (first click, or the anchor filtered
away) degrades to a plain toggle. Select-all and clear reset the anchor.

Wired into the 8 selection surfaces: transaction inbox and skattekonto
inbox (separate ranges, since the two row types book through different
endpoints), journal entry list, invoices, supplier invoices, orders,
pending operations, invoice inbox workspace.

Radix' onCheckedChange carries no mouse event, so each row records
shiftKey from the click that precedes it; the checkbox cells get
select-none so shift-clicking does not smear a text selection.

The pure range rule is unit tested (10 cases: both directions, range
unselect, anchor invalidation, rendered-order independence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y

* fix(ui): void the range anchor on an empty selection, keep placeholders out

Review findings from CodeRabbit and the skeptic pass, all in the new
range-selection feature:

- Clearing a selection left the anchor behind, so the next shift-click
  extended from a row the user could no longer see selected (click a row,
  press "Rensa markering", shift-click 30 rows down, get 30 rows). The
  explicit resetAnchor() calls only covered the clear paths that were
  wired by hand; several others (period change, filter change, post-bulk
  success, "Avmarkera") were not. An empty selection now counts as having
  no anchor, which covers every clear path including ones added later.
- The invoice inbox passed optimistic upload placeholders into visibleIds
  even though they render no checkbox. Safe today only because
  placeholders are always prepended; filtering them out makes the
  invariant local instead of depending on insert order elsewhere.
- pending: "Godkänn alla" pre-selects a non-empty set, so it resets the
  anchor explicitly.

Two existing tests used a fixture the UI cannot reach (an anchor with an
empty selection); they now start from the state a real anchor implies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:37:25 +02:00

928 lines
36 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button'
import { useUiState } from '@/lib/hooks/use-ui-state'
import { resolveInitialMode } from '@/lib/ui-state/client'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import {
sortInvoiceList,
type InvoiceListSort,
type InvoiceListSortColumn,
} from '@/lib/invoices/invoice-list-sort'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Plus,
ReceiptText,
Repeat,
FileInput,
FileDown,
} from 'lucide-react'
import { StartCard } from '@/components/dashboard/StartCard'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { FiscalPeriod, Invoice } from '@/types'
function NewInvoiceDialogLoading() {
const t = useTranslations('invoices')
// Non-modal + veil, matching NewInvoiceDialog: a modal fallback would lock
// the whole route (body pointer-events: none, no close path) if this chunk
// ever hangs or 404s on a stale deploy, and would dead-click the agent
// sheet meanwhile.
return (
<Dialog open modal={false}>
<DialogVeil />
<DialogContent className="sm:max-w-3xl">
<DialogTitle>{t('new_invoice')}</DialogTitle>
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-40" />
</div>
</DialogContent>
</Dialog>
)
}
const NewInvoiceDialog = dynamic(
() => import('@/components/invoices/NewInvoiceDialog'),
{ loading: NewInvoiceDialogLoading },
)
const RotRutPayoutDialog = dynamic(
() => import('@/components/invoices/RotRutPayoutDialog'),
)
const INITIAL_VISIBLE_ROWS = 100
const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const
// Main views (concept seg) and the low-frequency views behind "Fler …".
const SEG_TABS = ['all', 'unpaid', 'overdue', 'draft'] as const
const MORE_TABS = ['paid', 'proforma', 'delivery_note', 'credit', 'cancelled'] as const
const ALL_TABS = [...SEG_TABS, ...MORE_TABS]
type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number]
// The one status predicate: both the visible rows and the per-view counts in
// the ContextPicker go through it, so the annotation always matches what the
// view will show.
function matchesListTab(invoice: Invoice, tab: ListTab): boolean {
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
return (
(tab === 'all' && invoice.status !== 'cancelled') ||
(tab === 'unpaid' &&
['sent', 'overdue'].includes(invoice.status) &&
!isCreditNote &&
docType === 'invoice') ||
(tab === 'overdue' &&
invoice.status === 'overdue' &&
!isCreditNote &&
docType === 'invoice') ||
(tab === 'draft' &&
invoice.status === 'draft' &&
docType === 'invoice' &&
!isCreditNote) ||
(tab === 'paid' && invoice.status === 'paid') ||
(tab === 'credit' && isCreditNote) ||
(tab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
(tab === 'delivery_note' &&
docType === 'delivery_note' &&
invoice.status !== 'cancelled') ||
(tab === 'cancelled' && invoice.status === 'cancelled')
)
}
const TAB_LABEL_KEYS: Record<ListTab, string> = {
all: 'tab_all',
unpaid: 'tab_unpaid',
overdue: 'tab_overdue',
draft: 'tab_draft',
paid: 'tab_paid',
proforma: 'tab_proforma',
delivery_note: 'tab_delivery_note',
credit: 'tab_credit',
cancelled: 'tab_cancelled',
}
function daysOverdue(dueDateStr: string): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
return Math.round((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
}
interface SortableHeaderProps {
label: string
sortLabel: string
column: InvoiceListSortColumn
sort: InvoiceListSort | null
onSort: (column: InvoiceListSortColumn) => void
className?: string
align?: 'left' | 'right'
}
function SortableHeader({
label,
sortLabel,
column,
sort,
onSort,
className,
align = 'left',
}: SortableHeaderProps) {
const active = sort?.column === column
const direction = active ? sort.direction : null
const SortIcon = direction === 'asc' ? ArrowUp : direction === 'desc' ? ArrowDown : ArrowUpDown
return (
<th
className={cn(TH_CLASS, className)}
aria-sort={direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none'}
>
{/* Preflight sets text-transform: none on buttons, which would drop the
TH_CLASS uppercase idiom inside the sort control. */}
<button
type="button"
className={cn(
'-mx-2 inline-flex min-h-10 items-center gap-1 rounded-sm px-2 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
align === 'right' && 'ml-auto justify-end',
)}
aria-label={sortLabel}
onClick={() => onSort(column)}
>
<span>{label}</span>
<SortIcon
aria-hidden="true"
className={cn('h-3.5 w-3.5 shrink-0', !active && 'text-muted-foreground/60')}
/>
</button>
</th>
)
}
export default function InvoicesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const router = useRouter()
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<Invoice[]>([])
// Settings-driven gates from the session-cached settings row
// (lib/reference-data), derived instead of copied into state.
const { settings: companySettings } = useCompanySettings()
const oreRounding: boolean = companySettings?.ore_rounding ?? true
const rotRutEnabled: boolean = companySettings?.rot_rut_enabled ?? false
// Booking mode drives which rows are bulk-bookable (kontantmetoden: none).
const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
const [isBulkBooking, setIsBulkBooking] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [sort, setSort] = useState<InvoiceListSort | null>(null)
const [activeTab, setActiveTab] = useState<ListTab>(() => {
// Deep links from the worklist and older bookmarks: ?status= / ?tab=.
const param = searchParams.get('status') ?? searchParams.get('tab')
const alias: Record<string, ListTab> = { drafts: 'draft' }
const candidate = param ? (alias[param] ?? (param as ListTab)) : null
return candidate && ALL_TABS.includes(candidate as never)
? (candidate as ListTab)
: 'all'
})
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
// Fiscal-year scope (convention 8): null = all years.
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoices')
const tCommon = useTranslations('common')
const tStart = useTranslations('start_cards')
const { uiState, loaded: uiStateLoaded } = useUiState()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
// (the header button, empty states, the command palette, and the legacy
// /invoices/new redirect) opens the same dialog, and the browser back
// button closes it. No canWrite gate here: like the old /invoices/new page,
// the editor itself disables submission for viewers. ?self=1 preselects the
// självfaktura tab (split-button entry).
const copyFromId = searchParams.get('copy')
const showNewInvoice = searchParams.has('new') || copyFromId !== null
const openSelfBilled = searchParams.has('self')
const showRotRutPayout = searchParams.has('rot-rut')
// Open/close handlers rewrite only their own keys: a hardcoded '/invoices'
// would destroy the ?status= view write-back (and any other params) every
// time a dialog opens or closes.
const invoicesUrl = (mutate: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString())
mutate(params)
const qs = params.toString()
return qs ? `/invoices?${qs}` : '/invoices'
}
const closeNewInvoice = () =>
router.replace(
invoicesUrl((p) => {
p.delete('new')
p.delete('self')
p.delete('copy')
}),
{ scroll: false },
)
const openNewInvoice = () =>
router.push(invoicesUrl((p) => p.set('new', '1')), { scroll: false })
const openNewSelfBilled = () =>
router.push(
invoicesUrl((p) => {
p.set('new', '1')
p.set('self', '1')
}),
{ scroll: false },
)
const closeRotRutPayout = () =>
router.replace(invoicesUrl((p) => p.delete('rot-rut')), { scroll: false })
const openRotRutPayout = () =>
router.push(invoicesUrl((p) => p.set('rot-rut', '1')), { scroll: false })
// Begäran om utbetalning (Lag 2009:194 8 §) only concerns companies selling
// ROT/RUT-eligible work to consumers, so the action stays out of the header
// for everyone else. It appears once the company has invoiced a deduction
// (the payout can never precede that invoice), or once ROT/RUT is opted into
// in tax settings. Not scoped to the fiscal-year filter: a payout is claimed
// the year after payment, so last year's invoices are exactly the relevant
// ones. ?rot-rut=1 keeps working regardless, so nothing is unreachable.
const showRotRutAction =
rotRutEnabled || invoices.some((invoice) => (invoice.deduction_total ?? 0) > 0)
async function fetchInvoices() {
if (!company) return
// Skeleton takeover only while nothing is on screen: refetches after an
// action (bulk Bokför) reconcile BEHIND the rendered table. Collapsing
// hundreds of rows to 3 skeleton stubs and replaying the stagger-enter
// entrance for a row-scoped action was the "booking feels glitchy" jump.
if (invoices.length === 0) setIsLoading(true)
const [invoicesResult] = await Promise.allSettled([
fetchAllRows<Invoice>(
({ from, to }) =>
supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('company_id', company.id)
.order('invoice_date', { ascending: false })
.order('id', { ascending: false })
.range(from, to),
{ dedupeBy: (invoice) => invoice.id },
),
])
if (invoicesResult.status === 'rejected') {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
setInvoices(invoicesResult.value)
}
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const normalizedSearch = searchTerm.trim().toLocaleLowerCase('sv-SE')
// Search + fiscal-year scope, before the status view: the per-view counts
// in the ContextPicker are computed on this same base so they always equal
// the rows the view would show.
const scopedInvoices = useMemo(
() =>
invoices.filter((invoice) => {
const matchesSearch =
(invoice.invoice_number ?? '').toLocaleLowerCase('sv-SE').includes(normalizedSearch) ||
(invoice.external_invoice_number ?? '')
.toLocaleLowerCase('sv-SE')
.includes(normalizedSearch) ||
(invoice.customer as { name: string })?.name
?.toLocaleLowerCase('sv-SE')
.includes(normalizedSearch)
const matchesFy =
!fyPeriod ||
(invoice.invoice_date >= fyPeriod.period_start &&
invoice.invoice_date <= fyPeriod.period_end)
return matchesSearch && matchesFy
}),
[fyPeriod, invoices, normalizedSearch],
)
const filteredInvoices = useMemo(
() => scopedInvoices.filter((invoice) => matchesListTab(invoice, activeTab)),
[activeTab, scopedInvoices],
)
const sortedInvoices = useMemo(
() => (sort ? sortInvoiceList(filteredInvoices, sort, oreRounding) : filteredInvoices),
[filteredInvoices, oreRounding, sort],
)
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) {
for (const tab of ALL_TABS) {
if (matchesListTab(invoice, tab)) counts[tab] += 1
}
}
return counts
}, [scopedInvoices])
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
const updateSort = (column: InvoiceListSortColumn) => {
setSort((current) => ({
column,
direction:
current?.column === column && current.direction === 'asc' ? 'desc' : 'asc',
}))
resetPaging()
}
// Write the active view back to the URL (?status=) so views are shareable
// and survive back-navigation; the mount parser above already reads it.
// replace, not push: filter flips shouldn't stack history entries.
const updateTab = (tab: ListTab) => {
setActiveTab(tab)
resetPaging()
const params = new URLSearchParams(searchParams.toString())
params.delete('tab')
if (tab === 'all') params.delete('status')
else params.set('status', tab)
const qs = params.toString()
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
}
// Bulk Bokför eligibility. Kontantmetoden books at payment, so no row is
// selectable (the checkbox column is hidden entirely). Accrual companies
// that book at issue select drafts ("Bokför och markera som skickade");
// deferred companies (#967) select sent/overdue invoices that lack a
// verifikat (worklist-canonical predicate: journal_entry_id IS NULL).
const bulkMode: 'issue' | 'deferred' | null =
accountingMethod !== 'accrual' ? null : deferInvoiceBooking ? 'deferred' : 'issue'
const showSelection = canWrite && bulkMode !== null
const isBulkSelectable = (invoice: Invoice): boolean => {
if (!bulkMode) return false
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
if (docType !== 'invoice' || invoice.credited_invoice_id) return false
if (bulkMode === 'issue') return invoice.status === 'draft'
return ['sent', 'overdue'].includes(invoice.status) && !invoice.journal_entry_id
}
const selectableInvoices = filteredInvoices.filter(isBulkSelectable)
const allSelectableSelected =
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
// Ranges walk the selectable rows that are actually rendered: the list is
// sorted and cut at visibleCount, so rows below the fold are not in range.
const range = useRangeSelect({
visibleIds: visibleInvoices.filter(isBulkSelectable).map((inv) => inv.id),
selectedIds,
setSelectedIds,
})
function toggleSelect(id: string, extend?: boolean) {
range.toggle(id, extend)
}
const selectedInvoices = invoices.filter((inv) => selectedIds.has(inv.id))
const selectedDraftCount = selectedInvoices.filter((inv) => inv.status === 'draft').length
const selectedSentCount = selectedInvoices.length - selectedDraftCount
async function handleBulkBook() {
if (selectedIds.size === 0) return
setIsBulkBooking(true)
try {
const res = await fetch('/api/invoices/bulk-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selectedIds) }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status }))
const summary = json.data?.summary as { booked: number; failed: number } | undefined
const results = (json.data?.results ?? []) as Array<{ status: string; error?: string }>
// One aggregate toast (TOAST_LIMIT is 1), never per-row.
if (summary && summary.failed > 0) {
const firstErrors = results
.filter((r) => r.status === 'failed' && r.error)
.slice(0, 2)
.map((r) => r.error as string)
toast({
title: t('bulk_book_partial_title'),
description: [
t('bulk_book_partial_description', {
booked: summary.booked,
failed: summary.failed,
}),
...firstErrors,
].join(' '),
variant: 'destructive',
})
} else {
toast({
title: t('bulk_book_success_title'),
description: t('bulk_book_success_description', {
count: summary?.booked ?? selectedIds.size,
}),
})
}
setShowBulkBookConfirm(false)
setSelectedIds(new Set())
fetchInvoices()
} catch (err) {
toast({
title: t('bulk_book_failed_title'),
description: getErrorMessage(err),
variant: 'destructive',
})
} finally {
setIsBulkBooking(false)
}
}
const createOptions: SplitButtonOption[] = [
{
key: 'faktura',
label: t('new_invoice'),
icon: Plus,
description: t('create_invoice_desc'),
disabled: !canWrite,
disabledTitle: t('viewer_disabled_tooltip'),
onSelect: () => openNewInvoice(),
},
{
key: 'aterkommande',
label: t('create_recurring'),
icon: Repeat,
description: t('create_recurring_desc'),
onSelect: () => router.push('/invoices/recurring'),
},
{
key: 'sjalvfaktura',
label: t('create_self'),
icon: FileInput,
description: t('create_self_desc'),
disabled: !canWrite,
disabledTitle: t('viewer_disabled_tooltip'),
onSelect: () => openNewSelfBilled(),
},
]
// One derivable status chip per row (concept scene 15). Doc-type markers
// (proforma/följesedel/självfaktura) only appear in views where the type
// isn't already implied.
function statusDescriptor(invoice: Invoice): RowStatusDescriptor {
const isCreditNote = !!invoice.credited_invoice_id
if (invoice.status === 'cancelled') {
return { label: t('status_cancelled'), exception: true, variant: 'secondary' }
}
if (isCreditNote && invoice.status !== 'paid') {
return { label: t('badge_credit'), exception: true, variant: 'destructive' }
}
if (invoice.status === 'credited') {
return { label: t('status_credited'), exception: true, variant: 'secondary' }
}
if (invoice.status === 'draft') {
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isUnsent =
!!invoice.invoice_number && docType === 'invoice' && !isCreditNote && !invoice.is_self_billed
return isUnsent
? { label: t('status_unsent'), exception: true, variant: 'outline' }
: { label: t('status_draft'), exception: true, variant: 'secondary' }
}
if (invoice.status === 'paid') {
return {
label: invoice.paid_at
? t('status_paid_date', { date: formatDate(invoice.paid_at) })
: t('status_paid'),
}
}
if (invoice.status === 'partially_paid') {
return { label: t('status_partially_paid'), exception: true, variant: 'warning' }
}
if (invoice.status === 'overdue' && invoice.due_date) {
return {
label: t('status_overdue_days', { days: Math.max(1, daysOverdue(invoice.due_date)) }),
exception: true,
variant: 'warning',
}
}
return { label: t('status_sent') }
}
return (
<div className="space-y-8">
{/* Page header (concept scene 15): title + invoice actions */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<div className="flex flex-wrap items-center gap-2">
{showRotRutAction && (
<Button
type="button"
variant="outline"
onClick={openRotRutPayout}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<FileDown className="mr-2 h-4 w-4" />
{t('rot_rut_payout_action')}
</Button>
)}
<SplitButton
key={uiStateLoaded ? 'loaded' : 'initial'}
persistKey="invoices"
initialModeKey={resolveInitialMode(uiState, 'invoices', CREATE_MODES, 'faktura')}
options={createOptions}
/>
</div>
</div>
{/* Toolbar: one status chip-picker (founder direction: the status
views live behind a filter chip, not a seg), sök, FyPicker far
right. Counts ride as row annotations and on the trigger. */}
<div className="flex flex-wrap items-center gap-2">
<ContextPicker
value={activeTab}
onChange={(id) => updateTab(id as ListTab)}
ariaLabel={t('status_picker_aria')}
triggerLabel={
tabCounts[activeTab] > 0
? `${t(TAB_LABEL_KEYS[activeTab])} · ${tabCounts[activeTab]}`
: t(TAB_LABEL_KEYS[activeTab])
}
items={ALL_TABS.map((tab) => ({
id: tab,
label: t(TAB_LABEL_KEYS[tab]),
annotation: tabCounts[tab] > 0 ? String(tabCounts[tab]) : undefined,
}))}
/>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
resetPaging()
}}
/>
<div className="ml-auto">
<FyPicker
value={fyPeriodId}
onChange={(periodId, period) => {
setFyPeriodId(periodId)
setFyPeriod(period ?? null)
resetPaging()
}}
includeAllOption
/>
</div>
</div>
{/* Bulkbar: appears once anything is selected (supplier-invoices shape). */}
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2 text-[12.5px] animate-fade-in">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size })}
</span>
<Button size="sm" onClick={() => setShowBulkBookConfirm(true)}>
{bulkMode === 'issue'
? t('bulk_book_and_send_action', { count: selectedIds.size })
: t('bulk_book_action', { count: selectedIds.size })}
</Button>
{/* Hidden when the current view has no selectable rows: a
"Markera alla (0)" link would only wipe the selection. */}
{selectableInvoices.length > 0 && !allSelectableSelected && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => {
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
range.resetAnchor()
}}
>
{t('bulk_select_all', { count: selectableInvoices.length })}
</button>
)}
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => {
setSelectedIds(new Set())
range.resetAnchor()
}}
>
{t('bulk_clear')}
</button>
</div>
)}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-48 flex-1" />
<Skeleton className="h-5 w-24" />
</div>
))}
</div>
) : filteredInvoices.length === 0 ? (
searchTerm ? (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_search_results_title')}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : invoices.length === 0 ? (
<div className="animate-fade-in">
<StartCard
card="venice"
layout="center"
title={tStart('invoices_title')}
body={tStart('invoices_body')}
primary={{ label: tStart('invoices_primary'), href: '/import?mode=migration' }}
secondary={{ label: tStart('invoices_secondary'), onClick: openNewInvoice }}
/>
</div>
) : (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_category_title')}
description={t('no_category_description')}
/>
)
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{showSelection && (
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
)}
<SortableHeader
label={t('th_nr')}
sortLabel={t('sort_by', { column: t('th_nr') })}
column="number"
sort={sort}
onSort={updateSort}
/>
<SortableHeader
label={t('th_customer')}
sortLabel={t('sort_by', { column: t('th_customer') })}
column="customer"
sort={sort}
onSort={updateSort}
className="w-full"
/>
<SortableHeader
label={t('th_due')}
sortLabel={t('sort_by', { column: t('th_due') })}
column="due"
sort={sort}
onSort={updateSort}
className="hidden text-right sm:table-cell"
align="right"
/>
<SortableHeader
label={t('th_amount')}
sortLabel={t('sort_by', { column: t('th_amount') })}
column="amount"
sort={sort}
onSort={updateSort}
className="text-right"
align="right"
/>
<SortableHeader
label={t('th_status')}
sortLabel={t('sort_by', { column: t('th_status') })}
column="status"
sort={sort}
onSort={updateSort}
/>
</tr>
</thead>
<tbody className="stagger-enter">
{visibleInvoices.map((invoice) => {
const status = statusDescriptor(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const displayedTotal = getDisplayTotal(
{ total: Number(invoice.total), currency: invoice.currency, ore_rounding: invoice.ore_rounding },
{ ore_rounding: oreRounding },
).displayed
const number = invoice.is_self_billed
? invoiceDisplayNumber(invoice)
: invoice.invoice_number
// Doc-type marker only where the view doesn't already imply it.
const typeMarker =
activeTab === 'all'
? docType === 'proforma'
? t('badge_proforma')
: docType === 'delivery_note'
? t('badge_delivery_note')
: invoice.is_self_billed
? t('badge_self_billed')
: null
: null
return (
<tr
key={invoice.id}
className={cn(
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(invoice.id) && 'bg-secondary/40',
)}
onClick={() => {
rememberListContext()
router.push(`/invoices/${invoice.id}`)
}}
>
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
{showSelection && (
<td
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
onClick={(e) => e.stopPropagation()}
>
{isBulkSelectable(invoice) && (
<Checkbox
checked={selectedIds.has(invoice.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelect(invoice.id, shiftHeld.current)}
aria-label={t('bulk_select_row')}
className={cn(
'border-foreground duration-150',
selectedIds.has(invoice.id) || selectedIds.size > 0
? 'opacity-100'
: CHECKBOX_REVEAL_CLASS,
)}
/>
)}
</td>
)}
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<Link
href={`/invoices/${invoice.id}`}
className="hover:underline"
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{number ?? '·'}
</Link>
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="block truncate">
{(invoice.customer as { name: string })?.name ?? '-'}
</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{invoice.due_date && !isCreditNote && invoice.status !== 'draft'
? formatDate(invoice.due_date)
: ''}
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums rr-mask',
isCreditNote && 'text-destructive',
)}
title={
invoice.currency !== 'SEK' && invoice.total_sek
? formatCurrency(Number(invoice.total_sek))
: undefined
}
>
{formatCurrency(displayedTotal, invoice.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<span className="inline-flex items-center gap-1.5">
{typeMarker && (
<Badge variant="outline" className="font-normal">
{typeMarker}
</Badge>
)}
<RowStatus status={status} />
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{!isLoading && visibleCount < sortedInvoices.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
{showNewInvoice && (
<NewInvoiceDialog
open
copyFromId={copyFromId}
selfBilled={openSelfBilled}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
/>
)}
{showRotRutPayout && (
<RotRutPayoutDialog
open
canWrite={canWrite}
onOpenChange={(open) => {
if (!open) closeRotRutPayout()
}}
/>
)}
{/* Confirm-before-posting (convention 10): bulk Bokför writes immutable
verifikat, so describe the outcome first. */}
<ConfirmationDialog
open={showBulkBookConfirm}
onOpenChange={(open) => {
if (!isBulkBooking) setShowBulkBookConfirm(open)
}}
onConfirm={handleBulkBook}
isSubmitting={isBulkBooking}
title={t('bulk_book_confirm_title', { count: selectedIds.size })}
warningText={t('bulk_book_confirm_warning')}
confirmLabel={t('bulk_book_confirm_label')}
>
<div className="space-y-2 text-sm">
{selectedDraftCount > 0 && (
<p>{t('bulk_book_breakdown_drafts', { count: selectedDraftCount })}</p>
)}
{selectedSentCount > 0 && (
<p>{t('bulk_book_breakdown_sent', { count: selectedSentCount })}</p>
)}
</div>
</ConfirmationDialog>
</div>
)
}