Files
accounted/app/(dashboard)/invoices/page.tsx
T
Jakob WennbergandClaude Opus 4.8 36e3f6ceb0 Design critique: normalize daily-operator flows + UX improvements (#741)
* style(design): normalize daily-operator flows to locked design system

Sweep the dashboard, transactions, reconciliation, invoicing and supplier
flows for design-system violations (.claude/rules/design.md):

- font-medium removed from Hedvig display headings/numerals
- font-mono -> tabular-nums on monetary values (voucher ids stay mono)
- raw Tailwind status colors -> Badge variants / muted-alert pattern
- semantic colors removed from chrome backgrounds (deadline widgets, icon halos)
- hand-rolled skeletons/empty-states -> Skeleton / EmptyState primitives
- opacity-suffixed borders, the invisible warning-foreground count color, and
  shadow-sm/rounded-xl on non-overlay surfaces normalized

The four files that also received UX changes carry their token fixes in the
following commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ux): clearer dashboard CTA, match confidence, AI provenance, invoice actions

Four high-impact UX fixes from the design critique (these files also carry their
design-system token normalization):

- Dashboard: render the next-best-action hero for every agent-built company, not
  only 'slim' nav density, so there is always one obvious next step instead of
  four equal-weight metric tiles.
- Reconciliation: surface the match engine's 0-1 confidence as a graded strength
  badge (Stark / Trolig / Svag traff) in the shared verifikat picker rows and
  selected chip; drop the uninformative binary "Foreslagen traff" badge from the
  match dialog.
- Supplier inbox: show AI-filled provenance per extracted field (a success dot
  that clears once the user verifies/edits the value) so misparsed amounts/dates
  get proofread before they post to an immutable verifikat.
- Invoice detail: keep each status's primary action only in the header row; the
  sidebar "Status actions" card now holds secondary/reversible actions only
  (makulera, ta bort, skapa kreditnota, manual-send alternative), removing the
  duplicated CTAs and closing a viewer-permission gap on the old sidebar buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ux): Tier-B medium design-critique fixes across daily-operator flows

- Reconciliation: standardise the match-confirm verb on "Matcha" (was "Koppla"
  in MatchVoucherDialog) and replace the hand-rolled date <input>s in the bank
  reconciliation view with the Input primitive.
- Supplier inbox: fold the two alternative bookings (Skapa leverantorsfaktura /
  Bokfor som verifikat) behind a single "Andra satt att bokfora" dropdown so the
  default path (Matcha mot transaktion) stays the lone primary action.
- Duplicate-payment guard: demote the "Skapa ny verifikation anda" escape hatch
  to a ghost button so the safe "Koppla till befintlig" path dominates.
- Onboarding: raise the "start fresh" escape hatch from a muted text link to a
  visible secondary button; normalise the checklist's off-scale spacing.
- Supplier flows: finish the font-mono -> tabular-nums sweep on monetary values
  in the supplier-invoice detail / create / review surfaces (ids stay mono).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(deadlines): keep overdue rows visually distinct (destructive chrome is allowed)

The Tier-A normalization stripped all semantic-color row tints from the deadline
widgets, but design.md exempts --destructive ("only --destructive survives in
chrome"). Restore a subtle bg-destructive/5 on OVERDUE rows so missed tax/AGI
deadlines (-> skattetillagg) stay noticeable in a list scan; action-needed
(warning) rows stay clean since warning is data-only. Surfaced by the Swedish
compliance review on #741.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): hide match-strength badge on already-matched verifikat

Per the Swedish compliance review on #741: a green "Stark traff" confidence badge
rendered alongside "Redan matchad" could visually nudge an accidental double-match
of a posted verifikat (a BFL 5 kap audit-trail concern). Suppress the strength
badge when linked_transaction_count > 0 so "Redan matchad" is the lone signal
there; N:1 matching stays an explicit opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:23:33 +02:00

387 lines
16 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { PageHeader } from '@/components/ui/page-header'
import { Skeleton } from '@/components/ui/skeleton'
import {
DataList,
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
DataListEmpty,
} from '@/components/ui/data-list'
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, Receipt, Lock, Repeat } 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'
type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
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' },
}
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
}
}
export default function InvoicesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoices')
const getRelativeTimeLabel = useRelativeTimeLabel()
async function fetchInvoices() {
if (!company) return
setIsLoading(true)
const [invoicesResult, settingsResult] = await Promise.all([
supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('company_id', company.id)
.order('invoice_date', { ascending: false }),
supabase
.from('company_settings')
.select('ore_rounding')
.eq('company_id', company.id)
.maybeSingle(),
])
if (invoicesResult.error) {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
setInvoices(invoicesResult.data || [])
}
setOreRounding(settingsResult.data?.ore_rounding ?? true)
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
}, [])
const filteredInvoices = invoices.filter((invoice) => {
const matchesSearch =
(invoice.invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(invoice.external_invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
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 === '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)
return matchesSearch && matchesTab
})
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,
}
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 ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
</Link>
) : (
<Button
disabled
title={t('viewer_disabled_tooltip')}
>
<Lock className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
)}
</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">
<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="pl-10"
/>
</div>
{/* Mobile: dropdown select */}
<Select value={activeTab} onValueChange={setActiveTab}>
<SelectTrigger className="sm:hidden w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('tab_all')}</SelectItem>
<SelectItem value="unpaid">{t('tab_unpaid')}</SelectItem>
<SelectItem value="paid">{t('tab_paid')}</SelectItem>
<SelectItem value="draft">{t('tab_draft')}</SelectItem>
<SelectItem value="proforma">{t('tab_proforma')}</SelectItem>
<SelectItem value="delivery_note">{t('tab_delivery_note')}</SelectItem>
<SelectItem value="credit">{t('tab_credit')}</SelectItem>
<SelectItem value="cancelled">{t('tab_cancelled')}</SelectItem>
</SelectContent>
</Select>
{/* Desktop: tab bar */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="hidden sm:block">
<TabsList>
<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>
{isLoading ? (
[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-5 w-24" />
</div>
))
) : filteredInvoices.length === 0 ? (
searchTerm ? (
<DataListEmpty
icon={<Receipt className="h-6 w-6" />}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices />
) : (
<DataListEmpty
icon={<Receipt className="h-6 w-6" />}
title={t('no_category_title')}
description={t('no_category_description')}
/>
)
) : (
filteredInvoices.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
const relativeTime = invoice.due_date ? 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'
)}
>
{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>
)}
</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]"
>
{t(statusLabelKey)}
</Badge>
{isCreditNote && (
<>
<DataListMetaSeparator />
<Badge variant="destructive" className="h-4 px-1.5 py-0 text-[10px]">
{t('badge_credit')}
</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>
</div>
)
}