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
parent 08440fed94
commit 57d6651cfc
18 changed files with 556 additions and 248 deletions
+2
View File
@@ -952,3 +952,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit.
[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work).
[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for.
[2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else).
[2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand.
+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>
)
}
+5 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
import { Fragment, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -189,7 +189,7 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
const ALL_PAGE_SIZE = 100000
export default function JournalEntryList() {
export default function JournalEntryList({ pristineSlot }: { pristineSlot?: ReactNode } = {}) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -932,6 +932,9 @@ export default function JournalEntryList() {
// drafts view) must fall through to the main render below so the
// Verifikat/Utkast toggle stays reachable.
if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
if (pristineSlot) {
return <>{pristineSlot}</>
}
return (
<DataList className="stagger-enter">
<DataListEmpty
+253
View File
@@ -0,0 +1,253 @@
'use client'
import type { CSSProperties, ReactNode } from 'react'
import Link from 'next/link'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { START_CARDS, type StartCardName } from './startkort-assets'
// The start card is a self-contained dark surface: like LedgerGraph it carries
// its own palette in both themes, because the strata image bakes its ground
// color into the pixels. Warm white type and a white primary pill sit on top.
// Gradients here are image scrims inside a hero surface, not card chrome
// (see DECISIONS.md 2026-08-13), and rounded-xl is the hero-surface allowance.
const TITLE_COLOR = '#FAF8F1'
const BODY_COLOR = 'rgba(247, 244, 236, 0.72)'
const EYEBROW_COLOR = 'rgba(247, 244, 236, 0.52)'
interface StartCardAction {
label: string
href?: string
onClick?: () => void
}
interface StartCardProps {
card: StartCardName
/** 'side-right': image on the right half. 'bleed-left': full-bleed image,
* text over a scrim on the left. 'center': full-bleed, centered text. */
layout: 'side-right' | 'bleed-left' | 'center'
title: string
body: string
eyebrow?: string
primary: StartCardAction
secondary?: StartCardAction
/** Floating channel badges over the right half (Underlag). */
floatIcons?: boolean
/** Tighter type for narrow containers (the inbox preview pane). */
dense?: boolean
onDismiss?: () => void
dismissLabel?: string
className?: string
}
function ActionButton({ action, kind }: { action: StartCardAction; kind: 'primary' | 'secondary' }) {
const styles =
kind === 'primary'
? 'bg-white text-[#1A1410] hover:bg-white/90 active:bg-white/90 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
: 'border border-white/30 bg-transparent text-[#F7F4EC] hover:bg-white/10 active:bg-white/10 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
if (action.href) {
return (
<Button asChild className={styles}>
<Link href={action.href}>{action.label}</Link>
</Button>
)
}
return (
<Button className={styles} onClick={action.onClick}>
{action.label}
</Button>
)
}
// Channel badges for the Underlag card, Wispr-style: translucent circles over
// the image, one deliberately cropped by the card edge. Brand marks inline so
// the card stays self-contained (Gmail's M, WhatsApp's glyph).
const BADGE_STROKE = '#F4F1E8'
function badgeSvg(paths: string) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke={BADGE_STROKE}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: paths }}
/>
)
}
const FLOAT_BADGES: Array<{ style: CSSProperties; icon: ReactNode }> = [
{
style: { right: '34%', top: '8%', width: 38, height: 38 },
icon: badgeSvg(
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
),
},
{
style: { right: '6%', top: '14%', width: 46, height: 46 },
icon: (
<svg viewBox="0 0 48 48" aria-hidden="true">
<path fill="#4caf50" d="M45 16.2l-5 2.75-5 4.75V40h7c1.657 0 3-1.343 3-3V16.2z" />
<path fill="#1e88e5" d="M3 16.2l3.614 1.71L11 22.7V40H4c-1.657 0-3-1.343-3-3V16.2z" />
<polygon fill="#e53935" points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17" />
<path
fill="#c62828"
d="M3 12.298V16.2l8 6.5V11.2L7.4 8.504A1.61 1.61 0 0 0 6.41 8.174C4.527 8.174 3 9.7 3 11.583v.715z"
/>
<path
fill="#fbc02d"
d="M45 12.298V16.2l-8 6.5V11.2l3.6-2.696c.286-.214.633-.33.99-.33C43.473 8.174 45 9.7 45 11.583v.715z"
/>
</svg>
),
},
{
style: { right: '22%', top: '46%', width: 54, height: 54 },
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#25D366"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"
/>
</svg>
),
},
{
style: { right: '5%', top: '64%', width: 42, height: 42 },
icon: badgeSvg(
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
),
},
{
style: { right: '26%', bottom: '-8%', width: 50, height: 50 },
icon: badgeSvg(
'<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
),
},
]
export function StartCard({
card,
layout,
title,
body,
eyebrow,
primary,
secondary,
floatIcons = false,
dense = false,
onDismiss,
dismissLabel,
className,
}: StartCardProps) {
const asset = START_CARDS[card]
const g = asset.groundRgb
const scrim =
layout === 'center'
? `radial-gradient(ellipse 90% 130% at 50% 55%, rgba(${g}, 0.7) 0%, rgba(${g}, 0.36) 55%, rgba(${g}, 0.06) 100%)`
: `linear-gradient(90deg, rgba(${g}, 0.92) 0%, rgba(${g}, 0.55) 45%, rgba(${g}, 0.08) 75%)`
return (
<div
className={cn('relative h-[216px] overflow-hidden rounded-xl', className)}
style={{ backgroundColor: asset.ground }}
>
{layout === 'side-right' ? (
<div className="absolute inset-y-0 right-0 w-1/2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.src}
width={asset.w}
height={asset.h}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover"
style={{ objectPosition: asset.objectPosition }}
/>
<div
className="absolute inset-0"
style={{ background: `linear-gradient(90deg, ${asset.ground} 0%, rgba(${g}, 0) 42%)` }}
/>
</div>
) : (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.src}
width={asset.w}
height={asset.h}
alt=""
decoding="async"
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: asset.objectPosition }}
/>
<div className="absolute inset-0" style={{ background: scrim }} />
</>
)}
{floatIcons &&
FLOAT_BADGES.map((badge, i) => (
<div
key={i}
aria-hidden="true"
className="absolute z-[2] flex items-center justify-center rounded-full border border-white/15 backdrop-blur-[3px] [&>svg]:h-[46%] [&>svg]:w-[46%]"
style={{ ...badge.style, backgroundColor: 'rgba(30, 36, 52, 0.55)' }}
>
{badge.icon}
</div>
))}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
className="absolute right-4 top-3 z-[3] p-1 text-[#EBE5D3]/50 transition-colors duration-150 hover:text-[#EBE5D3]"
>
<X className="h-4 w-4" />
</button>
)}
<div
className={cn(
'relative z-[2] flex h-full flex-col justify-center',
dense ? 'px-6 py-4' : 'px-7 py-5',
layout === 'center'
? 'mx-auto max-w-[70%] items-center text-center'
: floatIcons
? 'max-w-[66%] items-start'
: 'max-w-[58%] items-start',
)}
>
{eyebrow && (
<div
className="mb-2 text-[10.5px] font-medium uppercase tracking-[0.12em]"
style={{ color: EYEBROW_COLOR }}
>
{eyebrow}
</div>
)}
<h2
className={cn('font-display text-balance', dense ? 'text-[19px] leading-snug' : 'text-2xl leading-tight')}
style={{ color: TITLE_COLOR }}
>
{title}
</h2>
<p className="mb-4 mt-2 text-[13px] leading-relaxed" style={{ color: BODY_COLOR }}>
{body}
</p>
<div className={cn('flex flex-wrap gap-2', layout === 'center' && 'justify-center')}>
<ActionButton action={primary} kind="primary" />
{secondary && <ActionButton action={secondary} kind="secondary" />}
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
// Strata renders for the start cards (public/startkort/*.webp). Generated
// deterministically by strata-engine in the CRM workspace (studio/strata):
//
// node stratify.mjs <source> linen-clear --bg <ground> [flags] --name <out>
//
// Per-file source + flags, so any card can be regenerated or re-grounded:
// venice.webp imports/venice-canal-painting/04-commons-giovanni-antonio-canal...jpg --bg #3A2118
// sthlm.webp imports/stockholm-gamla-stan-riddarholmen-waterfront-skyline/05-web...jpg --bg #16303B
// geese.webp imports/byt-hero/02-commons-canadiangeeseflyinginvformation-jpg.jpg --bg #2E3C58
// stopwatch.webp imports/konsult-hero/04-flickr-stopwatch.jpg (night-clear) --bg #322416
// ledger.webp imports/ledger/01-commons-pittsboro-merchant-business-ledger-dpla-.jpg --bg #1F2B20 --tone 0.35
// abacus.webp imports/abacus/04-commons-abacus-of-state-department-store-jpg.jpg (right unit crop) --bg #1B2136 --tone 0.5
//
// The ground color is baked into the render (the gaps between bars carry it),
// so the card background and scrims below must stay in the same hue: change
// them together or the image edge shows a seam. Intrinsic dimensions declared
// up front so the <img> gets width/height and avoids layout shift.
export const START_CARDS = {
venice: {
src: '/startkort/venice.webp',
w: 2400,
h: 1466,
ground: '#3A2118',
groundRgb: '58, 33, 24',
objectPosition: 'center 42%',
},
sthlm: {
src: '/startkort/sthlm.webp',
w: 2400,
h: 1500,
ground: '#16303B',
groundRgb: '22, 48, 59',
objectPosition: 'center 32%',
},
geese: {
src: '/startkort/geese.webp',
w: 2400,
h: 1600,
ground: '#2E3C58',
groundRgb: '46, 60, 88',
objectPosition: '0 26%',
},
stopwatch: {
src: '/startkort/stopwatch.webp',
w: 2400,
h: 1800,
ground: '#322416',
groundRgb: '50, 36, 22',
objectPosition: 'center 30%',
},
ledger: {
src: '/startkort/ledger.webp',
w: 2400,
h: 3602,
ground: '#1F2B20',
groundRgb: '31, 43, 32',
objectPosition: 'center 30%',
},
abacus: {
src: '/startkort/abacus.webp',
w: 2400,
h: 2176,
ground: '#1B2136',
groundRgb: '27, 33, 54',
objectPosition: 'center 35%',
},
} as const
export type StartCardName = keyof typeof START_CARDS
@@ -39,18 +39,16 @@ import {
ExternalLink,
FileQuestion,
Search,
Circle,
X,
ChevronDown,
ChevronRight,
Sparkles,
MessageCircle,
Maximize2,
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
import { StartCard } from '@/components/dashboard/StartCard'
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton'
import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark'
@@ -313,6 +311,7 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const t = useTranslations('inbox_workspace')
const tStart = useTranslations('start_cards')
const fileInputRef = useRef<HTMLInputElement | null>(null)
// Its own input: sharing the header's would upload without the purchase.
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
@@ -1572,16 +1571,20 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// So: compact card on mobile only, quiet empty state on desktop.
showOnboarding ? (
<>
<div className="xl:hidden">
<OnboardingCard
hasInboxAddress={hasInboxAddress}
hasAnyItem={hasAnyItem}
hasResolvedItem={hasResolvedItem}
onActivateInbox={handleRotateAddress}
onUploadClick={() => fileInputRef.current?.click()}
<div className="xl:hidden p-3">
<StartCard
card="geese"
layout="bleed-left"
dense
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
onDismiss={handleDismissOnboarding}
isActivating={isRotating}
compact
dismissLabel={tStart('inbox_dismiss')}
/>
</div>
<div className="hidden xl:block p-6 text-center text-sm text-muted-foreground">
@@ -1721,16 +1724,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
onRetry={() => { void loadDocument(selected.id, selected.document_id) }}
/>
) : showOnboarding ? (
<div className="h-full flex items-center justify-center px-4 py-6">
<OnboardingCard
hasInboxAddress={hasInboxAddress}
hasAnyItem={hasAnyItem}
hasResolvedItem={hasResolvedItem}
onActivateInbox={handleRotateAddress}
onUploadClick={() => fileInputRef.current?.click()}
onDismiss={handleDismissOnboarding}
isActivating={isRotating}
/>
<div className="h-full flex flex-col justify-center px-4 py-6">
<div className="animate-fade-in">
<StartCard
card="geese"
layout="bleed-left"
dense
floatIcons
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
onDismiss={handleDismissOnboarding}
dismissLabel={tStart('inbox_dismiss')}
/>
<p className="mt-4 text-center text-xs text-muted-foreground">
{t('drop_anywhere_hint')}
</p>
</div>
</div>
) : (
<EmptyPreview
@@ -2206,186 +2220,6 @@ export function DocumentPreview({
// ── Empty preview state ──────────────────────────────────────
// ── Onboarding card ──────────────────────────────────────────
interface OnboardingCardProps {
hasInboxAddress: boolean
hasAnyItem: boolean
hasResolvedItem: boolean
onActivateInbox: () => void
onUploadClick: () => void
onDismiss: () => void
isActivating: boolean
compact?: boolean
}
function OnboardingCard({
hasInboxAddress,
hasAnyItem,
hasResolvedItem,
onActivateInbox,
onUploadClick,
onDismiss,
isActivating,
compact = false,
}: OnboardingCardProps) {
// The card described the page as it was before the underlag rebuild: three
// steps ending at "matcha eller bokför", with no mention that the page now
// searches the mailboxes itself, lists the purchases that are missing a
// receipt, or proposes the kontering. It also carried a Beta badge it had
// outgrown.
const steps = [
{
done: hasInboxAddress,
title: 'Aktivera din inkorgsadress',
hint: 'En egen adress som leverantörer kan fakturera direkt, och som du kan vidarebefordra kvitton till.',
},
{
done: hasAnyItem,
title: 'Koppla en brevlåda, maila in, eller ladda upp',
hint: 'Med en kopplad brevlåda letar Kvittojakten själv upp kvitton till köp som saknar underlag. Utan den fyller du på för hand.',
},
{
done: hasResolvedItem,
title: 'Godkänn konteringen',
hint: 'Matchade underlag får ett förslag på hur de bokförs, utifrån hur du bokfört samma leverantör förut. Du granskar och bokför.',
},
]
// First incomplete step drives the active CTA. Falls back to -1 if all done
// (the parent should have hidden the card by then, but guard anyway).
const currentStep = steps.findIndex((s) => !s.done)
return (
<div
className={cn(
'relative rounded-lg border bg-card',
compact ? 'mx-3 my-3 p-4 text-xs' : 'max-w-md mx-auto p-6'
)}
>
<button
type="button"
onClick={onDismiss}
aria-label="Dölj guide"
className="absolute top-2 right-2 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
<div className={cn('space-y-1', compact ? 'pr-6' : 'pr-8')}>
<div className="flex items-center gap-2 flex-wrap">
<h2
className={cn(
'font-display tracking-tight',
compact ? 'text-sm' : 'text-lg'
)}
>
funkar Underlag
</h2>
</div>
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
Här samlas underlagen, och här syns köpen som saknar ett. Kvittojakten
söker igenom kopplade brevlådor efter kvitton du inte fått in, och
matchade underlag får ett förslag kontering att godkänna. Att samla
underlag är alltid gratis; AI-tolkning och kvittojakt ingår i
abonnemanget.
</p>
</div>
<ol className={cn('space-y-2.5', compact ? 'mt-3' : 'mt-5')}>
{steps.map((step, i) => {
const isDone = step.done
const isCurrent = !isDone && i === currentStep
return (
<li
key={step.title}
className={cn('flex items-start gap-2.5', compact && 'gap-2')}
>
<span className="shrink-0 mt-0.5">
{isDone ? (
<Check className={cn('text-success', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : isCurrent ? (
<span
className={cn(
'inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground font-medium',
compact ? 'h-3.5 w-3.5 text-[9px]' : 'h-4 w-4 text-[10px]'
)}
>
{i + 1}
</span>
) : (
<Circle className={cn('text-muted-foreground/40', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
)}
</span>
<div className="min-w-0">
<p
className={cn(
'font-medium',
isDone ? 'text-muted-foreground line-through decoration-muted-foreground/40' : 'text-foreground',
compact ? 'text-xs' : 'text-sm'
)}
>
{step.title}
</p>
{!isDone && (
<p
className={cn(
'text-muted-foreground mt-0.5',
compact ? 'text-[11px]' : 'text-xs'
)}
>
{step.hint}
</p>
)}
</div>
</li>
)
})}
</ol>
{/* CTA matches the current step. No CTA for step 3: it requires the user to pick a row. */}
{currentStep === 0 && (
<Button
size={compact ? 'sm' : 'default'}
className={cn('w-full mt-4', compact && 'h-8 text-xs')}
onClick={onActivateInbox}
disabled={isActivating}
>
{isActivating ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5 mr-1.5" />
)}
Aktivera inkorgsadress
</Button>
)}
{currentStep === 1 && (
<div className={cn('mt-4 space-y-2', compact && 'mt-3')}>
<Button
size={compact ? 'sm' : 'default'}
className={cn('w-full', compact && 'h-8 text-xs')}
onClick={onUploadClick}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Ladda upp en fil
</Button>
<p className={cn('text-center text-muted-foreground', compact ? 'text-[10px]' : 'text-[11px]')}>
eller maila underlagen till din inkorgsadress.
</p>
</div>
)}
{currentStep === 2 && (
<p
className={cn(
'mt-4 text-center italic text-muted-foreground',
compact ? 'text-[11px]' : 'text-xs'
)}
>
Välj en post i listan för att matcha eller bokföra.
</p>
)}
</div>
)
}
function EmptyPreview({
onUploadClick,
+16 -13
View File
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { StartCard } from '@/components/dashboard/StartCard'
import { Check, Upload, Plus } from 'lucide-react'
import Link from 'next/link'
@@ -13,22 +14,24 @@ interface InboxZeroStateProps {
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
const t = useTranslations('tx_inbox_zero')
const tStart = useTranslations('start_cards')
if (!hasTransactions) {
// No transactions at all
// No transactions at all: the start card leads with the bank connection
// (the import page's own "Rekommenderat" path) and keeps the bank-file
// import as the alternative. Manual creation stays reachable from the
// header split button.
return (
<EmptyState icon={Upload} title={t('empty_title')} description={t('empty_description')}>
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
{t('import_btn')}
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
{t('add_manual_btn')}
</Button>
</EmptyState>
<div className="animate-fade-in">
<StartCard
card="sthlm"
layout="bleed-left"
title={tStart('transactions_title')}
body={tStart('transactions_body')}
primary={{ label: tStart('transactions_primary'), href: '/import?mode=psd2' }}
secondary={{ label: tStart('transactions_secondary'), href: '/import?mode=bank' }}
/>
</div>
)
}
+36
View File
@@ -3118,6 +3118,7 @@
"purchase_searched_hint": "We searched the mailboxes. Drop the receipt here, or forward it to the inbox address.",
"purchase_open_portal": "Open {vendor}",
"purchase_drop": "Drop the file here, or click to choose",
"drop_anywhere_hint": "… or drop files anywhere on the page",
"purchase_uploading": "Uploading…",
"purchase_kind": "Purchase without a document",
"purchase_date": "Date",
@@ -7102,6 +7103,41 @@
"preset_reports_action": "Create invoice",
"preset_reports_secondary": "Import transactions"
},
"start_cards": {
"invoices_title": "Your first invoice takes two minutes.",
"invoices_body": "Send it by email, and the payment is matched to the invoice when it arrives.",
"invoices_primary": "Fetch customers and invoices",
"invoices_secondary": "Create invoice",
"transactions_title": "Connect your bank and the books stay a step ahead.",
"transactions_body": "New transactions arrive every night and are matched to invoices and receipts; you approve with one click.",
"transactions_primary": "Connect bank account",
"transactions_secondary": "Import bank file",
"inbox_title": "Drop the receipt here, and it's done.",
"inbox_body": "Snap a photo, email it or drop the file here: the amount is read and matched to the right transaction.",
"inbox_primary": "Connect mailboxes",
"inbox_secondary": "Upload document",
"inbox_dismiss": "Dismiss",
"salary_eyebrow_start": "Get started",
"salary_eyebrow_next": "Next step",
"salary_title": "The 25th takes care of itself.",
"salary_body_no_employees": "Add your first employee: tax, contributions and AGI are calculated and booked.",
"salary_body_ready": "{count, plural, one {One employee is in place} other {# employees are in place}}: tax, contributions and AGI are calculated and booked.",
"salary_primary_add": "Add employee",
"salary_primary_run": "Start payroll run",
"bookkeeping_title": "Your bookkeeping moves in within five minutes.",
"bookkeeping_body": "Move in from your previous software: fiscal years, the chart of accounts and every voucher come along.",
"bookkeeping_primary": "Fetch from another system",
"bookkeeping_secondary": "Import SIE file",
"bookkeeping_mini_mall_title": "From a template",
"bookkeeping_mini_mall_body": "Rent, salary and other recurring events in one click.",
"bookkeeping_mini_assistent_title": "With the assistant",
"bookkeeping_mini_assistent_body": "Describe the event in words and get the posting suggested.",
"bookkeeping_mini_tomt_title": "Blank voucher",
"bookkeeping_mini_tomt_body": "Line by line, with the next voucher number ready.",
"skattekonto_title": "Your tax account reconciles itself.",
"skattekonto_body": "Connect with BankID and the balance and events are fetched continuously, ready to book.",
"skattekonto_primary": "Connect Skatteverket"
},
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
"copy": "copy",
+36
View File
@@ -3119,6 +3119,7 @@
"purchase_open_portal": "Öppna {vendor}",
"purchase_drop": "Släpp filen här, eller klicka för att välja",
"purchase_uploading": "Laddar upp…",
"drop_anywhere_hint": "… eller släpp filer var som helst på sidan",
"purchase_kind": "Köp utan underlag",
"purchase_date": "Datum",
"purchase_amount": "Belopp",
@@ -7102,6 +7103,41 @@
"preset_reports_action": "Skapa faktura",
"preset_reports_secondary": "Importera transaktioner"
},
"start_cards": {
"invoices_title": "Din första faktura tar två minuter.",
"invoices_body": "Skicka med ett mejl, och betalningen matchas mot fakturan när den kommer in.",
"invoices_primary": "Hämta kunder och fakturor",
"invoices_secondary": "Skapa faktura",
"transactions_title": "Koppla banken, så ligger bokföringen steget före.",
"transactions_body": "Nya transaktioner hämtas varje natt och matchas mot fakturor och kvitton; du godkänner med ett klick.",
"transactions_primary": "Koppla bankkonto",
"transactions_secondary": "Importera bankfil",
"inbox_title": "Släpp kvittot här, sen är det klart.",
"inbox_body": "Fota, mejla eller släpp filen här: beloppet läses av och matchas mot rätt transaktion.",
"inbox_primary": "Koppla brevlådor",
"inbox_secondary": "Ladda upp underlag",
"inbox_dismiss": "Stäng",
"salary_eyebrow_start": "Kom igång",
"salary_eyebrow_next": "Nästa steg",
"salary_title": "Den 25:e sköter sig själv.",
"salary_body_no_employees": "Lägg till din första anställda: skatt, avgifter och AGI räknas fram och bokförs.",
"salary_body_ready": "{count, plural, one {En anställd är på plats} other {# anställda är på plats}}: skatt, avgifter och AGI räknas fram och bokförs.",
"salary_primary_add": "Lägg till anställd",
"salary_primary_run": "Starta lönekörning",
"bookkeeping_title": "Bokföringen flyttar in på fem minuter.",
"bookkeeping_body": "Flytta in från ditt förra program: räkenskapsår, kontoplan och alla verifikat följer med.",
"bookkeeping_primary": "Hämta från annat system",
"bookkeeping_secondary": "Importera SIE-fil",
"bookkeeping_mini_mall_title": "Från mall",
"bookkeeping_mini_mall_body": "Hyra, lön och andra återkommande händelser på ett klick.",
"bookkeeping_mini_assistent_title": "Med assistenten",
"bookkeeping_mini_assistent_body": "Beskriv händelsen med ord och få konteringen föreslagen.",
"bookkeeping_mini_tomt_title": "Tomt verifikat",
"bookkeeping_mini_tomt_body": "Rad för rad, med nästa verifikatnummer redo.",
"skattekonto_title": "Skattekontot stämmer av sig självt.",
"skattekonto_body": "Anslut med BankID så hämtas saldo och händelser löpande, färdiga att bokföra.",
"skattekonto_primary": "Anslut Skatteverket"
},
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
"copy": "kopiera",
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB