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>
This commit is contained in:
Jakob Wennberg
2026-07-23 22:24:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2175eccddd
commit 4352862845
11 changed files with 1035 additions and 1190 deletions
+141 -203
View File
@@ -6,19 +6,11 @@ import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
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, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
@@ -28,9 +20,8 @@ import {
throwOnStructuredError,
} from '@/lib/hooks/use-submit-with-account-activation'
import { EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import { compareArticles } from '@/lib/articles/sort'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
@@ -227,7 +218,9 @@ function ArticlesPageInner() {
}, [filteredArticles, sortColumn, sortDir])
const visibleArticles = sortedArticles.slice(0, visibleCount)
function SortableHeader({
// Sortable dry-table head: the button inherits the TH typography and only
// adds the direction affordance.
function SortableTh({
column,
label,
className,
@@ -239,218 +232,163 @@ function ArticlesPageInner() {
const isActive = sortColumn === column
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
return (
<TableHead className={className}>
<th className={cn(TH_CLASS, className)}>
<button
type="button"
onClick={() => updateSort(column)}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center gap-1 transition-colors duration-150 hover:text-foreground"
>
{label}
<Icon className="h-3 w-3 opacity-70" aria-hidden="true" />
</button>
</TableHead>
</th>
)
}
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<div className="flex items-center gap-2">
<ReportExportMenu
size="default"
items={[
{ format: 'xlsx', href: '/api/export/articles' },
{ format: 'csv', href: '/api/export/articles?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_article')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('add_article')}</DialogTitle>
</DialogHeader>
<ArticleForm
onSubmit={handleCreateArticle}
isLoading={isCreating}
/>
</DialogContent>
</Dialog>
</div>
}
/>
{/* Page header (concept scene 27): title + export + Ny artikel */}
<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/articles' },
{ format: 'csv', href: '/api/export/articles?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_article')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('add_article')}</DialogTitle>
</DialogHeader>
<ArticleForm
onSubmit={handleCreateArticle}
isLoading={isCreating}
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* Search */}
<div className="relative">
<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="pl-10"
/>
{/* Toolbar: search */}
<div className="flex flex-wrap items-center gap-2">
<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)
}}
className="h-9 pl-10"
/>
</div>
</div>
{/* Article list */}
{isLoading ? (
<>
{/* Desktop skeleton */}
<Card className="hidden md:block">
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
{/* Mobile skeleton */}
<div className="grid gap-4 md:hidden">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-1/3 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
</>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : sortedArticles.length === 0 ? (
<Card>
<CardContent className="p-0">
{searchTerm ? (
<EmptyState
icon={Package}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyState
icon={Package}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('empty_action') : undefined}
onAction={canWrite ? () => setIsDialogOpen(true) : undefined}
/>
)}
</CardContent>
</Card>
searchTerm ? (
<EmptyState
icon={Package}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyState
icon={Package}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('empty_action') : undefined}
onAction={canWrite ? () => setIsDialogOpen(true) : undefined}
/>
)
) : (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<SortableHeader column="article_number" label={t('col_number')} />
<SortableHeader column="name" label={t('col_name')} />
<SortableHeader column="type" label={t('col_type')} />
<SortableHeader column="unit" label={t('col_unit')} />
<SortableHeader
column="price_excl_vat"
label={t('col_price')}
className="text-right"
/>
<SortableHeader
column="vat_rate"
label={t('col_vat')}
className="text-right"
/>
<TableHead className="text-right">{t('col_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleArticles.map((article) => (
<TableRow
key={article.id}
className="cursor-pointer"
onClick={() => router.push(`/articles/${article.id}`)}
>
<TableCell className="tabular-nums text-muted-foreground">
{article.article_number || '-'}
</TableCell>
<TableCell className="font-medium">
<Link
href={`/articles/${article.id}`}
className="hover:text-primary transition-colors"
onClick={(e) => e.stopPropagation()}
>
{article.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{t(ARTICLE_TYPE_LABEL_KEYS[article.type])}
</TableCell>
<TableCell className="text-muted-foreground">{article.unit}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(article.price_excl_vat)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{article.vat_rate} %
</TableCell>
<TableCell className="text-right">
<Badge variant={article.active ? 'success' : 'secondary'}>
{article.active ? t('status_active') : t('status_inactive')}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<SortableTh column="article_number" label={t('col_number')} />
<SortableTh column="name" label={t('col_name')} className="w-full" />
<SortableTh column="type" label={t('col_type')} className="hidden md:table-cell" />
<SortableTh column="unit" label={t('col_unit')} className="hidden text-right sm:table-cell" />
<SortableTh column="price_excl_vat" label={t('col_price')} className="text-right" />
<SortableTh column="vat_rate" label={t('col_vat')} className="hidden text-right sm:table-cell" />
<th className={cn(TH_CLASS, 'text-right')}>{t('col_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{visibleArticles.map((article) => (
<tr
key={article.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/articles/${article.id}`)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{article.article_number || '-'}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<Link
href={`/articles/${article.id}`}
className="block truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{article.name}
</Link>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
{t(ARTICLE_TYPE_LABEL_KEYS[article.type])}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right text-muted-foreground sm:table-cell')}>
{article.unit}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(article.price_excl_vat)}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{article.vat_rate} %
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{article.active ? (
<span className="text-muted-foreground">{t('status_active')}</span>
) : (
<Badge variant="outline" className="font-normal">
{t('status_inactive')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Mobile card list */}
<div className="grid gap-4 md:hidden">
{visibleArticles.map((article) => (
<Link key={article.id} href={`/articles/${article.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">
{article.name}
</CardTitle>
<div className="mt-1 text-xs text-muted-foreground tabular-nums">
{t(ARTICLE_TYPE_LABEL_KEYS[article.type])}
{article.article_number ? ` · #${article.article_number}` : ''}
</div>
</div>
<span className="font-display text-lg tabular-nums shrink-0">
{formatCurrency(article.price_excl_vat)}
</span>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">
<span>{t('per_unit', { unit: article.unit })}</span>
<span aria-hidden="true">·</span>
<span>{t('vat_label_value', { rate: article.vat_rate })}</span>
</div>
</CardContent>
</Card>
</Link>
))}
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Footer count (concept pgnote) */}
<p className="px-1 text-xs text-muted-foreground tabular-nums">
{t('count_footer', { count: sortedArticles.length })}
</p>
{visibleCount < sortedArticles.length && (
<div className="flex justify-center">
<Button
+95 -84
View File
@@ -1,25 +1,16 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { Package, Pencil, Plus } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCurrency, formatDate } from '@/lib/utils'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Package, Plus } from 'lucide-react'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import type { Asset, AssetCategory } from '@/types'
import { CreateAssetDialog } from '@/components/bookkeeping/assets/CreateAssetDialog'
import { EditAssetDialog } from '@/components/bookkeeping/assets/EditAssetDialog'
@@ -40,6 +31,7 @@ const CATEGORY_LABEL_KEYS: Record<AssetCategory, string> = {
export default function AssetsPage() {
const t = useTranslations('assets')
const router = useRouter()
const [assets, setAssets] = useState<AssetRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -81,29 +73,24 @@ export default function AssetsPage() {
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 h-4 w-4" /> {t('new_asset')}
</Button>
}
/>
{/* Page header (concept scene 29): title + Ny tillgång */}
<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>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('new_asset')}
</Button>
</div>
{assets === null && !error && (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)}
{error && (
<Card>
<CardContent className="p-6 text-destructive">{error}</CardContent>
</Card>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{assets !== null && assets.length === 0 && (
<EmptyState
@@ -116,74 +103,98 @@ export default function AssetsPage() {
)}
{assets !== null && assets.length > 0 && (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_category')}</TableHead>
<TableHead>{t('th_acquired')}</TableHead>
<TableHead className="text-right">{t('th_acquisition_cost')}</TableHead>
<TableHead>{t('th_useful_life')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead className="text-right">{t('th_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
<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={cn(TH_CLASS, 'hidden md:table-cell')}>{t('th_category')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_acquired')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_acquisition_cost')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_useful_life')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'w-[120px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
{assets.map((asset) => {
const years = Math.round(asset.useful_life_months / 12)
const disposed = !!asset.disposed_at
return (
<TableRow key={asset.id}>
<TableCell className="font-medium">{asset.name}</TableCell>
<TableCell className="text-sm">{t(CATEGORY_LABEL_KEYS[asset.category])}</TableCell>
<TableCell className="tabular-nums">
<tr
key={asset.id}
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
!disposed && 'cursor-pointer',
)}
onClick={!disposed ? () => setEditing(asset) : undefined}
>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="block truncate">{asset.name}</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
{t(CATEGORY_LABEL_KEYS[asset.category])}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{formatDate(asset.acquisition_date)}
</TableCell>
<TableCell className="text-right tabular-nums">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
{formatCurrency(Number(asset.acquisition_cost))}
</TableCell>
<TableCell className="text-sm">
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
{t('useful_life_format', { years, months: asset.useful_life_months })}
</TableCell>
<TableCell>
{asset.disposed_at ? (
<div className="flex flex-col gap-1">
<Badge variant="secondary">{t('status_disposed')}</Badge>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{disposed ? (
<span className="inline-flex items-center gap-2">
<Badge variant="secondary" className="font-normal">
{t('status_disposed')}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
{formatDate(asset.disposed_at)}
{formatDate(asset.disposed_at!)}
</span>
</div>
</span>
) : (
<Badge variant="success">{t('status_active')}</Badge>
<span className="text-muted-foreground">{t('status_active')}</span>
)}
</TableCell>
<TableCell className="text-right">
{!asset.disposed_at && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
</td>
{/* Row actions as hover-revealed quiet links (concept) */}
<td
className={cn(TD_CLASS, 'whitespace-nowrap text-right')}
onClick={(e) => e.stopPropagation()}
>
{!disposed && (
<span className="inline-flex items-center gap-4 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover:opacity-100">
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setEditing(asset)}
>
<Pencil className="mr-1 h-4 w-4" />
{t('action_edit')}
</Button>
<Link href={`/assets/${asset.id}/dispose`}>
<Button variant="ghost" size="sm">
{t('action_dispose')}
</Button>
</Link>
</div>
</button>
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => router.push(`/assets/${asset.id}/dispose`)}
>
{t('action_dispose')}
</button>
</span>
)}
</TableCell>
</TableRow>
</td>
</tr>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</tbody>
</table>
</div>
{/* Footer count (concept pgnote) */}
<p className="px-1 text-xs text-muted-foreground tabular-nums">
{t('count_footer', { count: assets.length })}
</p>
</>
)}
<CreateAssetDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={handleCreated} />
+5 -10
View File
@@ -1,5 +1,3 @@
import { getTranslations } from 'next-intl/server'
import { PageHeader } from '@/components/ui/page-header'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
/**
@@ -7,13 +5,10 @@ import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsMana
* Bokföring. It is reference/configuration (activate/add/edit accounts, browse
* the BAS catalog), not a daily task, so it lives beside the ledger rather than
* as a co-equal tab inside it. Balances/lookup stay in Rapporter (Huvudbok).
*
* The manager owns the whole page chrome (concept scene 30 header with the
* BAS-version chip and the actions) since every control needs client state.
*/
export default async function ChartOfAccountsPage() {
const t = await getTranslations('nav')
return (
<div className="space-y-8">
<PageHeader title={t('chart_of_accounts')} />
<ChartOfAccountsManager />
</div>
)
export default function ChartOfAccountsPage() {
return <ChartOfAccountsManager />
}
+133 -210
View File
@@ -6,26 +6,17 @@ import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
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 { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -64,15 +55,6 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
]
const INITIAL_VISIBLE_ROWS = 100
function getInitials(name: string): string {
return name
.split(' ')
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase()
}
function getIdentifier(customer: Customer): string {
return customer.org_number || customer.personal_number || ''
}
@@ -230,6 +212,8 @@ function CustomersPageInner() {
}, [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,
@@ -242,226 +226,165 @@ function CustomersPageInner() {
const isActive = sortColumn === column
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
return (
<TableHead className={className}>
<th className={cn(TH_CLASS, className)}>
<button
type="button"
onClick={() => updateSort(column)}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
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>
</TableHead>
</th>
)
}
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<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}
/>
</DialogContent>
</Dialog>
</div>
}
/>
{/* 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}
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* Search */}
<div className="relative">
<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="pl-10"
/>
{/* 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>
{/* Customer list */}
{isLoading ? (
<>
{/* Desktop skeleton */}
<Card className="hidden md:block">
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
{/* Mobile skeleton */}
<div className="grid gap-4 md:hidden">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-1/3 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
</>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : sortedCustomers.length === 0 ? (
<Card>
<CardContent className="p-0">
{searchTerm ? (
<EmptyState
icon={Users}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
)}
</CardContent>
</Card>
searchTerm ? (
<EmptyState
icon={Users}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
)
) : (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<SortableHeader column="name" label={t('col_name')} />
<SortableHeader column="customer_type" label={t('col_type')} />
<SortableHeader column="identifier" label={t('col_identifier')} />
<SortableHeader column="email" label={t('col_email')} />
<SortableHeader column="city" label={t('col_city')} />
<SortableHeader
column="created_at"
label={t('col_created')}
className="text-right"
/>
</TableRow>
</TableHeader>
<TableBody>
{visibleCustomers.map((customer) => {
const identifier = getIdentifier(customer)
return (
<TableRow
key={customer.id}
className="cursor-pointer"
onClick={() => router.push(`/customers/${customer.id}`)}
>
<TableCell className="font-medium">
<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}`)}
>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<Link
href={`/customers/${customer.id}`}
className="hover:text-primary transition-colors"
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{customer.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
<div className="flex items-center gap-2">
<span>{identifier || '-'}</span>
{customer.org_number && customer.vat_number_validated && (
<Badge variant="success" className="text-xs">
{t('verified')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[220px]">
{customer.email || '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{customer.city || '-'}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatDate(customer.created_at)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Mobile card list */}
<div className="grid gap-4 md:hidden">
{visibleCustomers.map((customer) => (
<Link key={customer.id} href={`/customers/${customer.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3">
<div className="flex items-center gap-3.5">
<div className="h-11 w-11 rounded-full bg-secondary flex items-center justify-center shrink-0 text-sm font-semibold tracking-tight">
{getInitials(customer.name)}
</div>
<div className="min-w-0">
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">
{customer.name}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1 truncate">
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1.5 text-sm text-muted-foreground">
{customer.email && <p className="truncate">{customer.email}</p>}
{getIdentifier(customer) && (
<div className="flex items-center gap-2 tabular-nums">
<span>{getIdentifier(customer)}</span>
{customer.org_number && customer.vat_number_validated && (
<Badge variant="success" className="text-xs">
<Badge variant="success" className="shrink-0 font-normal">
{t('verified')}
</Badge>
)}
</div>
)}
{customer.city && (
<p>
{customer.city}, {customer.country}
</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</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
+9 -15
View File
@@ -1,8 +1,6 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { History } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PageHeader } from '@/components/ui/page-header'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import DimensionsManager from '@/components/dimensions/DimensionsManager'
/**
@@ -17,18 +15,14 @@ export default async function DimensionsPage() {
const t = await getTranslations('nav')
return (
<div className="space-y-8">
{/* "Tagga historik" stays Swedish like the workbench it opens (PR6). */}
<PageHeader
title={t('dimensions')}
action={
<Button variant="outline" asChild>
<Link href="/dimensions/tagging">
<History className="mr-2 h-4 w-4" />
Tagga historik
</Link>
</Button>
}
/>
{/* Page header (concept scene 31): title + quiet Tagga historik.
"Tagga historik" stays Swedish like the workbench it opens (PR6). */}
<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('dimensions')}</h1>
<Link href="/dimensions/tagging" className={QUIET_LINK_CLASS}>
Tagga historik
</Link>
</div>
<DimensionsManager />
</div>
)
+72 -70
View File
@@ -5,15 +5,13 @@ import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Plus, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import type { Employee } from '@/types'
@@ -28,6 +26,11 @@ const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
board_member: 'employment_board_member',
}
/**
* Anställda register (concept scene 28): header + dry-table. Tax table,
* jämkning, förmåner and semester are configured on the employee detail
* page; the register is the roster.
*/
export default function EmployeesPage() {
const t = useTranslations('employees')
const [employees, setEmployees] = useState<Employee[]>([])
@@ -61,91 +64,90 @@ export default function EmployeesPage() {
return (
<div className="space-y-8">
<div className="flex items-start gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/salary" aria-label={t('back_to_payroll')}><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1">
<PageHeader
title={t('title')}
description={t('registered_count', { count: employees.length })}
action={
canWrite ? (
<Button onClick={openNewEmployee}>
<Plus className="mr-2 h-4 w-4" />
{t('new_employee')}
</Button>
) : undefined
}
/>
</div>
{/* Page header (concept scene 28) */}
<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>
{canWrite && (
<Button onClick={openNewEmployee}>
<Plus className="mr-2 h-4 w-4" />
{t('new_employee')}
</Button>
)}
</div>
{loading ? (
<div className="space-y-2">
<div className="space-y-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-16 rounded-lg" />
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
) : employees.length === 0 ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={UserCircle}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('add_employee') : undefined}
onAction={canWrite ? openNewEmployee : undefined}
/>
</CardContent>
</Card>
<EmptyState
icon={UserCircle}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('add_employee') : undefined}
onAction={canWrite ? openNewEmployee : undefined}
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_personnummer')}</TableHead>
<TableHead>{t('th_type')}</TableHead>
<TableHead className="text-right">{t('th_salary')}</TableHead>
<TableHead className="text-right">{t('th_employment_degree')}</TableHead>
<TableHead>{t('th_tax_table')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<div>
<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={cn(TH_CLASS, 'hidden md:table-cell')}>{t('th_personnummer')}</th>
<th className={TH_CLASS}>{t('th_type')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_salary')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_employment_degree')}</th>
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_tax_table')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{employees.map(emp => (
<TableRow key={emp.id}>
<TableCell>
<Link href={`/salary/employees/${emp.id}`} className="font-medium hover:underline">
<tr
key={emp.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/salary/employees/${emp.id}`)}
>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<Link
href={`/salary/employees/${emp.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{emp.first_name} {emp.last_name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground md:table-cell')}>
{emp.personnummer}
</TableCell>
<TableCell className="text-muted-foreground">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-muted-foreground')}>
{EMPLOYMENT_LABEL_KEYS[emp.employment_type]
? t(EMPLOYMENT_LABEL_KEYS[emp.employment_type])
: emp.employment_type}
</TableCell>
<TableCell className="text-right tabular-nums">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
{emp.salary_type === 'hourly'
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}${t('hourly_suffix')}` : '-'
: emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums sm:table-cell')}>
{emp.employment_degree}%
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
{emp.tax_table_number ? t('tax_table_format', { table: emp.tax_table_number, column: emp.tax_column ?? '' }) : '-'}
</TableCell>
</TableRow>
</td>
</tr>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</tbody>
</table>
</div>
{/* Footer note (concept pgnote) */}
<p className="px-1 pt-3 text-xs text-muted-foreground tabular-nums">
{t('registered_count', { count: employees.length })}
</p>
</div>
)}
{showNewEmployee && (
+127 -139
View File
@@ -2,19 +2,20 @@
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 { Card, CardContent } from '@/components/ui/card'
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 { PageHeader } from '@/components/ui/page-header'
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'
@@ -39,6 +40,8 @@ const SUPPLIER_TYPE_KEYS: Record<SupplierType, string> = {
}
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 }
@@ -47,12 +50,6 @@ function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label
return null
}
function formatLocation(supplier: Supplier): string | null {
if (supplier.city && supplier.country) return `${supplier.city}, ${supplier.country}`
if (supplier.city) return supplier.city
return null
}
export default function SuppliersPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
@@ -66,6 +63,7 @@ export default function SuppliersPage() {
const { toast } = useToast()
const supabase = createClient()
const tCommon = useTranslations('common')
const router = useRouter()
async function fetchSuppliers() {
if (!company) return
@@ -90,6 +88,7 @@ export default function SuppliersPage() {
useEffect(() => {
fetchSuppliers()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleCreateSupplier(data: CreateSupplierInput) {
@@ -131,148 +130,137 @@ export default function SuppliersPage() {
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
<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>
}
/>
{/* 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>
{/* Search */}
<div className="relative">
<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="pl-10"
/>
{/* 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>
{/* Supplier list */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-3 w-2/3" />
<div className="h-px bg-muted" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</CardContent>
</Card>
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : filteredSuppliers.length === 0 ? (
<Card>
<CardContent className="p-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}
/>
)}
</CardContent>
</Card>
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="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{visibleSuppliers.map((supplier) => {
const payment = getPaymentInfo(supplier, t)
const location = formatLocation(supplier)
return (
<Link key={supplier.id} href={`/suppliers/${supplier.id}`} className="group">
<Card className="h-full cursor-pointer transition-colors duration-150 hover:border-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<CardContent className="p-6 flex flex-col h-full">
<div className="space-y-1 mb-4">
<h3 className="text-[15px] font-semibold tracking-tight leading-tight truncate group-hover:text-primary transition-colors">
{supplier.name}
</h3>
<p className="text-xs text-muted-foreground leading-snug">
<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])}
</p>
</div>
<dl className="mt-auto space-y-2 text-sm border-t pt-3">
{supplier.org_number && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">{t('label_org_number')}</dt>
<dd className="tabular-nums truncate">{supplier.org_number}</dd>
</div>
)}
{payment && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">{payment.label}</dt>
<dd className="tabular-nums truncate">{payment.value}</dd>
</div>
)}
{supplier.email && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">{t('label_email')}</dt>
<dd className="truncate">{supplier.email}</dd>
</div>
)}
{location && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">{t('label_location')}</dt>
<dd className="truncate">{location}</dd>
</div>
)}
{!supplier.org_number && !payment && !supplier.email && !location && (
<p className="text-xs text-muted-foreground italic">{t('no_contact_info')}</p>
)}
</dl>
</CardContent>
</Card>
</Link>
)
})}
</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