feat(empty-states): startkort on six pages with strata imagery (#1603)

Replace the true-empty states on Kundfakturor, Transaktioner, Underlag,
Loner, Bokforing and Skattekonto with StartCard: a self-contained dark
hero (image-derived ground baked into the strata render, white primary
CTA) that says what the page can do instead of what is missing. Primary
CTAs lead with the connect/setup action per page (bank via PSD2 deep
link, mailboxes, Skatteverket, migration import); filtered/search empty
states and viewer fallbacks keep the old compact states. Design signed
off in the Startkort prototype iterations 2026-08-13.

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-13 23:27:24 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 08440fed94
commit 57d6651cfc
18 changed files with 556 additions and 248 deletions
+49 -1
View File
@@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import { StartCard } from '@/components/dashboard/StartCard'
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
@@ -54,6 +55,7 @@ export default function BookkeepingPage() {
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(null)
const t = useTranslations('bookkeeping')
const tStart = useTranslations('start_cards')
const { openAgentSheet } = useAgentSheet()
const { uiState, loaded: uiStateLoaded } = useUiState()
@@ -186,7 +188,53 @@ export default function BookkeepingPage() {
}
/>
<JournalEntryList key={refreshKey} />
<JournalEntryList
key={refreshKey}
pristineSlot={
<div className="animate-fade-in space-y-4">
<StartCard
card="ledger"
layout="bleed-left"
title={tStart('bookkeeping_title')}
body={tStart('bookkeeping_body')}
primary={{ label: tStart('bookkeeping_primary'), href: '/import?mode=migration' }}
secondary={{ label: tStart('bookkeeping_secondary'), href: '/import?mode=sie' }}
/>
{/* The split button's three create modes, laid out as cards so the
pristine page shows what the ledger can do instead of a bare table. */}
<div className="grid gap-4 sm:grid-cols-3">
{(
[
['mall', () => setShowTemplateDialog(true)],
[
'assistent',
() => openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' }),
],
[
'tomt',
() => {
setCopyPrefill(null)
setShowNewEntry(true)
},
],
] as const
).map(([mode, onClick]) => (
<button
key={mode}
type="button"
onClick={onClick}
className="rounded-lg border border-border bg-background p-4 text-left transition-colors duration-150 hover:bg-secondary/35 active:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="text-[13px] font-medium">{tStart(`bookkeeping_mini_${mode}_title`)}</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{tStart(`bookkeeping_mini_${mode}_body`)}
</p>
</button>
))}
</div>
</div>
}
/>
{showTemplateDialog && (
<TemplateBookDialog
+12 -2
View File
@@ -45,7 +45,7 @@ import {
FileInput,
FileDown,
} from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { StartCard } from '@/components/dashboard/StartCard'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { FiscalPeriod, Invoice } from '@/types'
@@ -223,6 +223,7 @@ export default function InvoicesPage() {
const supabase = createClient()
const t = useTranslations('invoices')
const tCommon = useTranslations('common')
const tStart = useTranslations('start_cards')
const { uiState, loaded: uiStateLoaded } = useUiState()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
@@ -695,7 +696,16 @@ export default function InvoicesPage() {
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices onAction={openNewInvoice} />
<div className="animate-fade-in">
<StartCard
card="venice"
layout="center"
title={tStart('invoices_title')}
body={tStart('invoices_body')}
primary={{ label: tStart('invoices_primary'), href: '/import?mode=migration' }}
secondary={{ label: tStart('invoices_secondary'), onClick: openNewInvoice }}
/>
</div>
) : (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
+30 -14
View File
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { StartCard } from '@/components/dashboard/StartCard'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { HandCoins, Loader2, Plus, Users } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
@@ -53,6 +54,7 @@ export default function SalaryPage() {
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('salary')
const tStart = useTranslations('start_cards')
const load = useCallback(async () => {
const [runsRes, empRes] = await Promise.all([
@@ -146,23 +148,37 @@ export default function SalaryPage() {
{header}
{runs.length === 0 && employees.length === 0 ? (
<EmptyState
icon={Users}
title={t('onboarding_title')}
description={t('onboarding_description')}
actionLabel={canWrite ? t('onboarding_action') : undefined}
actionHref={canWrite ? '/salary/employees/new' : undefined}
/>
canWrite ? (
<div className="animate-fade-in">
<StartCard
card="stopwatch"
layout="side-right"
eyebrow={tStart('salary_eyebrow_start')}
title={tStart('salary_title')}
body={tStart('salary_body_no_employees')}
primary={{ label: tStart('salary_primary_add'), href: '/salary/employees/new' }}
/>
</div>
) : (
<EmptyState icon={Users} title={t('onboarding_title')} description={t('onboarding_description')} />
)
) : (
<div>
{runs.length === 0 ? (
<EmptyState
icon={HandCoins}
title={t('empty_runs_title')}
description={t('empty_runs_description')}
actionLabel={canWrite ? t('start_run') : undefined}
onAction={canWrite ? startRun : undefined}
/>
canWrite ? (
<div className="animate-fade-in">
<StartCard
card="stopwatch"
layout="side-right"
eyebrow={tStart('salary_eyebrow_next')}
title={tStart('salary_title')}
body={tStart('salary_body_ready', { count: employees.length })}
primary={{ label: tStart('salary_primary_run'), onClick: startRun }}
/>
</div>
) : (
<EmptyState icon={HandCoins} title={t('empty_runs_title')} description={t('empty_runs_description')} />
)
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
+11 -14
View File
@@ -10,6 +10,7 @@ import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { EmptyState } from '@/components/ui/empty-state'
import { StartCard } from '@/components/dashboard/StartCard'
import { Skeleton } from '@/components/ui/skeleton'
import {
TH_CLASS,
@@ -42,8 +43,6 @@ import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
import {
AlertCircle,
Copy,
ExternalLink,
Landmark,
RefreshCw,
} from 'lucide-react'
import { useCapability } from '@/contexts/CompanyContext'
@@ -89,6 +88,7 @@ interface MatchCandidate {
export default function SkattekontoPage() {
const { toast } = useToast()
const t = useTranslations('skattekonto')
const tStart = useTranslations('start_cards')
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
const [showPayment, setShowPayment] = useState(false)
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
@@ -374,18 +374,15 @@ export default function SkattekontoPage() {
return (
<div className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
<EmptyState
icon={Landmark}
title="Skatteverket är inte anslutet"
description="För att se saldo och transaktioner på skattekontot behöver du ansluta med BankID i inställningarna."
>
<Button asChild>
<Link href="/settings/tax">
<ExternalLink className="mr-2 h-4 w-4" />
Anslut Skatteverket
</Link>
</Button>
</EmptyState>
<div className="animate-fade-in">
<StartCard
card="abacus"
layout="side-right"
title={tStart('skattekonto_title')}
body={tStart('skattekonto_body')}
primary={{ label: tStart('skattekonto_primary'), href: '/settings/tax' }}
/>
</div>
</div>
)
}