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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
08440fed94
commit
57d6651cfc
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user