refactor(ui): migrate remaining inline pages to the concept design language (#1470)

* refactor(ui): migrate remaining inline pages to the concept design language

Catch-up pass for surfaces the 2026-07 UI migration missed:

- Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade
  verifikationer, ignorerade, matchade), instructional copy moved behind the
  page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty-
  dates hint, EmptyState for the blank page, space-y-8 rhythm.
- Report detail views (trial balance, income statement, balance sheet,
  resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L):
  shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened
  to hairline cards with font-display tabular-nums headline numbers,
  ReportSectionTable rebuilt on the group-band idiom, font-mono money ->
  tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed
  onto Input primitives, verdicts follow chips-mark-exceptions.
- Extensions browse: PageHeader, locked section headers, rounded-lg
  secondary icon tiles, flat hover shift on cards, p-6 content.
- Återkommande fakturor: page-level list moved off ui/table onto dry-table
  with hover-revealed quiet row actions; Skeleton loading.
- Help: EmptyState for no search hits, flat hover shift on resource links.
- Chart of accounts: spinner loading blocks -> Skeleton rows.
- Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea
  primitives instead of hand-rolled shadow-sm controls.

No logic, endpoint, or data changes. Verified via sandbox screenshots;
lint 0 errors, 13214 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-08 16:40:19 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 645ed0a53e
commit 02a5d10538
17 changed files with 781 additions and 883 deletions
@@ -72,8 +72,8 @@ export default async function ExtensionDetailPage({
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-8">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-7 w-7 text-primary" />
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-6 w-6 text-foreground" />
</div>
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{extensionName}</h1>
@@ -95,14 +95,14 @@ export default async function ExtensionDetailPage({
{/* Details */}
<div className="space-y-6">
<div>
<h2 className="text-sm font-semibold mb-2">{t('description_heading')}</h2>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">{t('description_heading')}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{extensionLongDescription}
</p>
</div>
<div>
<h2 className="text-sm font-semibold mb-2">{t('data_source_heading')}</h2>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">{t('data_source_heading')}</h2>
<p className="text-sm text-muted-foreground">
{dataPatternLabels[definition.dataPattern]}
</p>
+3 -3
View File
@@ -40,8 +40,8 @@ export default async function SectorExtensionsPage({
{/* Header */}
<div className="flex items-start gap-4 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-6 w-6 text-primary" />
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-6 w-6 text-foreground" />
</div>
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{sectorName}</h1>
@@ -50,7 +50,7 @@ export default async function SectorExtensionsPage({
</div>
{/* Extensions grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-enter">
{sector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
+22 -22
View File
@@ -1,13 +1,16 @@
import { getTranslations } from 'next-intl/server'
import { SECTORS } from '@/lib/extensions/sectors'
import { sectorNameKey } from '@/lib/extensions/i18n'
import { PageHeader } from '@/components/ui/page-header'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import SectorCard from '@/components/extensions/SectorCard'
export default async function ExtensionsPage() {
const t = await getTranslations('extensions')
const generalSector = SECTORS.find(s => s.slug === 'general')
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
// Only sectors that actually ship extensions: a shell with zero extensions
// would render a dead card and an empty grid.
const industrySectors = SECTORS.filter(s => s.slug !== 'general' && s.extensions.length > 0)
const generalSectorName = (() => {
if (!generalSector) return ''
@@ -16,21 +19,16 @@ export default async function ExtensionsPage() {
})()
return (
<div>
<div className="mb-8">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('page_description')}
</p>
</div>
<div className="space-y-8">
<PageHeader title={t('page_title')} />
{/* General extensions */}
{generalSector && (
<section className="mb-8">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
{generalSectorName}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-enter">
{generalSector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
@@ -38,17 +36,19 @@ export default async function ExtensionsPage() {
</section>
)}
{/* Industry sectors */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
{t('industry_tools')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{industrySectors.map(sector => (
<SectorCard key={sector.slug} sector={sector} />
))}
</div>
</section>
{/* Industry sectors (hidden while no industry sector ships extensions) */}
{industrySectors.length > 0 && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
{t('industry_tools')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 stagger-enter">
{industrySectors.map(sector => (
<SectorCard key={sector.slug} sector={sector} />
))}
</div>
</section>
)}
</div>
)
}
+10 -12
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'
import { HelpLink } from '@/components/ui/info-tooltip'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import {
Search,
BookOpen,
@@ -377,14 +378,11 @@ export default function HelpPage() {
{/* Terms list */}
<div className="space-y-4">
{filteredTerms.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t('no_results', { query: searchQuery })}
</p>
</CardContent>
</Card>
<EmptyState
icon={Search}
title={t('no_results_title')}
description={t('no_results', { query: searchQuery })}
/>
) : (
filteredTerms.map((term) => (
<TermCard
@@ -408,7 +406,7 @@ export default function HelpPage() {
<a
href="/docs/arkivplan-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
@@ -421,7 +419,7 @@ export default function HelpPage() {
<a
href="/docs/systemdokumentation-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
@@ -444,7 +442,7 @@ export default function HelpPage() {
<div className="grid gap-3 sm:grid-cols-2">
<HelpLink
href="https://www.skatteverket.se/foretag/foretagarguiden.4.361dc8c15312eff6fd1f87f.html"
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
@@ -456,7 +454,7 @@ export default function HelpPage() {
</HelpLink>
<HelpLink
href="https://www.verksamt.se/"
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
+133 -129
View File
@@ -4,18 +4,12 @@ import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { EmptyState } from '@/components/ui/empty-state'
import { cn } from '@/lib/utils'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
@@ -196,134 +190,144 @@ export default function RecurringInvoicesPage() {
/>
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{t('loading')}
</CardContent>
</Card>
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : schedules.length === 0 ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={Repeat}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_schedule') : undefined}
onAction={canWrite ? openNewSchedule : undefined}
/>
</CardContent>
</Card>
<EmptyState
icon={Repeat}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_schedule') : undefined}
onAction={canWrite ? openNewSchedule : undefined}
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_customer')}</TableHead>
<TableHead className="tabular-nums">{t('th_day')}</TableHead>
<TableHead className="tabular-nums">{t('th_next_run')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead className="tabular-nums text-right">{t('th_generated')}</TableHead>
<TableHead className="text-right">{t('th_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((s) => (
<TableRow
key={s.id}
className={canWrite ? 'cursor-pointer' : undefined}
onClick={canWrite ? () => openEdit(s.id) : undefined}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{s.name}
{s.last_run_warning && (
<AlertTriangle
className="h-4 w-4 text-warning-foreground"
aria-label={s.last_run_warning}
/>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{s.customer?.name ?? '-'}
</TableCell>
<TableCell className="tabular-nums">
{s.day_of_month}
<span className="text-muted-foreground">
{' · '}
{t('send_time', {
time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`,
})}
{/* Monthly is the norm; only a deviating cadence is
worth a label (chips-mark-exceptions convention). */}
{(s.interval_months ?? 1) > 1 && (
<>
{' · '}
{s.interval_months === 3
? t('interval_quarterly')
: s.interval_months === 6
? t('interval_semiannual')
: s.interval_months === 12
? t('interval_yearly')
: t('interval_every_n', { n: s.interval_months })}
</>
)}
</span>
</TableCell>
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
<TableCell>
{s.status === 'active' ? (
<span className="text-xs text-muted-foreground">{t('status_active')}</span>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_name')}</th>
<th className={TH_CLASS}>{t('th_customer')}</th>
<th className={TH_CLASS}>{t('th_day')}</th>
<th className={TH_CLASS}>{t('th_next_run')}</th>
<th className={TH_CLASS}>{t('th_status')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_generated')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_actions')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{schedules.map((s) => (
<tr
key={s.id}
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
canWrite && 'cursor-pointer',
)}
onClick={canWrite ? () => openEdit(s.id) : undefined}
>
<td className={`${TD_CLASS} font-medium`}>
<div className="flex items-center gap-2">
{/* Focusable edit affordance: the row onClick is mouse-only,
so keyboard users open the editor through the name. */}
{canWrite ? (
<button
type="button"
className="hover:underline underline-offset-4"
onClick={(e) => {
e.stopPropagation()
openEdit(s.id)
}}
>
{s.name}
</button>
) : (
<Badge variant="outline" className="font-normal">{t('status_paused')}</Badge>
s.name
)}
</TableCell>
<TableCell className="tabular-nums text-right">
{s.generated_count}
</TableCell>
<TableCell className="text-right">
{s.last_run_warning && (
<AlertTriangle
className="h-4 w-4 text-warning-foreground"
aria-label={s.last_run_warning}
/>
)}
</div>
</td>
<td className={`${TD_CLASS} text-muted-foreground`}>
{s.customer?.name ?? '-'}
</td>
<td className={`${TD_CLASS} tabular-nums`}>
{s.day_of_month}
<span className="text-muted-foreground">
{' · '}
{t('send_time', {
time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`,
})}
{/* Monthly is the norm; only a deviating cadence is
worth a label (chips-mark-exceptions convention). */}
{(s.interval_months ?? 1) > 1 && (
<>
{' · '}
{s.interval_months === 3
? t('interval_quarterly')
: s.interval_months === 6
? t('interval_semiannual')
: s.interval_months === 12
? t('interval_yearly')
: t('interval_every_n', { n: s.interval_months })}
</>
)}
</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(s.next_run_date)}</td>
<td className={TD_CLASS}>
{s.status === 'active' ? (
<span className="text-xs text-muted-foreground">{t('status_active')}</span>
) : (
<Badge variant="outline" className="font-normal">{t('status_paused')}</Badge>
)}
</td>
<td className={`${TD_CLASS} tabular-nums text-right`}>
{s.generated_count}
</td>
<td className={`${TD_CLASS} text-right`}>
{canWrite && (
<div
className="flex justify-end gap-2"
className="flex justify-end gap-4 whitespace-nowrap"
onClick={(e) => e.stopPropagation()}
>
{canWrite && (
<>
<Button
variant="secondary"
size="sm"
disabled={runningId !== null}
onClick={() => runNow(s)}
>
{t('run_now')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={togglingId !== null}
onClick={() => togglePause(s)}
>
{s.status === 'active' ? t('pause') : t('resume')}
</Button>
<Button
variant="ghost"
size="sm"
disabled={deletingId !== null}
onClick={() => deleteSchedule(s)}
>
{t('delete')}
</Button>
</>
)}
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={runningId !== null}
onClick={() => runNow(s)}
>
{t('run_now')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={togglingId !== null}
onClick={() => togglePause(s)}
>
{s.status === 'active' ? t('pause') : t('resume')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={deletingId !== null}
onClick={() => deleteSchedule(s)}
>
{t('delete')}
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<NewRecurringScheduleDialog