feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)

* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs

The sandbox showed neither Löner nor a usable set of reports, and the
"connect X" surfaces were oversized boxed cards.

Sandbox seed:
- pays_salaries + employer_registered, so Löner and Anställda appear at all
  (an enskild firma is not an employer by default). Both seeded employees are
  employment_type 'employee': an EF may employ staff, just not its own owner.
- Two employees, one booked and one open lönekörning, and the three verifikat
  the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/
  2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables.
- Year-to-date ledger history, January through last month, with the quarterly
  momsredovisning cleared to 2650 and paid on the SFL deadline. Without the
  settlement the demo collected VAT all year and never remitted it, which left
  an implausible bank balance and 155 813 kr of moms "att betala".
- The history is exempted through journal_entry_no_doc_required, the same way
  the SIE-import opt-in treats imported books: its kvitton live in the previous
  system, and unflagged it put 39 "verifikat utan underlag" on the home screen.
- Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma.
- History is numbered before the invoice and payroll vouchers so the series runs
  forwards through the year, and its writes are batched.

Connect CTAs:
- Bank picker: a two-column grid of 95px bordered logo cards becomes flat
  hairline rows, Lucide icons, and a quiet inline connecting state.
- Cloud backup: each provider collapses to one row; the BFL note is shown once
  for the section and names only configured destinations.
- Hem first-run: only the active step argues its case, but every not-done step
  keeps a reachable action. The Skatteverket nudge becomes one quiet sentence.

Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already
has an Assistent tab. A collapsed session keeps its handle everywhere except
/chat, which is itself the way back to the conversation.

Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox
guard, and a seeded booked run put "Skicka lönebesked" one click from an
anonymous visitor with live Resend behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): check the two unchecked Supabase errors and tighten review nits

CodeRabbit review on #1437.

Major: two calls discarded their error and continued with null data. A failed
chart_of_accounts re-select would have written account_id: null onto every
ledger-history and salary voucher line, and a failed next_voucher_number would
have inserted a posted verifikat with no number, which is a hole in the
verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is
rejected explicitly.

Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the
salary breakdown test's name said the opposite of its assertions after the
switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat
per month before the quarterly momsredovisning added a seventh in March, May and
June.

Bank picker: the spinner is aria-hidden, so loading and connecting had no text
equivalent and a failed bank fetch was never announced. Added role="status" with
an sr-only label, and role="alert" on the error line.

Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was
unconfirmed before this PR too, so adding a dialog is a behaviour change beyond
the redesign rather than a fix to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 15:30:04 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 43386b4852
commit d41ef2a909
18 changed files with 3718 additions and 412 deletions
+57 -16
View File
@@ -12,6 +12,13 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
// clicked. Hidden when the sheet is already open so the icon doesn't double up.
//
// Desktop-only for a FRESH open (founder call 2026-08-06). On mobile the pill
// landed on top of the bottom nav and covered page content, and the bottom nav
// already carries an "Assistent" tab to /chat behind the very same
// identity.isVerified gate used here, so the pill was pure redundancy. A
// COLLAPSED session keeps its handle on every viewport and on every page except
// /chat: see the page-suppression block and the visibility class below.
//
// Route-aware: routeToIntent(pathname) picks the right intent + intentArgs so
// clicking the FAB on /invoices/abc-123 opens invoice.draft with that invoice
// id (rather than the page-agnostic general.help with just the URL). The
@@ -44,19 +51,33 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
// the session is merely collapsed we KEEP the FAB: it's the handle that
// brings the minimized conversation back.
if (isOpen && !collapsed) return null
// The page-suppression rules below apply only to a FRESH open. A collapsed
// session always gets its reopen handle, regardless of page: otherwise a
// conversation minimized on /chat or /bookkeeping/[id] could never be
// brought back.
// Page suppression, split by WHY the page suppresses. The split is the point:
// one rule is about redundancy (the page already IS the conversation), the
// other is about crowding (the page is dense and the pill would sit on top of
// it). Only the redundancy rule survives into the collapsed state, because
// only it leaves the user another route back to the conversation.
//
// /chat*: suppressed in BOTH states. Fresh open: the surface IS the chat, so
// a floating "Fråga …" pill is redundant and overlaps the input. Collapsed:
// the /chat layout lists every conversation in its sidebar and renders the
// selected one in the main pane, so navigating here IS the way back; a handle
// that re-expands the panel on top of it would put two chats on one screen.
// Safe because the session lives in AgentSheetProvider in the dashboard
// layout, one level above the page: it stays mounted (messages, streaming,
// pending approval cards intact) the whole time the user is on /chat, and the
// handle reappears the moment they navigate anywhere else, bottom nav
// included. Nothing is discarded, the handle is just hidden while the user is
// standing inside the surface it would lead to.
if (pathname?.startsWith('/chat')) return null
// /bookkeeping/[id]: FRESH open only. The verifikation editor is a dense
// regulatory surface (debits/credits, BAS codes, period locks) and a floating
// "Fråga … om denna verifikation" pill on top of it adds noise without
// earning its place. A COLLAPSED session keeps its handle here, and on every
// other page in the app: this page offers no other route back to a minimized
// conversation, and stranding a half-finished booking is a worse outcome than
// a pill over the editor. (/bookkeeping list, /bookkeeping/new and
// /bookkeeping/year-end are not the editor and keep the fresh-open FAB.)
if (!collapsed) {
// The /chat surface IS the chat: a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks): a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
const segs = pathname?.split('/').filter(Boolean) ?? []
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
return null
@@ -107,13 +128,33 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
})
}
// Mobile visibility, decided per state rather than per viewport alone:
//
// fresh open -> 'hidden md:flex'. `display: none` (not opacity/visibility)
// so the button is genuinely non-interactive on mobile: not
// clickable, not tabbable, out of the accessibility tree.
// Losing it costs nothing, the bottom nav's "Assistent" tab
// reaches the same agent.
// collapsed -> 'flex' on every viewport. Off /chat this is the ONLY route
// back to a minimized conversation: the bottom-nav tab opens
// the /chat surface instead of restoring the in-progress
// sheet, so hiding the handle on mobile would strand a
// session mid-booking with no way to reopen it. On /chat the
// handle is suppressed outright (see above), which is exactly
// the case where the nav tab does lead somewhere useful.
//
// A pure CSS switch on purpose: no useMediaQuery, so no hydration mismatch,
// no resize listener, no layout shift on first paint.
const visibilityClass = collapsed ? 'flex' : 'hidden md:flex'
return (
<button
onClick={handleClick}
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
// no mobile nav to worry about.
className="fixed right-4 z-30 flex h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4"
// indicator (env(safe-area-inset-bottom)). Still needed after the FAB
// went desktop-only: the collapsed handle above renders on mobile too.
// Desktop: standard 20px lift, no mobile nav to worry about.
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full bg-foreground pl-2 pr-4 text-background shadow-lg hover:bg-foreground/90 transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4`}
aria-label={labelText}
// The label changes on its own while the panel is hidden, so it has to be
// announced rather than only redrawn. Polite: it never interrupts.
@@ -129,7 +170,7 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
{/* An answer waiting behind a hidden panel is the one thing worth a
dot: it is unread, not in progress. */}
{finished && (
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-foreground" />
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-success ring-2 ring-foreground" />
)}
</span>
{working && collapsed && (
+28 -33
View File
@@ -2,9 +2,6 @@
import { useCallback, useSyncExternalStore } from 'react'
import { useTranslations } from 'next-intl'
import { ArrowRight, FileCheck, X } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -32,8 +29,13 @@ interface SkatteverketPromoCardProps {
/**
* Dismissible dashboard nudge for companies that have never connected
* Skatteverket. New companies meet the connect step in NewUserChecklist;
* this card is the equivalent surface for existing companies, which
* otherwise only discover the integration deep inside the VAT/AGI flows.
* this is the equivalent surface for existing companies, which otherwise
* only discover the integration deep inside the VAT/AGI flows.
* Shape: one quiet sentence with the connect link at the end and the same
* "Dölj" text control the checklist uses, not a boxed card. It is an
* optional offer, not an exception, so it stays muted rather than taking
* the ochre .attn tone (convention 6/12: attention colour is for data and
* real exceptions).
* Capability-less users never see it: the paywall upsell lives where the
* intent is (SkatteverketPanel, AGIPanel), not on the dashboard.
* Sandbox companies never see it either: Skatteverket is one of the external
@@ -61,34 +63,27 @@ export function SkatteverketPromoCard({ companyId, connected }: SkatteverketProm
if (!extensionEnabled || !hasCapability || isSandbox || connected || dismissed) return null
return (
<section>
<Card>
<CardContent className="p-6 flex items-center gap-4">
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-secondary">
<FileCheck className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-display text-xl leading-tight">{t('skv_promo_title')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('skv_promo_description')}</p>
</div>
<Button variant="outline" asChild className="flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page; the authorize endpoint 302s to Skatteverket, which the client router cannot follow */}
<a href="/api/extensions/ext/skatteverket/authorize?return_to=/">
{t('skv_promo_cta')}
<ArrowRight className="ml-1.5 h-4 w-4" />
</a>
</Button>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0"
aria-label={t('skv_promo_dismiss')}
onClick={dismiss}
>
<X className="h-4 w-4" />
</Button>
</CardContent>
</Card>
<section className="flex items-start justify-between gap-4">
<p className="text-[12.5px] leading-5 text-muted-foreground">
{t('skv_promo_description')}{' '}
{/* py-3/-my-3: a 44px tap target on a link that still sits inline in
the sentence; the negative margin keeps the line box at 20px so
nothing shifts. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page; the authorize endpoint 302s to Skatteverket, which the client router cannot follow */}
<a
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
className="inline-block -my-3 whitespace-nowrap py-3 text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground"
>
{t('skv_promo_cta')}
</a>
</p>
<button
type="button"
onClick={dismiss}
className="-my-3 shrink-0 py-3 text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('skv_promo_dismiss')}
</button>
</section>
)
}
+112 -78
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
@@ -23,8 +24,13 @@ interface NewUserChecklistProps {
/**
* First-run getting-started block on Hem, in the founder-picked stepped
* shape: a numbered three-step thread (get the books in, connect the bank,
* build the assistant) with the partner marks on the steps that have them.
* shape: a numbered thread (get the books in, connect the bank, connect
* Skatteverket, build the assistant) on a hairline spine.
* Only the step you are on argues its case: it carries the description and
* the partner marks next to a filled action. Steps you have not reached yet
* drop the pitch but keep a quiet outline action, so any step stays one
* click away (connect Skatteverket before the bank, if that is your order).
* Done steps collapse to their title.
* The persisted state machine is unchanged (path / completedAt /
* dismissedAt via /api/onboarding/state); "Starta från början" records the
* fresh path and simply checks off step one.
@@ -104,11 +110,12 @@ export default function NewUserChecklist({
}
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : 4
const stepCount = hasSkatteverket ? 4 : 3
return (
<section className={className} aria-label={t('title', { count: hasSkatteverket ? 4 : 3 })}>
<div className="mb-1 flex items-center justify-between gap-3">
<h2 className="text-sm font-medium">{t('title', { count: hasSkatteverket ? 4 : 3 })}</h2>
<section className={className} aria-label={t('title', { count: stepCount })}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium">{t('title', { count: stepCount })}</h2>
<button
type="button"
disabled={saving !== null}
@@ -119,40 +126,41 @@ export default function NewUserChecklist({
</button>
</div>
<div className="mt-4">
<ol className="mt-3" role="list">
<Step
number={1}
done={step1Done}
active={activeStep === 1}
title={t('step_books_title')}
>
<p className="text-xs leading-5 text-muted-foreground">
{t('step_books_description')}{' '}
<button
type="button"
action={(variant) => (
<Button
size="sm"
variant={variant}
disabled={saving !== null}
onClick={goFresh}
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
onClick={() => void goMigration()}
>
{t('step_books_fresh_link')}
</button>{' '}
{t('step_books_fresh_suffix')}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
size="sm"
disabled={saving !== null}
onClick={() => void goMigration()}
>
{t('step_books_action')}
</Button>
<span className="flex items-center gap-1.5">
<LogoMark src="/logos/fortnox.svg" name="Fortnox" />
<LogoMark src="/logos/visma.jpeg" name="Visma" />
<LogoMark src="/logos/bokio.png" name="Bokio" />
<span className="ml-1 text-[11px] text-muted-foreground">{t('step_books_sie')}</span>
</span>
</div>
{t('step_books_action')}
</Button>
)}
marks={
<>
<LogoMark src="/logos/fortnox.svg" name="Fortnox" />
<LogoMark src="/logos/visma.jpeg" name="Visma" />
<LogoMark src="/logos/bokio.png" name="Bokio" />
<span className="text-[11px] text-muted-foreground">{t('step_books_sie')}</span>
</>
}
>
{t('step_books_description')}{' '}
<button
type="button"
disabled={saving !== null}
onClick={goFresh}
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_books_fresh_link')}
</button>{' '}
{t('step_books_fresh_suffix')}
</Step>
<Step
@@ -160,25 +168,23 @@ export default function NewUserChecklist({
done={step2Done}
active={activeStep === 2}
title={t('step_bank_title')}
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_bank_description')}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
action={(variant) => (
<Button
size="sm"
variant={activeStep === 2 ? 'default' : 'outline'}
variant={variant}
disabled={saving !== null}
onClick={() => void goBank()}
>
{t('step_bank_action')}
</Button>
{hasBanking && (
<LogoMark
src="/logos/enable-banking-icon.png"
name="Enable Banking"
mono
/>
)}
</div>
)}
marks={
hasBanking ? (
<LogoMark src="/logos/enable-banking-icon.png" name="Enable Banking" mono />
) : undefined
}
>
{t('step_bank_description')}
</Step>
{hasSkatteverket && (
@@ -187,22 +193,18 @@ export default function NewUserChecklist({
done={step3Done}
active={activeStep === 3}
title={t('step_skv_title')}
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_skv_description')}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
size="sm"
variant={activeStep === 3 ? 'default' : 'outline'}
asChild
>
action={(variant) => (
<Button size="sm" variant={variant} asChild>
{/* The authorize endpoint redirects off-site to Skatteverket. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/api/extensions/ext/skatteverket/authorize?return_to=/">
{t('step_skv_action')}
</a>
</Button>
<LogoMark src="/logos/skatteverket_color.svg" name="Skatteverket" />
</div>
)}
marks={<LogoMark src="/logos/skatteverket_color.svg" name="Skatteverket" />}
>
{t('step_skv_description')}
</Step>
)}
@@ -213,30 +215,41 @@ export default function NewUserChecklist({
title={t('step_assistant_title')}
badge={t('step_assistant_beta')}
last
>
<p className="text-xs leading-5 text-muted-foreground">{t('step_assistant_description')}</p>
<div className="mt-3">
action={(variant) => (
<Button
size="sm"
variant={activeStep >= 3 ? 'default' : 'outline'}
variant={variant}
onClick={() => router.push(hasAi ? '/onboarding/agent' : '/settings/billing')}
>
{t('step_assistant_action')}
</Button>
</div>
)}
>
{t('step_assistant_description')}
</Step>
</div>
</ol>
</section>
)
}
/** One numbered step on the thread: dot + hairline down to the next step. */
/**
* One numbered step on the thread: dot + hairline down to the next step.
* `children` (the pitch) and `marks` (partner logos) render only while this
* is the step the user is on. `action` renders on every step that is not
* done: filled on the active step, outline on the ones further down, so no
* step is ever unreachable just because it is not next in line.
* The title row is a constant 36px so it matches the h-9 action button and
* the dot (mt-1) centres in it; the spine then runs dot-bottom to dot-top
* (top-8 to -bottom-1) without visible breaks.
*/
function Step({
number,
done,
active,
title,
badge,
action,
marks,
last = false,
children,
}: {
@@ -245,43 +258,64 @@ function Step({
active: boolean
title: string
badge?: string
action?: (variant: 'default' | 'outline') => React.ReactNode
marks?: React.ReactNode
last?: boolean
children: React.ReactNode
}) {
const open = active && !done
return (
<div className="relative flex gap-4 pb-6 last:pb-0">
<li
className="relative flex gap-3 pb-3 last:pb-0"
aria-current={open ? 'step' : undefined}
>
{!last && (
<span
className="absolute bottom-0 left-[13px] top-8 w-px bg-border"
className="absolute -bottom-1 left-[13px] top-8 w-px bg-border"
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums',
'relative z-10 mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums',
done
? 'border-success/40 text-success'
: active
: open
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground',
)}
>
{done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : number}
</span>
<div className="min-w-0 flex-1 pt-1">
<p className={cn('flex items-center gap-2 text-sm', active && !done && 'font-medium', done && 'text-muted-foreground')}>
{title}
{badge && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] leading-none text-muted-foreground">
{badge}
</span>
)}
</p>
{/* A ticked step needs no pitch: the description and actions only
render while the step is still open. */}
{!done && <div className="mt-1">{children}</div>}
<div className="min-w-0 flex-1">
<div className="flex min-h-9 flex-wrap items-center gap-x-3 gap-y-2">
<div
className={cn(
'flex items-center gap-2 text-sm',
open ? 'font-medium' : 'text-muted-foreground',
)}
>
<span>{title}</span>
{badge && (
<Badge
variant="secondary"
className={cn('shrink-0 uppercase tracking-wider', !open && 'font-normal')}
>
{badge}
</Badge>
)}
</div>
{!done && action?.(open ? 'default' : 'outline')}
</div>
{open && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="text-xs leading-5 text-muted-foreground">{children}</p>
{marks && <span className="flex items-center gap-2">{marks}</span>}
</div>
)}
</div>
</div>
</li>
)
}