feat(invoices): concept customer-invoice list (UI migration PR 7) (#1128)

* feat(invoices): concept scene 15 for the customer invoice list

Kundfakturor becomes the concept dry-table: Nr, Kund, Forfaller, Belopp
and ONE derivable status chip per row (Utkast/Ej skickad/Skickad/Forfallen
N dgr/Betald datum/Delbetald/Kredit/Makulerad). The eight tabs collapse to
the seg [Alla/Obetalda/Forfallna (n)/Utkast] with the low-frequency views
(Proforma, Foljesedlar, Kreditfakturor, Makulerade) behind a quiet
'Fler ...' menu, plus search and the FyPicker far right. Create paths
collapse into the Ny faktura split button (Ny/Aterkommande/Sjalvfaktura,
last-used mode persisted); sjalvfaktura preselects the editor's tab via
?new=1&self=1. The summary line under the header is dropped per the plan.

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

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

Founder direction: the Alla/Obetalda/Forfallna/Utkast seg and the
'Fler ...' menu collapse into a single status ContextPicker chip with the
overdue count on the trigger and as a row annotation. All eight views,
same filtering semantics.

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

* fix(invoices): audit round: Betalda view, viewer states, deep links

Regression-audit fixes: the Betalda view returns as a status-picker row
(it existed on main and was dropped in the seg redesign); the create
options render disabled with the viewer tooltip for read-only members
instead of silently no-opping; and the page answers ?status=/?tab= deep
links again (the Att gora worklist links /invoices?status=unpaid).

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 21:59:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 008b4710d3
commit 323abc57fe
5 changed files with 333 additions and 276 deletions
+284 -268
View File
@@ -9,28 +9,25 @@ import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
DataList,
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
DataListEmpty,
} from '@/components/ui/data-list'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
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 { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { Plus, Search, ReceiptText, Repeat, FileInput } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
import type { FiscalPeriod, Invoice, InvoiceStatus } from '@/types'
function NewInvoiceDialogLoading() {
const t = useTranslations('invoices')
@@ -53,42 +50,33 @@ const NewInvoiceDialog = dynamic(
{ loading: NewInvoiceDialogLoading },
)
type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
const INITIAL_VISIBLE_ROWS = 100
const STATUS_CONFIG: Record<InvoiceStatus, { labelKey: string; variant: InvoiceStatusVariant }> = {
draft: { labelKey: 'status_draft', variant: 'secondary' },
sent: { labelKey: 'status_sent', variant: 'default' },
paid: { labelKey: 'status_paid', variant: 'success' },
partially_paid: { labelKey: 'status_partially_paid', variant: 'warning' },
overdue: { labelKey: 'status_overdue', variant: 'destructive' },
cancelled: { labelKey: 'status_cancelled', variant: 'secondary' },
credited: { labelKey: 'status_credited', variant: 'secondary' },
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
type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number]
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 useRelativeTimeLabel() {
const t = useTranslations('invoices')
return function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
if (status === 'paid' || status === 'cancelled' || status === 'credited' || status === 'draft') return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { text: t('due_days_overdue', { days: Math.abs(diffDays) }), color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: t('due_today'), color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: t('due_days_left', { days: diffDays }), color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: t('due_days_left', { days: diffDays }), color: 'text-muted-foreground' }
}
return null
}
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))
}
export default function InvoicesPage() {
@@ -100,23 +88,37 @@ export default function InvoicesPage() {
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
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 && [...SEG_TABS, ...MORE_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 getRelativeTimeLabel = useRelativeTimeLabel()
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.
// 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 closeNewInvoice = () => router.replace('/invoices', { scroll: false })
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
const openNewSelfBilled = () => router.push('/invoices?new=1&self=1', { scroll: false })
async function fetchInvoices() {
if (!company) return
@@ -149,6 +151,7 @@ export default function InvoicesPage() {
useEffect(() => {
fetchInvoices()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filteredInvoices = invoices.filter((invoice) => {
@@ -157,263 +160,275 @@ export default function InvoicesPage() {
(invoice.external_invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
const matchesFy =
!fyPeriod ||
(invoice.invoice_date >= fyPeriod.period_start && invoice.invoice_date <= fyPeriod.period_end)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const matchesTab =
(activeTab === 'all' && invoice.status !== 'cancelled') ||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
(activeTab === 'overdue' && invoice.status === 'overdue' && !isCreditNote && docType === 'invoice') ||
(activeTab === 'draft' && invoice.status === 'draft' && docType === 'invoice' && !isCreditNote) ||
(activeTab === 'paid' && invoice.status === 'paid') ||
(activeTab === 'credit' && isCreditNote) ||
(activeTab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
(activeTab === 'delivery_note' && docType === 'delivery_note' && invoice.status !== 'cancelled') ||
(activeTab === 'cancelled' && invoice.status === 'cancelled') ||
(activeTab !== 'all' && activeTab !== 'proforma' && activeTab !== 'delivery_note' && activeTab !== 'cancelled' && invoice.status === activeTab)
(activeTab === 'cancelled' && invoice.status === 'cancelled')
return matchesSearch && matchesTab
return matchesSearch && matchesFy && matchesTab
})
const visibleInvoices = filteredInvoices.slice(0, visibleCount)
const isOutstandingReceivable = (i: Invoice) =>
['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id
const stats = {
unpaid: invoices.filter(isOutstandingReceivable).length,
unpaidAmount: invoices
.filter(isOutstandingReceivable)
.reduce((sum, i) => {
if (i.currency === 'SEK') {
return sum + getDisplayTotal({ total: Number(i.total), currency: 'SEK', ore_rounding: i.ore_rounding }, { ore_rounding: oreRounding }).displayed
}
return sum + Number(i.total_sek || i.total)
}, 0),
overdue: invoices.filter((i) => i.status === 'overdue' && !i.credited_invoice_id).length,
const overdueCount = invoices.filter(
(i) => i.status === 'overdue' && !i.credited_invoice_id,
).length
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
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 statusChip(invoice: Invoice): { label: string; variant: 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' } {
const isCreditNote = !!invoice.credited_invoice_id
if (invoice.status === 'cancelled') return { label: t('status_cancelled'), variant: 'secondary' }
if (isCreditNote && invoice.status !== 'paid') return { label: t('badge_credit'), variant: 'destructive' }
if (invoice.status === 'credited') return { label: t('status_credited'), 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'), variant: 'outline' }
: { label: t('status_draft'), variant: 'secondary' }
}
if (invoice.status === 'paid') {
return {
label: invoice.paid_at
? t('status_paid_date', { date: formatDate(invoice.paid_at) })
: t('status_paid'),
variant: 'success',
}
}
if (invoice.status === 'partially_paid') return { label: t('status_partially_paid'), variant: 'warning' }
if (invoice.status === 'overdue' && invoice.due_date) {
return {
label: t('status_overdue_days', { days: Math.max(1, daysOverdue(invoice.due_date)) }),
variant: 'warning',
}
}
return { label: t('status_sent'), variant: 'outline' }
}
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<div className="flex gap-2">
<Link href="/invoices/recurring">
<Button variant="secondary">
<Repeat className="mr-2 h-4 w-4" />
{t('recurring')}
</Button>
</Link>
{canWrite ? (
<Button onClick={openNewInvoice}>
<Plus className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
) : (
<Button
disabled
title={t('viewer_disabled_tooltip')}
>
<Lock className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
)}
</div>
}
/>
{/* Page header (concept scene 15): title + Ny faktura split button */}
<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>
<SplitButton
key={uiStateLoaded ? 'loaded' : 'initial'}
persistKey="invoices"
initialModeKey={resolveInitialMode(uiState, 'invoices', CREATE_MODES, 'faktura')}
options={createOptions}
/>
</div>
{/* Inline summary */}
{!isLoading && invoices.length > 0 && (
<p className="text-sm text-muted-foreground tabular-nums">
{invoices.length === 1 ? t('summary_one', { count: invoices.length }) : t('summary_other', { count: invoices.length })}
{stats.unpaid > 0 && (
<>
{' · '}
<span className="text-foreground">{t('summary_unpaid', { count: stats.unpaid })}</span>
{' · '}
{t('summary_to_collect', { amount: formatCurrency(stats.unpaidAmount) })}
{stats.overdue > 0 && (
<>
{' · '}
<span className="text-destructive">{t('summary_overdue', { count: stats.overdue })}</span>
</>
)}
</>
)}
</p>
)}
{/* Search and tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
{/* 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)
resetPaging()
}}
ariaLabel={t('status_picker_aria')}
triggerLabel={
activeTab === 'overdue' && overdueCount > 0
? `${t(TAB_LABEL_KEYS[activeTab])} · ${overdueCount}`
: t(TAB_LABEL_KEYS[activeTab])
}
items={[...SEG_TABS, ...MORE_TABS].map((tab) => ({
id: tab,
label: t(TAB_LABEL_KEYS[tab]),
annotation:
tab === 'overdue' && overdueCount > 0 ? String(overdueCount) : 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)
setVisibleCount(INITIAL_VISIBLE_ROWS)
resetPaging()
}}
className="pl-10"
className="h-9 pl-10"
/>
</div>
<div className="ml-auto">
<FyPicker
value={fyPeriodId}
onChange={(periodId, period) => {
setFyPeriodId(periodId)
setFyPeriod(period ?? null)
resetPaging()
}}
includeAllOption
/>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="min-w-0"
>
<TabsList className="w-max max-w-full justify-start">
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="unpaid">{t('tab_unpaid')}</TabsTrigger>
<TabsTrigger value="paid">{t('tab_paid')}</TabsTrigger>
<TabsTrigger value="draft">{t('tab_draft')}</TabsTrigger>
<TabsTrigger value="proforma">{t('tab_proforma')}</TabsTrigger>
<TabsTrigger value="delivery_note">{t('tab_delivery_note')}</TabsTrigger>
<TabsTrigger value="credit">{t('tab_credit')}</TabsTrigger>
<TabsTrigger value="cancelled">{t('tab_cancelled')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<DataList className="stagger-enter">
{isLoading ? (
[1, 2, 3].map((i) => (
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-48 flex-1" />
<Skeleton className="h-5 w-24" />
</div>
))
) : filteredInvoices.length === 0 ? (
searchTerm ? (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices onAction={openNewInvoice} />
) : (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_category_title')}
description={t('no_category_description')}
/>
)
))}
</div>
) : filteredInvoices.length === 0 ? (
searchTerm ? (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices onAction={openNewInvoice} />
) : (
visibleInvoices.map((invoice) => {
const status = STATUS_CONFIG[invoice.status]
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
// A draft that already has a number is issued-but-unsent ("Granska &
// skapa" done, "Skicka" pending): distinct from a true unnumbered
// draft. Show "Ej skickad" so the two don't look alike. Display-only.
const isUnsentInvoice =
invoice.status === 'draft' &&
!!invoice.invoice_number &&
!isProforma &&
!isDeliveryNote &&
!isCreditNote &&
!invoice.is_self_billed
const statusLabelKey = isUnsentInvoice ? 'status_unsent' : status.labelKey
const statusVariant: InvoiceStatusVariant | 'outline' = isUnsentInvoice ? 'outline' : status.variant
// Credit notes are never payable (invoices_credit_note_not_paid),
// so a due-date countdown ("X dagar försenad") is meaningless for them.
const relativeTime = invoice.due_date && !isCreditNote ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
const displayedTotal = getDisplayTotal(
{ total: Number(invoice.total), currency: invoice.currency, ore_rounding: invoice.ore_rounding },
{ ore_rounding: oreRounding },
).displayed
return (
<Link key={invoice.id} href={`/invoices/${invoice.id}`} className="block focus:outline-none">
<DataListRow
trailing={
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isCreditNote && 'text-destructive'
)}
<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>
<th className={TH_CLASS}>{t('th_nr')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_customer')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_due')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={TH_CLASS}>{t('th_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{visibleInvoices.map((invoice) => {
const chip = statusChip(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="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/invoices/${invoice.id}`)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<Link
href={`/invoices/${invoice.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{formatCurrency(displayedTotal, invoice.currency)}
</p>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<p
className={cn(
'mt-1 text-[11px] tabular-nums',
isCreditNote ? 'text-destructive/70' : 'text-muted-foreground'
)}
>
{formatCurrency(Number(invoice.total_sek))}
</p>
{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 sensitive-field',
isCreditNote && 'text-destructive',
)}
</div>
}
>
<DataListPrimary className={cn(!invoice.invoice_number && !invoice.external_invoice_number && 'italic text-muted-foreground')}>
{invoice.is_self_billed ? invoiceDisplayNumber(invoice) : (invoice.invoice_number ?? '-')}{' '}
<span className="font-normal text-muted-foreground">
· {(invoice.customer as { name: string })?.name}
</span>
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
<DataListMetaSeparator />
<Badge
variant={statusVariant as 'default' | 'secondary' | 'destructive' | 'outline'}
className="h-4 px-1.5 py-0 text-[10px]"
title={
invoice.currency !== 'SEK' && invoice.total_sek
? formatCurrency(Number(invoice.total_sek))
: undefined
}
>
{t(statusLabelKey)}
</Badge>
{isCreditNote && (
<>
<DataListMetaSeparator />
<Badge variant="destructive" className="h-4 px-1.5 py-0 text-[10px]">
{t('badge_credit')}
{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>
)}
<Badge variant={chip.variant} className="font-normal">
{chip.label}
</Badge>
</>
)}
{isProforma && (
<>
<DataListMetaSeparator />
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
{t('badge_proforma')}
</Badge>
</>
)}
{isDeliveryNote && (
<>
<DataListMetaSeparator />
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
{t('badge_delivery_note')}
</Badge>
</>
)}
{invoice.is_self_billed && (
<>
<DataListMetaSeparator />
<Badge variant="outline" className="h-4 px-1.5 py-0 text-[10px]">
{t('badge_self_billed')}
</Badge>
</>
)}
{relativeTime && (
<>
<DataListMetaSeparator />
<span className={cn('font-medium', relativeTime.color)}>
{relativeTime.text}
</span>
</>
)}
</DataListMeta>
</DataListRow>
</Link>
)
})
)}
</DataList>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{!isLoading && visibleCount < filteredInvoices.length && (
<div className="flex justify-center">
@@ -431,6 +446,7 @@ export default function InvoicesPage() {
<NewInvoiceDialog
open
copyFromId={copyFromId}
selfBilled={openSelfBilled}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
+9 -2
View File
@@ -81,7 +81,12 @@ export type InvoiceEditorProps = (
| { mode?: 'create' }
| { mode: 'edit'; initial: InvoiceForEdit }
| { mode: 'copy'; initial: InvoiceCopyInitial }
) & { bare?: boolean }
) & {
bare?: boolean
/** Open with the självfaktura tab preselected (the "Självfaktura" entry in
* the invoice list's split button). Create mode only. */
initialSelfBilled?: boolean
}
// Subset of Article fields the line picker needs to pre-fill a row.
type ArticleOption = Pick<
@@ -127,7 +132,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// Toggle between a normal customer invoice (default) and registering a
// self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§).
// Self-billing is never available when editing an existing draft.
const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice')
const [mode, setMode] = useState<'invoice' | 'self_billed'>(
props.initialSelfBilled && !isEditMode ? 'self_billed' : 'invoice',
)
// Company-wide opt-in from the invoice settings page: the whole payment
// link section (manual field + Stripe auto toggle) stays hidden until the
// company enables it. The send routes enforce the same setting server-side
+4 -2
View File
@@ -34,6 +34,8 @@ interface Props {
open: boolean
onOpenChange: (open: boolean) => void
copyFromId?: string | null
/** Preselect the självfaktura tab (split-button entry on /invoices). */
selfBilled?: boolean
}
/**
@@ -47,7 +49,7 @@ interface Props {
* shows the invoice-number preview: a static DialogTitle would duplicate or
* contradict it.
*/
export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) {
export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null, selfBilled = false }: Props) {
const t = useTranslations('invoice_editor')
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
@@ -146,7 +148,7 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
</div>
)
) : (
<InvoiceEditor mode="create" bare />
<InvoiceEditor mode="create" bare initialSelfBilled={selfBilled} />
)}
</DialogContent>
</Dialog>
+18 -2
View File
@@ -657,7 +657,8 @@
"approve": "Approve",
"approve_failed_title": "Approval failed",
"approved_title": "Approved",
"approved_description": "The invoice has been approved"
"approved_description": "The invoice has been approved",
"status_picker_aria": "Filter by status"
},
"purchase_orders": {
"title": "Purchase orders",
@@ -5026,7 +5027,22 @@
"status_credited": "Credited",
"due_days_overdue": "{days} days overdue",
"due_today": "Due today",
"due_days_left": "{days} days left"
"due_days_left": "{days} days left",
"tab_overdue": "Overdue",
"more_views": "More …",
"th_nr": "No.",
"th_customer": "Customer",
"th_due": "Due",
"th_amount": "Amount",
"th_status": "Status",
"create_invoice_desc": "Regular customer invoice",
"create_recurring": "Recurring invoice",
"create_recurring_desc": "Created automatically, e.g. every month",
"create_self": "Self-billing invoice",
"create_self_desc": "Register a self-billing invoice from your customer",
"status_overdue_days": "Overdue {days} d",
"status_paid_date": "Paid {date}",
"status_picker_aria": "Filter by status"
},
"dashboard": {
"skv_promo_title": "Connect Skatteverket",
+18 -2
View File
@@ -657,7 +657,8 @@
"approve": "Godkänn",
"approve_failed_title": "Godkännande misslyckades",
"approved_title": "Godkänd",
"approved_description": "Fakturan har godkänts"
"approved_description": "Fakturan har godkänts",
"status_picker_aria": "Filtrera på status"
},
"purchase_orders": {
"title": "Inköpsorder",
@@ -5026,7 +5027,22 @@
"status_credited": "Krediterad",
"due_days_overdue": "{days} dagar försenad",
"due_today": "Förfaller idag",
"due_days_left": "{days} dagar kvar"
"due_days_left": "{days} dagar kvar",
"tab_overdue": "Förfallna",
"more_views": "Fler …",
"th_nr": "Nr",
"th_customer": "Kund",
"th_due": "Förfaller",
"th_amount": "Belopp",
"th_status": "Status",
"create_invoice_desc": "Vanlig kundfaktura",
"create_recurring": "Återkommande faktura",
"create_recurring_desc": "Skapas automatiskt, till exempel varje månad",
"create_self": "Självfaktura",
"create_self_desc": "Registrera en självfaktura från din kund",
"status_overdue_days": "Förfallen {days} dgr",
"status_paid_date": "Betald {date}",
"status_picker_aria": "Filtrera på status"
},
"dashboard": {
"skv_promo_title": "Koppla Skatteverket",