From 658c274d972f4d32a797493ab6070ae386449a9b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:09:16 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/supplier-invoices/page.tsx | 397 +++++++++++++-------- messages/en.json | 5 +- messages/sv.json | 5 +- 3 files changed, 251 insertions(+), 156 deletions(-) diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index f2c731e7..faa1623b 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -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 = { - 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 = { + registered: 'outline', + approved: 'secondary', paid: 'success', partially_paid: 'warning', overdue: 'destructive', @@ -49,6 +53,17 @@ const STATUS_LABEL_KEYS: Record = { reversed: 'status_reversed', } +const TABS = ['all', 'registered', 'approved', 'to_pay', 'paid'] as const +type ListTab = (typeof TABS)[number] + +const TAB_LABEL_KEYS: Record = { + 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('all') + const [searchTerm, setSearchTerm] = useState('') + // Fiscal-year scope (convention 8): null = all years. + const [fyPeriodId, setFyPeriodId] = useState(null) + const [fyPeriod, setFyPeriod] = useState(null) const [approvingId, setApprovingId] = useState(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 (
- - - {t('register_invoice')} - - ) : ( - - ) - } - /> + {/* 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. */} +
+ +

{t('title')}

+ {t('help_body')} +
+ {canWrite ? ( + + ) : ( + + )} +
- {/* Tabs */} - - - {t('tab_all')} - {t('tab_registered')} - {t('tab_approved')} - {t('tab_to_pay')} - {t('tab_paid')} - + {/* 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. */} +
+ 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, + }))} + /> +
+ + setSearchTerm(e.target.value)} + className="h-9 pl-10" + /> +
+
+ { + setFyPeriodId(periodId) + setFyPeriod(period ?? null) + }} + includeAllOption + /> +
+
- - - - {isLoading ? ( -
-
- -
- {[1, 2, 3, 4].map((i) => ( -
- - - - - - - -
- ))} -
- ) : filteredInvoices.length === 0 ? ( - } - title={t('empty_title')} - description={ - activeTab === 'all' - ? t('empty_description_all') - : t('empty_description_category') - } - action={ - activeTab === 'all' && canWrite ? ( - - ) : undefined - } - /> - ) : ( - - - - {t('th_arrival')} - {t('th_supplier')} - {t('th_invoice_number')} - {t('th_invoice_date')} - {t('th_due_date')} - {t('th_amount')} - {t('th_remaining')} - {t('th_status')} - - - - {filteredInvoices.map((inv) => ( - - {inv.arrival_number} - - - {inv.supplier?.name || '-'} - - - - - {inv.supplier_invoice_number} - - - {formatDate(inv.invoice_date)} - {formatDate(inv.due_date)} - {/* 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). */} - - {formatCurrency(getDisplayTotal( - { total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding }, - { ore_rounding: false }, - ).displayed, inv.currency)} - - {formatCurrency(inv.remaining_amount, inv.currency)} - - {activeTab === 'to_pay' && inv.status === 'registered' ? ( -
- {t('not_approved')} - {!inv.is_credit_note && canWrite && ( - - )} -
- ) : ( - - {STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status} - - )} -
-
- ))} -
-
- )} -
-
-
-
+ {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ + + + +
+ ))} +
+ ) : filteredInvoices.length === 0 ? ( + } + title={t('empty_title')} + description={ + activeTab === 'all' && !searchTerm + ? t('empty_description_all') + : t('empty_description_category') + } + action={ + activeTab === 'all' && !searchTerm && canWrite ? ( + + ) : undefined + } + /> + ) : ( +
+ + + + + + + + + + + + + + + {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 ( + router.push(`/supplier-invoices/${inv.id}`)} + > + + + + + {/* 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). */} + + + + {/* Attest as a hover action on registered rows (concept): + approval gates payment, so it lives right on the row. */} + + + ) + })} + +
{t('th_supplier')}{t('th_invoice_number')}{t('th_invoice_date')}{t('th_due_date')}{t('th_amount')}{t('th_remaining')}{t('th_status')}
+ {inv.supplier?.name || '-'} + + e.stopPropagation()} + > + {inv.supplier_invoice_number} + + + {formatDate(inv.invoice_date)} + + {formatDate(inv.due_date)} + + {formatCurrency(getDisplayTotal( + { total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding }, + { ore_rounding: false }, + ).displayed, inv.currency)} + + {formatCurrency(inv.remaining_amount, inv.currency)} + + + {chipLabel} + + e.stopPropagation()} + > + {canApprove && ( + + )} +
+
+ )} {showNewInvoice && (