feat(register): concept scenes 25-31 for the seven register pages (#1131)

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 22:24:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2175eccddd
commit 4352862845
11 changed files with 1035 additions and 1190 deletions
+291 -284
View File
@@ -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 */}
+130 -165
View File
@@ -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 */}