'use client' import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' 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 { Input } from '@/components/ui/input' 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, Search, 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 type { Customer, CustomerType, CreateCustomerInput } from '@/types' const CustomerForm = dynamic( () => import('@/components/customers/CustomerForm'), { loading: () => (
), }, ) const CUSTOMER_TYPE_LABEL_KEYS: Record = { 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 = [ 'name', 'customer_type', 'identifier', 'email', 'city', 'created_at', ] const INITIAL_VISIBLE_ROWS = 100 function getIdentifier(customer: Customer): string { return customer.org_number || customer.personal_number || '' } function compareStrings(a: string, b: string): number { return a.localeCompare(b, 'sv', { sensitivity: 'base' }) } function CustomersPageInner() { const { canWrite } = useCanWrite() const [customers, setCustomers] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) 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).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] ) /** * Read the roster through the API, not straight from Supabase. * * personal_number holds AES-256-GCM ciphertext (migration 20260726110000). * A browser-side select('*') handed this page 76 to 82 hex characters and * getIdentifier() rendered them into the nowrap identifier cell, which is * what shredded the table layout for companies with private customers. * GET /api/customers maps every row through maskCustomerRow, so the * ciphertext now never leaves the server and the column shows the same * '********-1234' the detail view does. * * No `company` guard: the route resolves the active company server-side, so * the fetch no longer has to wait for CompanyContext to hydrate. The old * guard could leave the list empty on a slow context load, because the * effect below runs once and never retries. */ async function fetchCustomers() { setIsLoading(true) try { const response = await fetch('/api/customers') if (!response.ok) throw new Error('Failed to load customers') const { data } = await response.json() setCustomers(data || []) } catch { toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive', }) } finally { setIsLoading(false) } } useEffect(() => { fetchCustomers() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) 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 }), }) setCustomers([...customers, result.data]) 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 ( ) } return (
{/* Page header (concept scene 25): title + export + Ny kund */}

{t('title')}

{t('add_customer')}
{/* Toolbar: search (concept) */}
{ setSearchTerm(e.target.value) setVisibleCount(INITIAL_VISIBLE_ROWS) }} className="h-9 pl-10" />
{isLoading ? (
{[1, 2, 3, 4, 5].map((i) => ( ))}
) : sortedCustomers.length === 0 ? ( searchTerm ? ( ) : ( setIsDialogOpen(true)} /> ) ) : ( <>
{visibleCustomers.map((customer) => { const identifier = getIdentifier(customer) return ( router.push(`/customers/${customer.id}`)} > ) })}
e.stopPropagation()} > {customer.name} {customer.org_number && customer.vat_number_validated && ( {t('verified')} )} {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} {identifier || ''} {customer.email || ''} {customer.city || ''}
{/* Footer note (concept pgnote) */}

{t('count_summary', { count: customers.length })}

{visibleCount < sortedCustomers.length && (
)} )}
) } export default function CustomersPage() { return (
{[1, 2, 3, 4].map((i) => ( ))}
} >
) }