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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
645ed0a53e
commit
02a5d10538
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user