feat(supplier-invoices): concept supplier-invoice list (UI migration PR 8) (#1129)

* feat(supplier-invoices): concept scene 21 for the list

Leverantorsfakturor becomes the concept dry-table: Leverantor, Fakturanr,
Fakturadatum, Forfaller, Belopp, Kvar att betala and one status chip per
row (Registrerad outline, Godkand beige, Betald + datum sage). Tabs turn
into the seg [Alla/Registrerade (n)/Godkanda/Att betala (n)/Betalda] with
search and the FyPicker far right. Godkann (attest) is a hover-revealed
quiet action on registered rows; there is deliberately no mark-as-paid
button (payments reconcile via bank matching), which the new help popover
after the H1 explains. Arrival number moves to the detail page.

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

* feat(supplier-invoices): status views behind one chip-picker

Same founder direction as Kundfakturor: the five-tab seg collapses into a
single status ContextPicker chip with Registrerade/Att betala counts on
the trigger and as row annotations.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 22:09:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 323abc57fe
commit 658c274d97
3 changed files with 251 additions and 156 deletions
+243 -154
View File
@@ -4,32 +4,36 @@ import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Skeleton } from "@/components/ui/skeleton"
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { DataListEmpty } from '@/components/ui/data-list'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, FileInput, Lock } from 'lucide-react'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { HelpPopover } from '@/components/ui/help-popover'
import { Plus, FileInput, Lock, Search } from 'lucide-react'
import Link from 'next/link'
import { PageHeader } from '@/components/ui/page-header'
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 { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice } from '@/types'
import type { FiscalPeriod, SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
() => import('@/components/supplier-invoices/NewSupplierInvoiceDialog'),
{ loading: DialogLoadingSkeleton },
)
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary',
approved: 'default',
// 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',
@@ -49,6 +53,17 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
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',
}
export default function SupplierInvoicesPage() {
const t = useTranslations('supplier_invoices')
const { canWrite } = useCanWrite()
@@ -57,7 +72,11 @@ export default function SupplierInvoicesPage() {
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
const [activeTab, setActiveTab] = useState<ListTab>('all')
const [searchTerm, setSearchTerm] = useState('')
// 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)
// The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1,
@@ -101,17 +120,34 @@ export default function SupplierInvoicesPage() {
// "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 an inline approve.
// for paying, not for visibility; unapproved rows get a hover approve.
const filteredInvoices = invoices.filter((inv) => {
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 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
})
const registeredCount = invoices.filter((inv) => inv.status === 'registered').length
const toPayCount = invoices.filter(
(inv) => inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue',
).length
async function handleApprove(id: string) {
setApprovingId(id)
try {
@@ -136,142 +172,195 @@ export default function SupplierInvoicesPage() {
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
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>
)
}
/>
{/* 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>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="registered">{t('tab_registered')}</TabsTrigger>
<TabsTrigger value="approved">{t('tab_approved')}</TabsTrigger>
<TabsTrigger value="to_pay">{t('tab_to_pay')}</TabsTrigger>
<TabsTrigger value="paid">{t('tab_paid')}</TabsTrigger>
</TabsList>
{/* 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,
}))}
/>
<div className="relative min-w-[190px] max-w-xs flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 pl-10"
/>
</div>
<div className="ml-auto">
<FyPicker
value={fyPeriodId}
onChange={(periodId, period) => {
setFyPeriodId(periodId)
setFyPeriod(period ?? null)
}}
includeAllOption
/>
</div>
</div>
<TabsContent value={activeTab}>
<Card>
<CardContent className="p-0">
{isLoading ? (
<div>
<div className="p-3 border-b border-border">
<Skeleton className="h-4 w-full" />
</div>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-4 p-3 border-b border-border last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20 ml-auto" />
<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'
? t('empty_description_all')
: t('empty_description_category')
}
action={
activeTab === 'all' && canWrite ? (
<Button onClick={openNewInvoice}>{t('register_invoice')}</Button>
) : undefined
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_arrival')}</TableHead>
<TableHead>{t('th_supplier')}</TableHead>
<TableHead>{t('th_invoice_number')}</TableHead>
<TableHead>{t('th_invoice_date')}</TableHead>
<TableHead>{t('th_due_date')}</TableHead>
<TableHead className="text-right">{t('th_amount')}</TableHead>
<TableHead className="text-right">{t('th_remaining')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody className="stagger-enter">
{filteredInvoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="tabular-nums">{inv.arrival_number}</TableCell>
<TableCell>
<Link href={`/suppliers/${inv.supplier_id}`} className="hover:underline">
{inv.supplier?.name || '-'}
</Link>
</TableCell>
<TableCell>
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
{inv.supplier_invoice_number}
</Link>
</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
{/* 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). */}
<TableCell className="text-right tabular-nums">
{formatCurrency(getDisplayTotal(
{ total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding },
{ ore_rounding: false },
).displayed, inv.currency)}
</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(inv.remaining_amount, inv.currency)}</TableCell>
<TableCell>
{activeTab === 'to_pay' && inv.status === 'registered' ? (
<div className="flex items-center gap-2">
<Badge variant="warning" className="whitespace-nowrap">{t('not_approved')}</Badge>
{!inv.is_credit_note && canWrite && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => handleApprove(inv.id)}
disabled={approvingId !== null}
>
{t('approve')}
</Button>
)}
</div>
) : (
<Badge variant={STATUS_VARIANTS[inv.status] || 'secondary'}>
{STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status}
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{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
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_supplier')}</th>
<th className={TH_CLASS}>{t('th_invoice_number')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_invoice_date')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_due_date')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'hidden text-right lg:table-cell')}>{t('th_remaining')}</th>
<th className={TH_CLASS}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'w-[96px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
{filteredInvoices.map((inv) => {
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
const canApprove =
inv.status === 'registered' && !inv.is_credit_note && canWrite
return (
<tr
key={inv.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/supplier-invoices/${inv.id}`)}
>
<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()}
>
{inv.supplier_invoice_number}
</Link>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
{formatDate(inv.invoice_date)}
</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 sensitive-field')}>
{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 sensitive-field lg:table-cell')}>
{formatCurrency(inv.remaining_amount, inv.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<Badge variant={chipVariant} className="font-normal">
{chipLabel}
</Badge>
</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>
)
})}
</tbody>
</table>
</div>
)}
{showNewInvoice && (
<NewSupplierInvoiceDialog
+4 -1
View File
@@ -658,7 +658,10 @@
"approve_failed_title": "Approval failed",
"approved_title": "Approved",
"approved_description": "The invoice has been approved",
"status_picker_aria": "Filter by status"
"status_picker_aria": "Filter by status",
"search_placeholder": "Search supplier invoices …",
"status_paid_date": "Paid {date}",
"help_body": "Approval attests the invoice for payment. Payments are reconciled automatically when they are matched against the bank, so there is no \"mark as paid\" button here."
},
"purchase_orders": {
"title": "Purchase orders",
+4 -1
View File
@@ -658,7 +658,10 @@
"approve_failed_title": "Godkännande misslyckades",
"approved_title": "Godkänd",
"approved_description": "Fakturan har godkänts",
"status_picker_aria": "Filtrera på status"
"status_picker_aria": "Filtrera på status",
"search_placeholder": "Sök leverantörsfaktura …",
"status_paid_date": "Betald {date}",
"help_body": "Godkännandet attesterar fakturan för betalning. Betalningar prickas av automatiskt när de matchas mot banken, så det finns ingen \"markera som betald\"-knapp här."
},
"purchase_orders": {
"title": "Inköpsorder",