Files
accounted/app/(dashboard)/customers/page.tsx
T
bjornbergenheimandClaude Opus 5 c091a7f28e fix(ui): clip the flexible list cell on the rows #2003 missed (#2284)
#2003 gave TransactionInboxCard overflow-hidden because its shrink-0 row
markers cannot truncate: past the width that fits them the cell painted
over the Belopp column instead of clipping. Six other rows carry the same
`max-w-0 w-full` flexible cell with shrink-0 chips inside and never got
the guard, so they still overlap the neighbouring column.

Reproduced on /transactions with a Skatteverket row whose booking
suggestion is long ("Bokförs mot 2731 Avräkning lagstadgade sociala
avgifter"): the text runs straight through the amount. Seen in both
Chrome and Firefox, so this is not engine specific; Firefox only reaches
it sooner, because it ignores the max-w-0 cap on a td when sizing
columns (CSS 2.1 10.4 leaves max-width on table cells undefined).

Rows fixed: the skattekonto inbox row, both rows in the transaction
history list, the verifikat list, the chart of accounts, the customer
list and the salary run list. ChartOfAccountsManager's second flexible
cell is left alone: its only child truncates, so it has nothing that can
overflow.

Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:07:33 +02:00

435 lines
16 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import { useCompanySettings, useCustomers } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { SuggestionsAttn } from '@/components/parties/SuggestionsAttn'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { customerListIdentifier } from '@/lib/customers/mask-personal-number'
const CustomerForm = dynamic(
() => import('@/components/customers/CustomerForm'),
{
loading: () => (
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</div>
),
},
)
const CUSTOMER_TYPE_LABEL_KEYS: Record<CustomerType, string> = {
individual: 'type_individual',
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
type SortColumn = 'name' | 'customer_type' | 'identifier' | 'email' | 'city' | 'created_at'
type SortDir = 'asc' | 'desc'
const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'name',
'customer_type',
'identifier',
'email',
'city',
'created_at',
]
const INITIAL_VISIBLE_ROWS = 100
// Business rows show org_number; individual rows show the masked
// personnummer the API returns, and a legacy individual row that still
// carries its personnummer in org_number shows that masked too, never raw.
function getIdentifier(customer: Customer): string {
return customerListIdentifier(customer)
}
function compareStrings(a: string, b: string): number {
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
}
function CustomersPageInner() {
const { canWrite } = useCanWrite()
// The roster from the session cache (lib/reference-data): /api/customers
// masks the personnummer column server-side (see the note that used to sit
// on fetchCustomers), the list is shared with every customer picker, and
// a revisit renders from cache. Skeleton only on the very first load.
const { customers, isLoading: customersLoading, error: customersError } = useCustomers()
const isLoading = customersLoading && customers.length === 0
const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
// Company default payment terms (Inställningar → Fakturering). Prefilled
// into the new-customer form so it opens on the company's own default
// instead of a hardcoded 30.
// Default payment terms from the session-cached settings row; the dialog
// falls back to 30 until (or unless) the company set one.
const { settings: companySettings } = useCompanySettings()
const companyDefaultTerms =
typeof companySettings?.invoice_default_days === 'number' && companySettings.invoice_default_days > 0
? companySettings.invoice_default_days
: null
const { toast } = useToast()
const t = useTranslations('customers')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const sortParam = searchParams.get('sort')
const dirParam = searchParams.get('dir')
const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray<string>).includes(sortParam ?? '')
? (sortParam as SortColumn)
: 'name'
const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc'
const updateSort = useCallback(
(column: SortColumn) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
nextDir = sortDir === 'asc' ? 'desc' : 'asc'
}
params.set('sort', column)
params.set('dir', nextDir)
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[searchParams, sortColumn, sortDir, router, pathname]
)
useEffect(() => {
if (!customersError) return
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
}, [customersError, toast, t])
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreating(true)
const response = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
toast({
title: t('create_failed_title'),
description: getErrorMessage(result, { context: 'customer', locale: errorLocale }),
variant: 'destructive',
})
} else {
toast({
title: t('created_title'),
description: t('created_description', { name: data.name }),
})
// Every picker shares the cached list: refresh it instead of patching
// this page's copy.
await invalidateReferenceData('ref:customers')
setIsDialogOpen(false)
}
setIsCreating(false)
}
const filteredCustomers = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) return customers
return customers.filter((c) => {
return (
c.name.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term) ||
c.org_number?.includes(term) ||
// The masked form, so this matches the last four digits. Against the
// raw ciphertext it matched nothing, which read as "search is broken"
// for anyone looking up a private customer by personnummer.
c.personal_number?.includes(term) ||
c.city?.toLowerCase().includes(term) ||
c.notes?.toLowerCase().includes(term)
)
})
}, [customers, searchTerm])
const sortedCustomers = useMemo(() => {
const arr = [...filteredCustomers]
arr.sort((a, b) => {
let av = ''
let bv = ''
switch (sortColumn) {
case 'name':
av = a.name || ''
bv = b.name || ''
break
case 'customer_type':
av = a.customer_type || ''
bv = b.customer_type || ''
break
case 'identifier':
av = getIdentifier(a)
bv = getIdentifier(b)
break
case 'email':
av = a.email || ''
bv = b.email || ''
break
case 'city':
av = a.city || ''
bv = b.city || ''
break
case 'created_at':
av = a.created_at || ''
bv = b.created_at || ''
break
}
const cmp = compareStrings(av, bv)
return sortDir === 'asc' ? cmp : -cmp
})
return arr
}, [filteredCustomers, sortColumn, sortDir])
const visibleCustomers = sortedCustomers.slice(0, visibleCount)
// Sortable dry-table head: the button inherits the uppercase hairline
// styling from TH_CLASS on the th.
function SortableHeader({
column,
label,
className,
}: {
column: SortColumn
label: string
className?: string
}) {
const isActive = sortColumn === column
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
return (
<th className={cn(TH_CLASS, className)}>
<button
type="button"
onClick={() => updateSort(column)}
className="inline-flex items-center gap-1 uppercase transition-colors duration-150 hover:text-foreground"
>
{label}
<Icon className="h-3 w-3 opacity-70" aria-hidden="true" />
</button>
</th>
)
}
return (
<div className="space-y-8">
{/* Page header (concept scene 25): title + export + Ny kund */}
<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>
<div className="flex items-center gap-2">
<ReportExportMenu
size="default"
items={[
{ format: 'xlsx', href: '/api/export/customers' },
{ format: 'csv', href: '/api/export/customers?format=csv' },
]}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
{t('new_customer')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('add_customer')}</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleCreateCustomer}
isLoading={isCreating}
initialData={
companyDefaultTerms !== null
? { default_payment_terms: companyDefaultTerms }
: undefined
}
/>
</DialogContent>
</Dialog>
</div>
</div>
<SuggestionsAttn side="customer" />
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
<ToolbarSearch
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
/>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : sortedCustomers.length === 0 ? (
searchTerm ? (
<EmptyState
icon={Users}
title={t('no_search_results_title')}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
)
) : (
<>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<SortableHeader column="name" label={t('col_name')} className="w-full" />
<SortableHeader column="customer_type" label={t('col_type')} />
<SortableHeader
column="identifier"
label={t('col_identifier')}
className="hidden lg:table-cell"
/>
<SortableHeader
column="email"
label={t('col_email')}
className="hidden md:table-cell"
/>
<SortableHeader
column="city"
label={t('col_city')}
className="hidden sm:table-cell"
/>
</tr>
</thead>
<tbody className="stagger-enter">
{visibleCustomers.map((customer) => {
const identifier = getIdentifier(customer)
return (
<tr
key={customer.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/customers/${customer.id}`)}
>
{/* overflow-hidden: see #2003, the shrink-0 verified
badge cannot truncate. */}
<td className={cn(TD_CLASS, 'max-w-0 w-full overflow-hidden')}>
<span className="flex min-w-0 items-center gap-2">
<Link
href={`/customers/${customer.id}`}
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{customer.name}
</Link>
{customer.org_number && customer.vat_number_validated && (
<Badge variant="success" className="shrink-0 font-normal">
{t('verified')}
</Badge>
)}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-muted-foreground')}>
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground lg:table-cell')}>
{identifier || ''}
</td>
<td className={cn(TD_CLASS, 'hidden max-w-[220px] truncate text-muted-foreground md:table-cell')}>
{customer.email || ''}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground sm:table-cell')}>
{customer.city || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Footer note (concept pgnote) */}
<p className="px-1 text-xs text-muted-foreground tabular-nums">
{t('count_summary', { count: customers.length })}
</p>
{visibleCount < sortedCustomers.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</>
)}
</div>
)
}
export default function CustomersPage() {
return (
<Suspense
fallback={
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-10 w-32" />
</div>
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
}
>
<CustomersPageInner />
</Suspense>
)
}