Files
accounted/app/(dashboard)/supplier-invoices/page.tsx
T
Jakob Wennberg 68fee7dbe7 fix(supplier-invoices): fit the list inside the content column (#2262) (#2281)
The Leverantörsfakturor table was 1133px wide at every desktop size while
the content column is at most 960px (max-w-5xl minus px-8) and 948px on a
1280-wide laptop. With nowrap cells every column adds its widest header
or cell to the table's minimum width, so the overflow-x-auto wrapper
scrolled sideways: Leverantör collapsed to its header width (129px) and
the Status chips were cut at the right edge, with the Godkänn column off
screen. Measured with the real page rendered under /sandbox at 1280,
1366, 1440, 1536 and 1920 wide.

The list carried two date columns plus "Kvar att betala" on top of what
the customer invoice list shows, and #2091's always-visible sort control
added ~18px to each of seven headers, which tipped an already tight
budget over the column. Viewport breakpoints cannot help because the
column is capped at 960px regardless of screen size.

- Drop the Fakturadatum column from the list: förfaller is the payer's
  date and the default order, and the invoice date lives in the detail
  view (the customer invoice list has no invoice-date column either).
  The sort comparator keeps invoice_date as its tie-break; only the
  header goes.
- Shorten the sv header "Kvar att betala" to "Kvar": the label was 163px
  for a column whose numbers need ~120px.
- Leave a column-budget comment on the table and one sentence in the
  dry-table design rule, since there is no shared list component to fix:
  every page-level list hand-writes the overflow-x-auto wrapper, and the
  three overflow reports had three different causes.

After the change the table measures 948/960px (equal to its wrapper) with
worst-case data (16-char invoice numbers, seven-digit amounts, two chips
on one row), and Leverantör keeps 142-154px even then.

Fixes #2262


Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:18:50 +02:00

846 lines
35 KiB
TypeScript

'use client'
import { Fragment, useMemo, useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { groupRows } from '@/lib/lists/group-rows'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
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 { HelpPopover } from '@/components/ui/help-popover'
import { Plus, FileInput, Lock, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
import Link from 'next/link'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useToast } from '@/components/ui/use-toast'
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 {
sortSupplierInvoiceList,
type SupplierInvoiceListSort,
type SupplierInvoiceListSortColumn,
} from '@/lib/supplier-invoices/supplier-invoice-list-sort'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { FiscalPeriod, SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
() => import('@/components/supplier-invoices/NewSupplierInvoiceDialog'),
{ loading: DialogLoadingSkeleton },
)
const PaymentFileDialog = dynamic(
() => import('@/components/supplier-invoices/PaymentFileDialog'),
{ loading: DialogLoadingSkeleton },
)
// Rough client-side gate for the payment-file bulk selection: the statuses
// mark-paid accepts, SEK only, something left to pay, not a credit note. The
// preview re-evaluates server-side (payee, OCR, active batches), so this only
// decides which rows get a checkbox.
function isBatchSelectable(inv: SupplierInvoice): boolean {
return (
['registered', 'approved', 'partially_paid', 'overdue'].includes(inv.status) &&
!inv.is_credit_note &&
inv.currency === 'SEK' &&
inv.remaining_amount > 0.005
)
}
// One derivable chip per row (concept scene 21): Registrerad is the "waiting
// for attest" state (outline), Godkänd the beige ready-to-pay state; paid is
// the sage exception-free end state.
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
registered: 'outline',
approved: 'secondary',
paid: 'success',
partially_paid: 'warning',
overdue: 'destructive',
disputed: 'warning',
credited: 'secondary',
reversed: 'secondary',
}
const STATUS_LABEL_KEYS: Record<string, string> = {
registered: 'status_registered',
approved: 'status_approved',
paid: 'status_paid',
partially_paid: 'status_partially_paid',
overdue: 'status_overdue',
disputed: 'status_disputed',
credited: 'status_credited',
reversed: 'status_reversed',
}
const TABS = ['all', 'registered', 'approved', 'to_pay', 'paid'] as const
type ListTab = (typeof TABS)[number]
const TAB_LABEL_KEYS: Record<ListTab, string> = {
all: 'tab_all',
registered: 'tab_registered',
approved: 'tab_approved',
to_pay: 'tab_to_pay',
paid: 'tab_paid',
}
// Same shape as the invoices list header (app/(dashboard)/invoices/page.tsx).
// Like the verifikat list (and unlike /invoices, which starts unsorted), this
// list has a meaningful default order (förfallodatum stigande from the API),
// so the click cycle is tri-state: asc → desc → back to the default.
interface SortableHeaderProps {
label: string
sortLabel: string
column: SupplierInvoiceListSortColumn
sort: SupplierInvoiceListSort | null
onSort: (column: SupplierInvoiceListSortColumn) => 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>
)
}
// Row grouping (same pattern as the customer invoice list): 'none' (the flat
// list) is the default; the other modes are opt-in via ?group=, and 'status'
// reproduces the payment-queue sections.
/** Mirrors PAYABLE_STATUSES in lib/invoices/bulk-reconcile-supplier-vouchers.ts:
* a partially paid invoice still belongs in the payment queue. */
const AWAITING_PAYMENT_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
const isAwaitingPayment = (status: string | null | undefined) =>
!!status && AWAITING_PAYMENT_STATUSES.includes(status)
const STATUS_SECTION_ORDER = ['awaiting', 'settled'] as const
/** Sentinel bucket for rows with no supplier or no date. */
const UNKNOWN_GROUP_KEY = 'unknown'
const GROUP_MODES = ['status', 'supplier', 'month', 'none'] as const
type GroupMode = (typeof GROUP_MODES)[number]
const GROUP_LABEL_KEYS: Record<GroupMode, string> = {
status: 'group_status',
supplier: 'group_supplier',
month: 'group_month',
none: 'group_none',
}
export default function SupplierInvoicesPage() {
const t = useTranslations('supplier_invoices')
const locale = useLocale()
const { canWrite } = useCanWrite()
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')
const [searchTerm, setSearchTerm] = useState('')
// null = the API's default order (förfallodatum stigande).
const [sort, setSort] = useState<SupplierInvoiceListSort | null>(null)
const [groupMode, setGroupMode] = useState<GroupMode>(() => {
const param = searchParams.get('group')
return param && GROUP_MODES.includes(param as never) ? (param as GroupMode) : 'none'
})
// Fiscal-year scope (convention 8): null = all years.
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
const [approvingId, setApprovingId] = useState<string | null>(null)
// Payment-file bulk selection + the "already in an active betalfil" chip map.
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 [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState<Set<string>>(new Set())
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
// The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1,
// optionally with inbox_item_id for the invoice-inbox conversion flow) so
// every entry point (the header button, the empty state, the command
// palette, and the legacy /supplier-invoices/new redirect) opens the same
// dialog, and the browser back button closes it.
const showNewInvoice = searchParams.has('new')
const inboxItemId = searchParams.get('inbox_item_id')
const closeNewInvoice = () => router.replace('/supplier-invoices', { scroll: false })
const openNewInvoice = () => router.push('/supplier-invoices?new=1', { scroll: false })
async function fetchInvoices() {
// Skeleton takeover only while nothing is on screen: refetches after an
// action (register, betalfil, approve fallback) reconcile BEHIND the
// rendered table instead of collapsing it to 4 skeleton stubs and
// replaying the entrance animation.
if (invoices.length === 0) setIsLoading(true)
try {
const res = await fetch('/api/supplier-invoices?status=all')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { data } = await res.json()
setInvoices(data || [])
} catch {
// Without this, a failed fetch either stuck the skeleton forever or
// silently rendered the empty state as if the invoices were gone.
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
// Which invoices already sit in an active (not cancelled) betalfil: feeds
// the "I betalfil" chip. Non-blocking; the list renders without it.
async function fetchActiveBatchMembership() {
try {
const res = await fetch('/api/supplier-invoices/payment-batches?status=created')
if (!res.ok) return
const { data } = await res.json()
const ids = new Set<string>()
for (const batch of (data ?? []) as Array<{ supplier_invoice_ids?: string[] }>) {
for (const id of batch.supplier_invoice_ids ?? []) ids.add(id)
}
setActiveBatchInvoiceIds(ids)
} catch {
// Chip data only; the list stays functional without it.
}
}
useEffect(() => {
fetchInvoices()
fetchActiveBatchMembership()
// Mount-only fetch (same pattern as /invoices): fetchInvoices reads state
// only to decide skeleton vs background refresh.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Mirrors the old standalone page's post-create navigation: inbox
// conversions land back in the inbox, a created invoice opens its detail
// page, and flows that end here (e.g. private expense) close the modal and
// refresh the list in place.
const handleCreated = (invoiceId?: string) => {
if (inboxItemId) {
router.push('/e/general/invoice-inbox')
return
}
if (invoiceId) {
router.push(`/supplier-invoices/${invoiceId}`)
return
}
closeNewInvoice()
fetchInvoices()
}
// "Att betala" is the full payment queue: registered invoices are already
// booked as debt (2440), so they belong here too. Approval stays the gate
// for paying, not for visibility; unapproved rows get a hover approve.
const filteredInvoices = invoices.filter((inv) => {
const matchesTab = (() => {
switch (activeTab) {
case 'registered': return inv.status === 'registered'
case 'approved': return inv.status === 'approved'
case 'to_pay': return inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue'
case 'paid': return inv.status === 'paid'
default: return true
}
})()
const query = searchTerm.trim().toLowerCase()
const matchesSearch =
!query ||
(inv.supplier?.name ?? '').toLowerCase().includes(query) ||
(inv.supplier_invoice_number ?? '').toLowerCase().includes(query) ||
String(inv.arrival_number ?? '').includes(query)
const matchesFy =
!fyPeriod ||
(inv.invoice_date >= fyPeriod.period_start && inv.invoice_date <= fyPeriod.period_end)
return matchesTab && matchesSearch && matchesFy
})
// Tri-state cycle: asc → desc → back to the API default (due date asc).
const updateSort = (column: SupplierInvoiceListSortColumn) => {
setSort((current) => {
if (current?.column !== column) return { column, direction: 'asc' }
return current.direction === 'asc' ? { column, direction: 'desc' } : null
})
}
const sortedInvoices = sort ? sortSupplierInvoiceList(filteredInvoices, sort) : filteredInvoices
// Grouping: bucket the sorted rows through the shared helper and flatten
// back so paging, range selection and the detail pager walk the exact
// rendered order. Order is never touched here: for a payment queue the
// API's forfallodatum-ascending default is the order that matters (what
// falls due first goes first), and an active column sort governs the rest.
const { orderedInvoices, rowGroupKeys, groupMeta } = useMemo(() => {
const keys = new Map<string, string | null>()
if (groupMode === 'none') {
for (const inv of sortedInvoices) keys.set(inv.id, null)
return {
orderedInvoices: sortedInvoices,
rowGroupKeys: keys,
groupMeta: new Map<string, { label: string; count: number }>(),
}
}
const grouped =
groupMode === 'status'
? groupRows(sortedInvoices, {
keyOf: (inv) => {
const key = isAwaitingPayment(inv.status) ? 'awaiting' : 'settled'
return { key, label: key }
},
order: STATUS_SECTION_ORDER,
})
: groupMode === 'supplier'
? groupRows(sortedInvoices, {
keyOf: (inv) => {
const label = inv.supplier?.name ?? UNKNOWN_GROUP_KEY
// Bucket by id, not display name: two suppliers can share one.
return { key: inv.supplier_id ?? label, label }
},
order: (a, b) => a.label.localeCompare(b.label, 'sv'),
})
: groupRows(sortedInvoices, {
keyOf: (inv) => {
const key = (inv.invoice_date ?? '').slice(0, 7) || UNKNOWN_GROUP_KEY
return { key, label: key }
},
order: (a, b) => b.key.localeCompare(a.key),
})
const flat: typeof sortedInvoices = []
for (const entry of grouped.rows) {
keys.set(entry.row.id, entry.groupKey)
flat.push(entry.row)
}
return { orderedInvoices: flat, rowGroupKeys: keys, groupMeta: grouped.meta }
}, [groupMode, sortedInvoices])
// Status sections only earn headers when there is more than one of them;
// supplier/month grouping is an explicit ask, so headers always show.
const showGroupHeaders = groupMode !== 'none' && (groupMode !== 'status' || groupMeta.size > 1)
const monthFormatter = useMemo(
() => new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'sv-SE', { month: 'long', year: 'numeric' }),
[locale],
)
function groupHeaderLabel(key: string): string {
const meta = groupMeta.get(key)
const count = meta?.count ?? 0
if (groupMode === 'status') {
return key === 'awaiting' ? t('section_awaiting', { count }) : t('section_settled', { count })
}
if (groupMode === 'month' && key !== UNKNOWN_GROUP_KEY) {
const label = monthFormatter.format(new Date(`${key}-01T00:00:00`))
return `${label.charAt(0).toLocaleUpperCase('sv-SE')}${label.slice(1)} (${count})`
}
if (key === UNKNOWN_GROUP_KEY) return `${t('group_unknown')} (${count})`
return `${meta?.label ?? key} (${count})`
}
const updateGroup = (mode: GroupMode) => {
setGroupMode(mode)
const params = new URLSearchParams(searchParams.toString())
// Flat is the default, so it owns the URL-less state; every other mode
// is written out so it round-trips through reload and back-navigation.
if (mode === 'none') params.delete('group')
else params.set('group', mode)
const qs = params.toString()
router.replace(qs ? `/supplier-invoices?${qs}` : '/supplier-invoices', { scroll: false })
}
// Detail-pager context: the list as rendered (filtered + sectioned +
// sorted), written when the user navigates into a row.
const rememberListContext = () => {
writeListContext(listContextKey('supplier-invoices', company?.id), {
ids: orderedInvoices.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',
).length
const selectableInvoices = filteredInvoices.filter(isBatchSelectable)
const allSelectableSelected =
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
// Ranges walk the selectable rows in rendered order: sorted, then sectioned
// by the active grouping, which is what the user sees on screen.
const range = useRangeSelect({
visibleIds: orderedInvoices.filter(isBatchSelectable).map((inv) => inv.id),
selectedIds,
setSelectedIds,
})
function toggleSelect(id: string, extend?: boolean) {
range.toggle(id, extend)
}
// Labels the excluded rows in the payment dialog ("Derome CD3014794407"),
// so a server-side exclusion never reads as a bare UUID.
const invoiceLabelById = new Map(
invoices.map((inv) => [
inv.id,
`${inv.supplier?.name ?? ''} ${inv.supplier_invoice_number}`.trim(),
]),
)
const handleBatchCreated = () => {
setSelectedIds(new Set())
fetchInvoices()
fetchActiveBatchMembership()
}
async function handleApprove(id: string) {
setApprovingId(id)
try {
const res = await fetch(`/api/supplier-invoices/${id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: t('approve_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
// Re-sync from the server: an operator about to pay must see the
// invoice's true approval state, not an optimistic guess.
fetchInvoices()
} else {
toast({ title: t('approved_title'), description: t('approved_description') })
// Trust the server's status: an attested invoice that is still past due
// stays labelled 'overdue' rather than flipping to 'approved'. An
// incomplete payload is not an excuse to invent either field: the row an
// operator is about to pay must show real state, so re-read instead.
const approved = result?.data as Partial<SupplierInvoice> | undefined
if (!approved?.status || !approved.approved_at) {
fetchInvoices()
return
}
setInvoices((prev) =>
prev.map((inv) =>
inv.id === id
? { ...inv, status: approved.status!, approved_at: approved.approved_at! }
: inv,
),
)
}
} catch {
toast({ title: t('approve_failed_title'), description: getErrorMessage(null, { context: 'supplier_invoice' }), variant: 'destructive' })
fetchInvoices()
} finally {
setApprovingId(null)
}
}
return (
<div className="space-y-8">
{/* Page header (concept scene 21): title + help + primary action.
The help popover carries the payment model (convention 7): approval
attests for payment; payments reconcile via bank matching, so there
is deliberately no mark-as-paid button here. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-center gap-2">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<HelpPopover>{t('help_body')}</HelpPopover>
</span>
{canWrite ? (
<Button onClick={openNewInvoice}>
<Plus className="mr-2 h-4 w-4" />
{t('register_invoice')}
</Button>
) : (
<Button disabled title={t('viewer_disabled_tooltip')}>
<Lock className="mr-2 h-4 w-4" />
{t('register_invoice')}
</Button>
)}
</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) => setActiveTab(id as ListTab)}
ariaLabel={t('status_picker_aria')}
triggerLabel={(() => {
const count =
activeTab === 'registered' ? registeredCount : activeTab === 'to_pay' ? toPayCount : 0
return count > 0
? `${t(TAB_LABEL_KEYS[activeTab])} · ${count}`
: t(TAB_LABEL_KEYS[activeTab])
})()}
items={TABS.map((tab) => ({
id: tab,
label: t(TAB_LABEL_KEYS[tab]),
annotation:
tab === 'registered' && registeredCount > 0
? String(registeredCount)
: tab === 'to_pay' && toPayCount > 0
? String(toPayCount)
: undefined,
}))}
/>
<ContextPicker
value={groupMode}
onChange={(id) => updateGroup(id as GroupMode)}
ariaLabel={t('group_picker_aria')}
triggerLabel={`${t('group_by')} · ${t(GROUP_LABEL_KEYS[groupMode])}`}
items={GROUP_MODES.map((mode) => ({
id: mode,
label: t(GROUP_LABEL_KEYS[mode]),
}))}
/>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="ml-auto flex items-center gap-4">
<Link href="/supplier-invoices/payment-files" className={QUIET_LINK_CLASS}>
{t('payment_files_link')}
</Link>
<FyPicker
value={fyPeriodId}
onChange={(periodId, period) => {
setFyPeriodId(periodId)
setFyPeriod(period ?? null)
}}
includeAllOption
/>
</div>
</div>
{/* Bulkbar: appears once anything is selected (transactions-page 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.5 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={() => setShowPaymentDialog(true)}>
{t('bulk_create_file')}
</Button>
{!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, 4].map((i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20 flex-1" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
) : filteredInvoices.length === 0 ? (
<DataListEmpty
icon={<FileInput className="h-6 w-6" />}
title={t('empty_title')}
description={
activeTab === 'all' && !searchTerm
? t('empty_description_all')
: t('empty_description_category')
}
action={
activeTab === 'all' && !searchTerm && canWrite ? (
<Button onClick={openNewInvoice}>{t('register_invoice')}</Button>
) : undefined
}
/>
) : (
/* Column budget (#2262): the content column is at most 960px (max-w-5xl
minus px-8) and 948px on a 1280-wide laptop, at every desktop size,
so viewport breakpoints cannot buy room. Every nowrap column adds its
widest header or cell to the table's minimum width; past the budget
the wrapper scrolls sideways, Leverantör collapses to its header
width and Status is cut at the edge. That is why the list carries
one date (förfaller: the payer's date and the default order) and a
short Kvar header; fakturadatum lives in the detail view. */
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{canWrite && <th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>}
<SortableHeader
label={t('th_supplier')}
sortLabel={t('sort_by', { column: t('th_supplier') })}
column="supplier"
sort={sort}
onSort={updateSort}
className="w-full"
/>
<SortableHeader
label={t('th_invoice_number')}
sortLabel={t('sort_by', { column: t('th_invoice_number') })}
column="number"
sort={sort}
onSort={updateSort}
/>
<SortableHeader
label={t('th_due_date')}
sortLabel={t('sort_by', { column: t('th_due_date') })}
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_remaining')}
sortLabel={t('sort_by', { column: t('th_remaining') })}
column="remaining"
sort={sort}
onSort={updateSort}
className="hidden text-right lg:table-cell"
align="right"
/>
<SortableHeader
label={t('th_status')}
sortLabel={t('sort_by', { column: t('th_status') })}
column="status"
sort={sort}
onSort={updateSort}
/>
<th className={cn(TH_CLASS, 'w-[96px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
{orderedInvoices.map((inv, rowIndex) => {
const chipVariant = STATUS_VARIANTS[inv.status] || 'secondary'
const chipLabel =
inv.status === 'paid' && inv.paid_at
? t('status_paid_date', { date: formatDate(inv.paid_at) })
: STATUS_LABEL_KEYS[inv.status]
? t(STATUS_LABEL_KEYS[inv.status])
: inv.status
// Aged-but-unapproved invoices sit on 'overdue' (the cron flips
// them there), so attest keys off approved_at, not the status.
const canApprove =
canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite
const selectable = canWrite && isBatchSelectable(inv)
// Same shape as the customer list: the section header is a
// sibling row decided from the previous row's key.
const groupKey = rowGroupKeys.get(inv.id) ?? null
const prevKey =
rowIndex > 0 ? rowGroupKeys.get(orderedInvoices[rowIndex - 1].id) ?? null : undefined
const showHeader = showGroupHeaders && groupKey !== null && groupKey !== prevKey
return (
<Fragment key={inv.id}>
{showHeader && (
<tr data-no-stagger>
<td
colSpan={canWrite ? 9 : 8}
className={cn(
'border-b border-border px-1 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground',
rowIndex === 0 ? 'pt-4' : 'pt-6',
)}
>
{groupHeaderLabel(groupKey)}
</td>
</tr>
)}
<tr
className={cn(
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(inv.id) && 'bg-secondary/40',
)}
onClick={() => {
rememberListContext()
router.push(`/supplier-invoices/${inv.id}`)
}}
>
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
{canWrite && (
<td
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
<Checkbox
checked={selectedIds.has(inv.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelect(inv.id, shiftHeld.current)}
aria-label={t('bulk_select_row')}
className={cn(
'border-foreground duration-150',
selectedIds.has(inv.id) || selectedIds.size > 0
? 'opacity-100'
: CHECKBOX_REVEAL_CLASS,
)}
/>
)}
</td>
)}
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="block truncate">{inv.supplier?.name || '-'}</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<Link
href={`/supplier-invoices/${inv.id}`}
className="hover:underline"
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{inv.supplier_invoice_number}
</Link>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{formatDate(inv.due_date)}
</td>
{/* Belopp rounds like the detail page when the invoice's
öresavrundning flag is on; "kvar att betala" stays
öre-exact (it is the actual outstanding debt). */}
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
{formatCurrency(getDisplayTotal(
{ total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding },
{ ore_rounding: false },
).displayed, inv.currency)}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums rr-mask lg:table-cell')}>
{formatCurrency(inv.remaining_amount, inv.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<span className="inline-flex items-center gap-1">
<Badge variant={chipVariant} className="font-normal">
{chipLabel}
</Badge>
{activeBatchInvoiceIds.has(inv.id) && inv.status !== 'paid' && (
<Badge variant="outline" className="font-normal">
{t('in_batch_chip')}
</Badge>
)}
</span>
</td>
{/* Attest as a hover action on registered rows (concept):
approval gates payment, so it lives right on the row. */}
<td
className={cn(TD_CLASS, 'whitespace-nowrap text-right')}
onClick={(e) => e.stopPropagation()}
>
{canApprove && (
<button
type="button"
className={cn(
QUIET_LINK_CLASS,
'opacity-0 transition-opacity duration-150 focus-visible:opacity-100 group-hover:opacity-100',
approvingId !== null && 'pointer-events-none opacity-50',
)}
onClick={() => handleApprove(inv.id)}
>
{t('approve')}
</button>
)}
</td>
</tr>
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
{showNewInvoice && (
<NewSupplierInvoiceDialog
open
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
inboxItemId={inboxItemId}
onCreated={handleCreated}
/>
)}
{showPaymentDialog && (
<PaymentFileDialog
open
onOpenChange={(open) => {
if (!open) setShowPaymentDialog(false)
}}
invoiceIds={Array.from(selectedIds)}
invoiceLabelById={invoiceLabelById}
onCreated={handleBatchCreated}
/>
)}
</div>
)
}