Files
accounted/app/(dashboard)/suppliers/page.tsx
T
Jakob WennbergandClaude Fable 5 4352862845 feat(register): concept scenes 25-31 for the seven register pages (#1131)
Kunder, Leverantorer, Artiklar, Anstallda, Anlaggningstillgangar,
Kontoplan and Kostnadsstallen & projekt all move to the house language:
serif header with quiet secondary actions + primary pill, dry-table lists
(TH/TD from components/ui/dry-table, stagger-enter, clickable rows,
responsive column hiding), muted text for normal states with chips only
for exceptions (Verifierad, Inaktiv, Arkiverad, Avyttrad), and a muted
pgnote count footer. Kontoplan gets the scene 30 treatment: BAS 2026 chip,
Mina konton/BAS-katalogen seg with counts, class band rows as folds, and
the activation toggles preserved. Dimensions swaps its tabs for the house
seg with per-dimension counts and Tagga historik as a quiet header link.
Separate mobile card lists and Card wrappers are gone; all dialogs, sort
params, export menus, gating and load-more behavior preserved.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:24:25 +02:00

280 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Lock } from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const SupplierForm = dynamic(
() => import('@/components/suppliers/SupplierForm'),
{
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 SUPPLIER_TYPE_KEYS: Record<SupplierType, string> = {
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
const INITIAL_VISIBLE_ROWS = 100
// "Betalsätt" cell (concept scene 26): the supplier's primary payment route,
// e.g. "BG 5050-1055". First match wins, mirroring the detail page's order.
function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro }
if (supplier.plusgiro) return { label: t('label_pg'), value: supplier.plusgiro }
if (supplier.iban) return { label: t('label_iban'), value: supplier.iban }
if (supplier.bank_account) return { label: t('label_bank_account'), value: supplier.bank_account }
return null
}
export default function SuppliersPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const t = useTranslations('suppliers')
const [suppliers, setSuppliers] = useState<Supplier[]>([])
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 supabase = createClient()
const tCommon = useTranslations('common')
const router = useRouter()
async function fetchSuppliers() {
if (!company) return
setIsLoading(true)
const { data, error } = await supabase
.from('suppliers')
.select('*')
.eq('company_id', company.id)
.order('name', { ascending: true })
if (error) {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
setSuppliers(data || [])
}
setIsLoading(false)
}
useEffect(() => {
fetchSuppliers()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleCreateSupplier(data: CreateSupplierInput) {
setIsCreating(true)
const response = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
const fieldErrors = result.errors?.map((e: { field: string; message: string }) => `${e.field}: ${e.message}`).join(', ')
toast({
title: t('create_failed_title'),
description: fieldErrors || result.error || t('create_failed_retry'),
variant: 'destructive',
})
} else {
toast({
title: t('created_title'),
description: t('created_description', { name: data.name }),
})
setSuppliers([...suppliers, result.data])
setIsDialogOpen(false)
}
setIsCreating(false)
}
const filteredSuppliers = suppliers.filter((s) =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.org_number?.includes(searchTerm)
)
const visibleSuppliers = filteredSuppliers.slice(0, visibleCount)
return (
<div className="space-y-8">
{/* Page header (concept scene 26): title + export + Ny leverantör */}
<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/suppliers' },
{ format: 'csv', href: '/api/export/suppliers?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_supplier')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('add_supplier')}</DialogTitle>
</DialogHeader>
<SupplierForm
onSubmit={handleCreateSupplier}
isLoading={isCreating}
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] 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)
}}
className="h-9 pl-10"
/>
</div>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : filteredSuppliers.length === 0 ? (
searchTerm ? (
<EmptyState
icon={Building2}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyState
icon={Building2}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_supplier') : undefined}
onAction={canWrite ? () => setIsDialogOpen(true) : undefined}
/>
)
) : (
<>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_name')}</th>
<th className={TH_CLASS}>{t('th_type')}</th>
<th className={cn(TH_CLASS, 'hidden sm:table-cell')}>{t('th_payment')}</th>
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('th_email')}</th>
<th className={cn(TH_CLASS, 'hidden lg:table-cell')}>{t('th_org_number')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{visibleSuppliers.map((supplier) => {
const payment = getPaymentInfo(supplier, t)
return (
<tr
key={supplier.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/suppliers/${supplier.id}`)}
>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<Link
href={`/suppliers/${supplier.id}`}
className="block truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{supplier.name}
</Link>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-muted-foreground')}>
{t(SUPPLIER_TYPE_KEYS[supplier.supplier_type])}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground sm:table-cell')}>
{payment ? `${payment.label} ${payment.value}` : ''}
</td>
<td className={cn(TD_CLASS, 'hidden max-w-[220px] truncate text-muted-foreground md:table-cell')}>
{supplier.email || ''}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground lg:table-cell')}>
{supplier.org_number || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Footer note (concept pgnote) */}
<p className="px-1 text-xs text-muted-foreground tabular-nums">
{t('count_summary', { count: suppliers.length })}
</p>
{visibleCount < filteredSuppliers.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</>
)}
</div>
)
}