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:
co-authored by
Claude Fable 5
parent
2175eccddd
commit
4352862845
+141
-203
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import {
|
||||
@@ -18,15 +18,14 @@ import { EditAccountDialog } from './EditAccountDialog'
|
||||
import { PruneAccountsDialog } from './PruneAccountsDialog'
|
||||
import {
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -48,6 +47,7 @@ interface ReferenceAccount extends BASReferenceAccount {
|
||||
export default function ChartOfAccountsManager() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
@@ -65,10 +65,14 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// View state
|
||||
// View state. Mina konton renders flat (concept scene 30), so its band
|
||||
// rows track the classes the user has explicitly FOLDED AWAY; the BAS
|
||||
// catalog (~1300 rows) keeps the inverse default and tracks the classes
|
||||
// the user has opened.
|
||||
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [collapsedMyClasses, setCollapsedMyClasses] = useState<Set<number>>(new Set())
|
||||
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
|
||||
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
|
||||
|
||||
// Data state
|
||||
@@ -278,17 +282,23 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Toggle class expansion
|
||||
// Toggle class expansion (band rows)
|
||||
// -------------------------------------------
|
||||
|
||||
function toggleClass(cls: number) {
|
||||
setExpandedClasses((prev) => {
|
||||
function toggleMyClass(cls: number) {
|
||||
setCollapsedMyClasses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(cls)) {
|
||||
next.delete(cls)
|
||||
} else {
|
||||
next.add(cls)
|
||||
}
|
||||
if (next.has(cls)) next.delete(cls)
|
||||
else next.add(cls)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleCatalogClass(cls: number) {
|
||||
setExpandedCatalogClasses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(cls)) next.delete(cls)
|
||||
else next.add(cls)
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -343,59 +353,105 @@ export default function ChartOfAccountsManager() {
|
||||
// Render
|
||||
// -------------------------------------------
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
const shownCount = view === 'my-accounts' ? filteredAccounts.length : filteredReference.length
|
||||
const totalCount = view === 'my-accounts' ? accounts.length : referenceAccounts.length
|
||||
|
||||
// Page header + toolbar render even while loading so the chrome is stable.
|
||||
const header = (
|
||||
<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">{tNav('chart_of_accounts')}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="font-normal">{t('bas_version_chip')}</Badge>
|
||||
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setPruneDialogOpen(true)}>
|
||||
{t('prune_button')}
|
||||
</button>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_own')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const switchView = (next: 'my-accounts' | 'bas-catalog') => {
|
||||
setView(next)
|
||||
setCollapsedMyClasses(new Set())
|
||||
setExpandedCatalogClasses(new Set())
|
||||
if (next === 'bas-catalog') void ensureReferenceLoaded()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => {
|
||||
const next = v as 'my-accounts' | 'bas-catalog'
|
||||
setView(next)
|
||||
setExpandedClasses(new Set())
|
||||
if (next === 'bas-catalog') void ensureReferenceLoaded()
|
||||
}}
|
||||
// Concept band row (Klass N: label), clickable to fold the class away.
|
||||
const bandRow = (
|
||||
cls: number,
|
||||
open: boolean,
|
||||
onToggle: () => void,
|
||||
countLabel: string,
|
||||
colSpan: number,
|
||||
) => (
|
||||
<tr key={`band-${cls}`}>
|
||||
<td colSpan={colSpan} className="border-b border-border p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-1.5 bg-muted/40 px-3 py-[7px] text-left text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground transition-colors duration-150 hover:bg-muted/60"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="my-accounts">
|
||||
{t('tab_my_accounts')}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground tabular-nums">
|
||||
({accounts.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bas-catalog">
|
||||
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('tab_bas_catalog')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ChevronRight className={cn('h-3 w-3 shrink-0 transition-transform duration-200', open && 'rotate-90')} />
|
||||
{t('class_heading', { cls, label: classLabel(cls) })}
|
||||
<span className="font-normal normal-case tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
{view === 'my-accounts' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPruneDialogOpen(true)}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('prune_button')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('add_own')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{header}
|
||||
|
||||
{/* Toolbar: seg + search; the K2 filter rides far right in catalog view */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'my-accounts'}
|
||||
onClick={() => switchView('my-accounts')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
view === 'my-accounts'
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('tab_my_accounts')}
|
||||
<span className="font-normal text-muted-foreground tabular-nums">{accounts.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'bas-catalog'}
|
||||
onClick={() => switchView('bas-catalog')}
|
||||
className={cn(
|
||||
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
view === 'bas-catalog'
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('tab_bas_catalog')}
|
||||
</button>
|
||||
</div>
|
||||
<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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-9 pl-10"
|
||||
/>
|
||||
</div>
|
||||
{view === 'bas-catalog' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="ml-auto flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={hideK2Excluded ?? false}
|
||||
onCheckedChange={setHideK2Excluded}
|
||||
@@ -406,117 +462,103 @@ export default function ChartOfAccountsManager() {
|
||||
)}
|
||||
</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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* My Accounts view */}
|
||||
{view === 'my-accounts' && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(groupedAccounts)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
const classNum = Number(cls)
|
||||
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
||||
const activeCount = classAccounts.filter((a) => a.is_active).length
|
||||
|
||||
return (
|
||||
<Card key={cls}>
|
||||
<button
|
||||
onClick={() => toggleClass(classNum)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-left">
|
||||
{t('class_heading', { cls, label: classLabel(classNum) })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('active_count_label', { active: activeCount, total: classAccounts.length })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">{t('col_account')}</th>
|
||||
<th className="py-2">{t('col_name')}</th>
|
||||
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
||||
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
||||
<th className="py-2 w-20 text-right">{t('col_usage')}</th>
|
||||
<th className="py-2 w-16 text-center">{t('col_active')}</th>
|
||||
<th className="py-2 w-20 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classAccounts.map((account) => (
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : view === 'my-accounts' ? (
|
||||
filteredAccounts.length === 0 ? (
|
||||
<p className="px-1 py-12 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? t('no_matches') : t('no_accounts')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_account')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('col_name')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('col_sru')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('col_type')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('col_usage')}</th>
|
||||
<th className={TH_CLASS}>{t('col_active')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[84px]')} aria-hidden="true"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{Object.entries(groupedAccounts)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
const classNum = Number(cls)
|
||||
const open = !collapsedMyClasses.has(classNum) || !!searchQuery
|
||||
const activeCount = classAccounts.filter((a) => a.is_active).length
|
||||
return (
|
||||
<Fragment key={cls}>
|
||||
{bandRow(
|
||||
classNum,
|
||||
open,
|
||||
() => toggleMyClass(classNum),
|
||||
t('active_count_label', { active: activeCount, total: classAccounts.length }),
|
||||
7,
|
||||
)}
|
||||
{open &&
|
||||
classAccounts.map((account) => (
|
||||
<tr
|
||||
key={account.id}
|
||||
className={`border-b last:border-0 transition-opacity ${
|
||||
!account.is_active ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
'group transition-colors duration-150 hover:bg-secondary/35',
|
||||
!account.is_active && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<td className="py-2">
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
|
||||
<AccountNumber number={account.account_number} name={account.account_name} />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{account.account_name}
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{account.account_name}</span>
|
||||
{account.is_system_account && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('system_badge')}
|
||||
</span>
|
||||
)}
|
||||
{!isStandardBASAccount(account.account_number) && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('own_badge')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{account.sru_code || '\u2014'}
|
||||
</span>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{account.sru_code || ''}
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typeLabel(account.account_type)}
|
||||
</span>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
|
||||
{typeLabel(account.account_type)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{usageCounts.get(account.account_number) ?? '\u2014'}
|
||||
</span>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{usageCounts.get(account.account_number) ?? ''}
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap py-[9px]')}>
|
||||
<Switch
|
||||
checked={account.is_active}
|
||||
onCheckedChange={() => toggleActive(account)}
|
||||
disabled={togglingAccount === account.account_number}
|
||||
aria-label={t('col_active') + ' ' + account.account_number}
|
||||
className="scale-75"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[5px]')}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-end gap-1 transition-opacity duration-150',
|
||||
'opacity-0 focus-within:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
className="h-8 w-8"
|
||||
aria-label={tCommon('edit')}
|
||||
onClick={() => setEditAccount(account)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -525,7 +567,8 @@ export default function ChartOfAccountsManager() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 text-destructive hover:text-destructive"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
aria-label={t('delete_confirm_action')}
|
||||
onClick={() => deleteAccount(account)}
|
||||
disabled={deletingAccount === account.account_number}
|
||||
>
|
||||
@@ -536,151 +579,115 @@ export default function ChartOfAccountsManager() {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredAccounts.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
{searchQuery ? t('no_matches') : t('no_accounts')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
{referenceLoading && referenceAccounts.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : filteredReference.length === 0 ? (
|
||||
<p className="px-1 py-12 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_account')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('col_name')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('col_sru')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('col_type')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('col_status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{Object.entries(groupedReference)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
const classNum = Number(cls)
|
||||
const open = expandedCatalogClasses.has(classNum) || !!searchQuery
|
||||
const activatedCount = classAccounts.filter((a) => a.is_activated).length
|
||||
return (
|
||||
<Fragment key={cls}>
|
||||
{bandRow(
|
||||
classNum,
|
||||
open,
|
||||
() => toggleCatalogClass(classNum),
|
||||
t('active_count_label', { active: activatedCount, total: classAccounts.length }),
|
||||
5,
|
||||
)}
|
||||
{open &&
|
||||
classAccounts.map((account) => (
|
||||
<tr
|
||||
key={account.account_number}
|
||||
className="group transition-colors duration-150 hover:bg-secondary/35"
|
||||
>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
|
||||
<AccountNumber number={account.account_number} name={account.account_name} />
|
||||
</td>
|
||||
<td
|
||||
className={cn(TD_CLASS, 'max-w-0 w-full')}
|
||||
title={account.description || undefined}
|
||||
>
|
||||
<span className="block truncate">{account.account_name}</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{account.sru_code || ''}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
|
||||
{typeLabel(account.account_type)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
|
||||
{account.is_activated ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t('activated')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-3.5 text-xs"
|
||||
onClick={() => activateBASAccount(account.account_number)}
|
||||
disabled={activatingAccounts.has(account.account_number)}
|
||||
>
|
||||
{activatingAccounts.has(account.account_number) ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('add')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BAS Catalog view */}
|
||||
{view === 'bas-catalog' && (
|
||||
<div className="space-y-2">
|
||||
{referenceLoading && referenceAccounts.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{Object.entries(groupedReference)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
const classNum = Number(cls)
|
||||
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
||||
const activatedCount = classAccounts.filter((a) => a.is_activated).length
|
||||
|
||||
return (
|
||||
<Card key={cls}>
|
||||
<button
|
||||
onClick={() => toggleClass(classNum)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-left">
|
||||
{t('class_heading', { cls, label: classLabel(classNum) })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('active_count_label', { active: activatedCount, total: classAccounts.length })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">{t('col_account')}</th>
|
||||
<th className="py-2">{t('col_name')}</th>
|
||||
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
||||
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
||||
<th className="py-2 w-28 text-right">{t('col_status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classAccounts.map((account) => (
|
||||
<tr
|
||||
key={account.account_number}
|
||||
className={`border-b last:border-0 ${
|
||||
account.is_activated ? 'bg-muted/30' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2">
|
||||
<AccountNumber number={account.account_number} name={account.account_name} />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div>
|
||||
<span>{account.account_name}</span>
|
||||
{account.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
||||
{account.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{account.sru_code || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typeLabel(account.account_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{account.is_activated ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t('activated')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => activateBASAccount(account.account_number)}
|
||||
disabled={activatingAccounts.has(account.account_number)}
|
||||
>
|
||||
{activatingAccounts.has(account.account_number) ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('add')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{!referenceLoading && filteredReference.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
{t('no_matches')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer note (concept pgnote) */}
|
||||
{!loading && totalCount > 0 && (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{t('footer_note', { shown: shownCount, total: totalCount })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,19 +22,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
@@ -325,88 +316,102 @@ export default function DimensionsManager() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStatusBadge(value: DimensionValueDto) {
|
||||
function renderStatus(value: DimensionValueDto) {
|
||||
return value.is_active ? (
|
||||
<Badge variant="success">{t('status_active')}</Badge>
|
||||
<span className="text-muted-foreground">{t('status_active')}</span>
|
||||
) : (
|
||||
<Badge variant="secondary">{t('status_archived')}</Badge>
|
||||
<Badge variant="outline" className="font-normal">{t('status_archived')}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed && dimensions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
actionLabel={t('retry')}
|
||||
onAction={() => void loadDimensions(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
actionLabel={t('retry')}
|
||||
onAction={() => void loadDimensions(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Segmented tabs: one per registry dimension (1 Kostnadsställe,
|
||||
6 Projekt, plus any custom 20+ dims) */}
|
||||
{/* Toolbar (concept scene 31): seg per registry dimension with a
|
||||
muted value count, search, and the actions far right. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs
|
||||
value={activeDimId ?? undefined}
|
||||
onValueChange={(id) => {
|
||||
setActiveDimId(id)
|
||||
setSearchTerm('')
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
{dimensions.map((dim) => (
|
||||
<TabsTrigger key={dim.id} value={dim.id}>
|
||||
{dim.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
{dimensions.map((dim) => (
|
||||
<button
|
||||
key={dim.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeDimId === dim.id}
|
||||
onClick={() => {
|
||||
setActiveDimId(dim.id)
|
||||
setSearchTerm('')
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
activeDimId === dim.id
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{dim.name}
|
||||
{dim.values.length > 0 && (
|
||||
<span className="text-muted-foreground tabular-nums">{dim.values.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative min-w-[180px] 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)}
|
||||
className="h-9 pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(QUIET_LINK_CLASS, 'disabled:pointer-events-none disabled:opacity-50')}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
onClick={() => setNewDimDialogOpen(true)}
|
||||
>
|
||||
{t('new_dimension')}
|
||||
</Button>
|
||||
</button>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
@@ -424,116 +429,76 @@ export default function DimensionsManager() {
|
||||
)}
|
||||
</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)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value list */}
|
||||
{/* Value list (concept dry-table: Kod, Namn, Start, Slut, Status) */}
|
||||
{sortedValues.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description', { dimension: activeDim?.name ?? '' })}
|
||||
actionLabel={canWrite ? t('new_value') : undefined}
|
||||
onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
searchTerm ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description', { dimension: activeDim?.name ?? '' })}
|
||||
actionLabel={canWrite ? t('new_value') : undefined}
|
||||
onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHeader column="code" label={t('col_code')} />
|
||||
<SortableHeader column="name" label={t('col_name')} />
|
||||
<SortableHeader column="status" label={t('col_status')} />
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<SortableHeader column="start_date" label={t('col_start')} />
|
||||
<SortableHeader column="end_date" label={t('col_end')} />
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedValues.map((value) => (
|
||||
<TableRow
|
||||
key={value.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<TableCell className="font-mono">{value.code}</TableCell>
|
||||
<TableCell className="font-medium">{value.name}</TableCell>
|
||||
<TableCell>{renderStatusBadge(value)}</TableCell>
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.start_date ? formatDate(value.start_date) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.end_date ? formatDate(value.end_date) : '-'}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="grid gap-4 md:hidden">
|
||||
{sortedValues.map((value) => (
|
||||
<Card
|
||||
key={value.id}
|
||||
className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm">{value.code}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{value.name}
|
||||
</p>
|
||||
</div>
|
||||
{renderStatusBadge(value)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isProjectTab && (value.start_date || value.end_date) && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{value.start_date ? formatDate(value.start_date) : '-'}
|
||||
{' till '}
|
||||
{value.end_date ? formatDate(value.end_date) : '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader column="code" label={t('col_code')} />
|
||||
<SortableHeader column="name" label={t('col_name')} className="w-full" />
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<SortableHeader
|
||||
column="start_date"
|
||||
label={t('col_start')}
|
||||
className="hidden text-right sm:table-cell"
|
||||
/>
|
||||
<SortableHeader
|
||||
column="end_date"
|
||||
label={t('col_end')}
|
||||
className="hidden text-right sm:table-cell"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<SortableHeader column="status" label={t('col_status')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{sortedValues.map((value) => (
|
||||
<tr
|
||||
key={value.id}
|
||||
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap font-mono tabular-nums')}>
|
||||
{value.code}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="block truncate">{value.name}</span>
|
||||
</td>
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{value.start_date ? formatDate(value.start_date) : ''}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{value.end_date ? formatDate(value.end_date) : ''}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>{renderStatus(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/edit dialog */}
|
||||
|
||||
+16
-5
@@ -717,7 +717,13 @@
|
||||
"label_pg": "PG",
|
||||
"label_iban": "IBAN",
|
||||
"label_bank_account": "Bank account",
|
||||
"no_contact_info": "No contact details"
|
||||
"no_contact_info": "No contact details",
|
||||
"count_summary": "{count, plural, one {1 supplier} other {# suppliers}}",
|
||||
"th_name": "Name",
|
||||
"th_type": "Type",
|
||||
"th_payment": "Payment method",
|
||||
"th_email": "Email",
|
||||
"th_org_number": "Org. no."
|
||||
},
|
||||
"accruals": {
|
||||
"row_toggle": "Defer over period",
|
||||
@@ -762,7 +768,8 @@
|
||||
"category_equipment": "Equipment",
|
||||
"category_vehicle": "Vehicle",
|
||||
"category_computer": "Computer",
|
||||
"category_other_tangible": "Other tangible"
|
||||
"category_other_tangible": "Other tangible",
|
||||
"count_footer": "{count, plural, one {1 asset} other {# assets}}"
|
||||
},
|
||||
"employees": {
|
||||
"title": "Employees",
|
||||
@@ -4164,7 +4171,9 @@
|
||||
"toast_pruned_title": "Chart of accounts cleaned",
|
||||
"toast_pruned_description": "{deleted} accounts deleted",
|
||||
"toast_pruned_with_skipped": "{deleted} accounts deleted, {skipped} skipped",
|
||||
"toast_prune_failed": "Could not clean up the chart of accounts"
|
||||
"toast_prune_failed": "Could not clean up the chart of accounts",
|
||||
"bas_version_chip": "BAS 2026",
|
||||
"footer_note": "Showing {shown} of {total} accounts, grouped by BAS class. SRU codes carry through to the income tax return."
|
||||
},
|
||||
"dimensions": {
|
||||
"new_value": "New value",
|
||||
@@ -4660,7 +4669,8 @@
|
||||
"col_price": "Price excl. VAT",
|
||||
"col_vat": "VAT",
|
||||
"col_status": "Status",
|
||||
"activate_and_save": "Activate and save"
|
||||
"activate_and_save": "Activate and save",
|
||||
"count_footer": "{count, plural, one {1 article} other {# articles}}"
|
||||
},
|
||||
"article_detail": {
|
||||
"back": "Back to articles",
|
||||
@@ -4768,7 +4778,8 @@
|
||||
"col_identifier": "Org. no. / Personal no.",
|
||||
"col_email": "Email",
|
||||
"col_city": "City",
|
||||
"col_created": "Created"
|
||||
"col_created": "Created",
|
||||
"count_summary": "{count, plural, one {1 customer} other {# customers}}"
|
||||
},
|
||||
"products": {
|
||||
"title": "Products",
|
||||
|
||||
+16
-5
@@ -717,7 +717,13 @@
|
||||
"label_pg": "PG",
|
||||
"label_iban": "IBAN",
|
||||
"label_bank_account": "Bankkonto",
|
||||
"no_contact_info": "Inga kontaktuppgifter"
|
||||
"no_contact_info": "Inga kontaktuppgifter",
|
||||
"count_summary": "{count, plural, one {1 leverantör} other {# leverantörer}}",
|
||||
"th_name": "Namn",
|
||||
"th_type": "Typ",
|
||||
"th_payment": "Betalsätt",
|
||||
"th_email": "E-post",
|
||||
"th_org_number": "Org.nr"
|
||||
},
|
||||
"accruals": {
|
||||
"row_toggle": "Periodisera",
|
||||
@@ -762,7 +768,8 @@
|
||||
"category_equipment": "Inventarier",
|
||||
"category_vehicle": "Fordon",
|
||||
"category_computer": "Dator",
|
||||
"category_other_tangible": "Övriga materiella"
|
||||
"category_other_tangible": "Övriga materiella",
|
||||
"count_footer": "{count, plural, one {1 tillgång} other {# tillgångar}}"
|
||||
},
|
||||
"employees": {
|
||||
"title": "Anställda",
|
||||
@@ -4164,7 +4171,9 @@
|
||||
"toast_pruned_title": "Kontoplan rensad",
|
||||
"toast_pruned_description": "{deleted} konton togs bort",
|
||||
"toast_pruned_with_skipped": "{deleted} konton togs bort, {skipped} hoppades över",
|
||||
"toast_prune_failed": "Kunde inte rensa kontoplanen"
|
||||
"toast_prune_failed": "Kunde inte rensa kontoplanen",
|
||||
"bas_version_chip": "BAS 2026",
|
||||
"footer_note": "Visar {shown} av {total} konton, grupperade per BAS-klass. SRU-koderna följer med till inkomstdeklarationen."
|
||||
},
|
||||
"dimensions": {
|
||||
"new_value": "Nytt värde",
|
||||
@@ -4660,7 +4669,8 @@
|
||||
"col_price": "Pris exkl. moms",
|
||||
"col_vat": "Moms",
|
||||
"col_status": "Status",
|
||||
"activate_and_save": "Aktivera och spara"
|
||||
"activate_and_save": "Aktivera och spara",
|
||||
"count_footer": "{count, plural, one {1 artikel} other {# artiklar}}"
|
||||
},
|
||||
"article_detail": {
|
||||
"back": "Tillbaka till artiklar",
|
||||
@@ -4768,7 +4778,8 @@
|
||||
"col_identifier": "Org.nr / Personnummer",
|
||||
"col_email": "E-post",
|
||||
"col_city": "Stad",
|
||||
"col_created": "Skapad"
|
||||
"col_created": "Skapad",
|
||||
"count_summary": "{count, plural, one {1 kund} other {# kunder}}"
|
||||
},
|
||||
"products": {
|
||||
"title": "Produkter",
|
||||
|
||||
Reference in New Issue
Block a user