diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx
index 19896591..297da1a2 100644
--- a/app/(dashboard)/assets/page.tsx
+++ b/app/(dashboard)/assets/page.tsx
@@ -139,7 +139,7 @@ export default function AssetsPage() {
{formatDate(asset.acquisition_date)}
-
+
{formatCurrency(Number(asset.acquisition_cost))}
diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx
index aff1bde7..8d1f47db 100644
--- a/app/(dashboard)/invoices/page.tsx
+++ b/app/(dashboard)/invoices/page.tsx
@@ -399,7 +399,7 @@ export default function InvoicesPage() {
0,
}
- // Calculate totals from journal entry lines using account classes
- const calculateTotals = (lines: typeof journalLines, fromDate: string) => {
- const filtered = (lines || []).filter((l) => {
- const entry = l.journal_entry as unknown as { entry_date: string; status: string }
- return entry.entry_date >= fromDate
- })
-
- let revenue = 0
- let expenses = 0
-
- for (const line of filtered) {
- const acct = line.account_number
- if (acct.startsWith('3')) {
- // Revenue: class 3, credit-normal accounts
- revenue += Math.round(((line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
- } else if (acct.startsWith('4') || acct.startsWith('5') || acct.startsWith('6') || acct.startsWith('7')) {
- // Expenses: classes 4-7, debit-normal accounts
- expenses += Math.round(((line.debit_amount || 0) - (line.credit_amount || 0)) * 100) / 100
- }
- }
-
- revenue = Math.round(revenue * 100) / 100
- expenses = Math.round(expenses * 100) / 100
-
- return { income: revenue, expenses, net: Math.round((revenue - expenses) * 100) / 100 }
- }
-
- const ytdTotals = calculateTotals(journalLines, startOfYearStr)
- const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
-
- // Mirror the per-invoice öresavrundning rule used on the invoice list/detail
- // pages: sum the displayed (rounded) SEK amount per invoice so the dashboard
- // total matches what the user sees on the invoice list when the setting is on.
- const unpaidTotal = (unpaidInvoices || []).reduce(
- (sum, inv) => sum + getDisplayTotal(
- { total: Number(inv.total_sek || inv.total), currency: 'SEK', ore_rounding: inv.ore_rounding },
- settings,
- ).displayed,
- 0
- )
-
- const unpaidVatTotal = (unpaidInvoices || []).reduce(
- (sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
- 0
- )
-
- const overdueCount = (unpaidInvoices || []).filter(
- (inv) => inv.status === 'overdue'
- ).length
-
- let bankBalance: number | null = null
- if (bankConnections && bankConnections.length > 0) {
- const allBalances = bankConnections.flatMap(conn => {
- const accounts = conn.accounts_data as { balance: number }[] | null
- return accounts || []
- })
- if (allBalances.length > 0) {
- bankBalance = allBalances.reduce((sum, acc) => sum + (acc.balance || 0), 0)
- }
- }
-
- const nowMs = new Date().getTime()
+ const nowMs = now.getTime()
const expiringBankConnections = (bankConnections || [])
.filter(conn => {
if (!conn.consent_expires) return false
@@ -182,24 +111,17 @@ export default async function DashboardPage() {
),
}))
+ const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
+
return (
-
+
{emp.salary_type === 'hourly'
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}${t('hourly_suffix')}` : '-'
: emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '-'}
diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx
index f2780cb1..540e64cb 100644
--- a/app/(dashboard)/salary/page.tsx
+++ b/app/(dashboard)/salary/page.tsx
@@ -216,10 +216,10 @@ export default function SalaryPage() {
{employeeCount ?? ''}
-
+
{formatCurrency(run.total_gross)}
-
+
{formatCurrency(run.total_net)}
diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx
index faa1623b..7bf92394 100644
--- a/app/(dashboard)/supplier-invoices/page.tsx
+++ b/app/(dashboard)/supplier-invoices/page.tsx
@@ -320,13 +320,13 @@ export default function SupplierInvoicesPage() {
{/* Belopp rounds like the detail page when the invoice's
öresavrundning flag is on; "kvar att betala" stays
öre-exact (it is the actual outstanding debt). */}
-
+
{formatCurrency(getDisplayTotal(
{ total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding },
{ ore_rounding: false },
).displayed, inv.currency)}
-
+
{formatCurrency(inv.remaining_amount, inv.currency)}
diff --git a/app/globals.css b/app/globals.css
index 17f370a7..1c23d20f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -201,11 +201,15 @@ body {
font-optical-sizing: auto;
}
-h1, h2, h3 {
- font-family: var(--font-display);
- font-weight: 500;
- letter-spacing: -0.015em;
- font-optical-sizing: auto;
+/* Layered so utility classes (e.g. font-sans on a pane title) can override:
+ unlayered element rules would beat Tailwind's layered utilities. */
+@layer base {
+ h1, h2, h3 {
+ font-family: var(--font-display);
+ font-weight: 500;
+ letter-spacing: -0.015em;
+ font-optical-sizing: auto;
+ }
}
/* Number styling - tabular for alignment */
@@ -303,6 +307,66 @@ h1, h2, h3 {
}
}
+/* ─────────────────────────────────────────────────────────────────────────
+ * Enter/exit animation utilities (animate-in / animate-out vocabulary).
+ * The codebase uses the shadcn/tailwindcss-animate classes but no animate
+ * plugin was ever installed, so Tailwind v4 silently dropped them all:
+ * every popover, dialog and slide-over appeared instantly. These implement
+ * the exact subset in use, plugin-free. The duration and ease utilities
+ * compose via Tailwind v4's --tw-duration and --tw-ease variables; the
+ * global prefers-reduced-motion block collapses them like everything else.
+ * ───────────────────────────────────────────────────────────────────────── */
+@keyframes accEnter {
+ from {
+ opacity: var(--tw-enter-opacity, 1);
+ transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0)
+ scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1);
+ }
+}
+@keyframes accExit {
+ to {
+ opacity: var(--tw-exit-opacity, 1);
+ transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0)
+ scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1);
+ }
+}
+@utility animate-in {
+ animation-name: accEnter;
+ animation-duration: var(--tw-duration, 150ms);
+ animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
+ animation-fill-mode: both;
+}
+@utility animate-out {
+ animation-name: accExit;
+ animation-duration: var(--tw-duration, 150ms);
+ animation-timing-function: var(--tw-ease, cubic-bezier(0.32, 0.72, 0, 1));
+ animation-fill-mode: both;
+}
+@utility fade-in { --tw-enter-opacity: 0; }
+@utility fade-in-0 { --tw-enter-opacity: 0; }
+@utility fade-out { --tw-exit-opacity: 0; }
+@utility fade-out-0 { --tw-exit-opacity: 0; }
+@utility zoom-in-95 { --tw-enter-scale: 0.95; }
+@utility zoom-out-95 { --tw-exit-scale: 0.95; }
+@utility slide-in-from-top { --tw-enter-translate-y: -100%; }
+@utility slide-in-from-top-1 { --tw-enter-translate-y: -0.25rem; }
+@utility slide-in-from-top-2 { --tw-enter-translate-y: -0.5rem; }
+@utility slide-in-from-top-full { --tw-enter-translate-y: -100%; }
+@utility slide-in-from-bottom { --tw-enter-translate-y: 100%; }
+@utility slide-in-from-bottom-1 { --tw-enter-translate-y: 0.25rem; }
+@utility slide-in-from-bottom-2 { --tw-enter-translate-y: 0.5rem; }
+@utility slide-in-from-left { --tw-enter-translate-x: -100%; }
+@utility slide-in-from-left-1 { --tw-enter-translate-x: -0.25rem; }
+@utility slide-in-from-left-2 { --tw-enter-translate-x: -0.5rem; }
+@utility slide-in-from-right { --tw-enter-translate-x: 100%; }
+@utility slide-in-from-right-2 { --tw-enter-translate-x: 0.5rem; }
+@utility slide-in-from-right-full { --tw-enter-translate-x: 100%; }
+@utility slide-out-to-top { --tw-exit-translate-y: -100%; }
+@utility slide-out-to-bottom { --tw-exit-translate-y: 100%; }
+@utility slide-out-to-left { --tw-exit-translate-x: -100%; }
+@utility slide-out-to-right { --tw-exit-translate-x: 100%; }
+@utility slide-out-to-right-full { --tw-exit-translate-x: 100%; }
+
/* Staggered entrance animation */
.stagger-enter > * {
animation: slideUp var(--duration-slow) var(--ease-out) both;
@@ -362,10 +426,16 @@ h1, h2, h3 {
}
@supports (scrollbar-color: auto) {
- * {
- scrollbar-width: thin;
- scrollbar-color: transparent transparent;
- transition: scrollbar-color 300ms ease-out;
+ /* Layered: the universal transition must not beat Tailwind's layered
+ transition-* utilities (unlayered rules win over layers regardless of
+ specificity: this silently killed every width/margin/color transition
+ in the app). scrollbar-color still animates via the base default. */
+ @layer base {
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+ transition: scrollbar-color 300ms ease-out;
+ }
}
.is-scrolling {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index d83fe295..67daa87b 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -1105,7 +1105,7 @@ export default function JournalEntryList() {
)}
-
+
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx
index 5935de08..b3d32174 100644
--- a/components/dashboard/AttGoraSection.tsx
+++ b/components/dashboard/AttGoraSection.tsx
@@ -3,7 +3,6 @@
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
-import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
@@ -53,7 +52,6 @@ interface AttGoraSectionProps {
worklist: WorklistCounts
suggestedMatches: SuggestedMatch[]
expiringBankConnections?: ExpiringBankConnection[]
- staleUncategorizedCount: number
}
interface WorklistRowProps {
@@ -69,23 +67,29 @@ function WorklistRow({ href, icon: Icon, label, detail, count, badge }: Worklist
return (
-
-
-
{label}
- {detail &&
{detail}
}
+
+
+
+
+
{label}
+ {detail &&
{detail}
}
- {badge}
-
{count}
-
+
+ {badge}
+
+ {count}
+
+
+
)
}
function BandHeader({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
)
@@ -95,7 +99,6 @@ export default function AttGoraSection({
worklist,
suggestedMatches,
expiringBankConnections = [],
- staleUncategorizedCount,
}: AttGoraSectionProps) {
const t = useTranslations('dashboard')
const { toast } = useToast()
@@ -198,15 +201,15 @@ export default function AttGoraSection({
return (
-
-
{t('att_gora_title')}
-
+ {/* Pane header: Geist title + quiet count over a hairline */}
+
+
{t('att_gora_title')}
+
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
-
-
+
{allClear ? (
{t('band_bokfor')}
-
+
{counts.book_transaction > 0 && (
0 ? (
-
- {t('row_book_transactions_stale', { count: staleUncategorizedCount })}
-
- ) : undefined
- }
/>
)}
{matches.length > 0 && (
@@ -319,7 +315,7 @@ export default function AttGoraSection({
{granskaRows && (
{t('band_granska')}
-
+
{counts.supplier_invoice_approval > 0 && (
{t('band_bevaka')}
-
+
{counts.overdue_invoice > 0 && (
)}
-
-
+
)
}
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 643e17e1..e6c131cf 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -1,46 +1,35 @@
'use client'
+import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
-import { cn, formatCurrency } from '@/lib/utils'
-import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
-import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
-import { useCapability } from '@/contexts/CompanyContext'
+import { useCapability, useCompany } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import AttGoraSection from '@/components/dashboard/AttGoraSection'
+import ResumePane from '@/components/dashboard/ResumePane'
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
-import {
- ChevronRight,
- CheckCircle2,
- ArrowRight,
- MessageCircle,
-} from 'lucide-react'
-import type { Deadline, InitialSetupState, OnboardingProgress } from '@/types'
+import { ArrowRight, MessageCircle } from 'lucide-react'
+import type { InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
-import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
+import type { ResumeItem } from '@/lib/worklist/resume'
interface DashboardContentProps {
companyId: string
- summary: {
- ytd: { income: number; expenses: number; net: number }
- mtd: { income: number; expenses: number; net: number }
- unpaidInvoicesCount: number
- unpaidInvoicesTotal: number
- unpaidVatTotal: number
- overdueInvoicesCount: number
- bankBalance: number | null
- expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
- deadlines: Deadline[]
- staleUncategorizedCount: number
- }
+ /** Signed-in user's first name for the greeting; null falls back to a
+ * nameless greeting. */
+ userFirstName?: string | null
+ /** Expiring PSD2 consents (dashboard-only worklist extra). */
+ expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
worklist: WorklistCounts
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
suggestedMatches: SuggestedMatch[]
+ /** In-progress work for the Fortsätt pane (lib/worklist/resume). */
+ resumeItems: ResumeItem[]
onboardingProgress?: OnboardingProgress
initialSetup: InitialSetupState
/**
@@ -52,30 +41,56 @@ interface DashboardContentProps {
agentBuilt?: boolean
}
-export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, initialSetup, agentBuilt = true }: DashboardContentProps) {
+/**
+ * Hem (concept scene 14): greeting, then the two panes side by side:
+ * Att göra (obligations, lib/worklist) and Fortsätt (in-progress work,
+ * lib/worklist/resume). KPI tiles, revenue/expense cards and the deadline/tax
+ * widgets left the page (founder direction, dev_docs/last_session_resume.md
+ * §8): the numbers live at /kpi and /reports, deadlines render as Bevaka rows.
+ */
+export default function DashboardContent({
+ companyId,
+ userFirstName,
+ expiringBankConnections,
+ worklist,
+ suggestedMatches,
+ resumeItems,
+ onboardingProgress,
+ initialSetup,
+ agentBuilt = true,
+}: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
+ const { company } = useCompany()
- const formatLargeNumber = (amount: number) => {
- return new Intl.NumberFormat('sv-SE', {
- style: 'decimal',
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(amount)
- }
-
- // One number, one source (visibleWorklistTotal): the worklist total plus
- // expiring bank connections (dashboard-only), minus the hidden paid inbox row
- // for non-payers. Must match AttGoraSection's header off the same helper.
- const todoCount = visibleWorklistTotalFrom(
- worklist,
- hasAi,
- summary.expiringBankConnections?.length ?? 0,
- )
+ // Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock
+ // on purpose (the user's local morning, not the server's), captured once
+ // so render stays pure.
+ const [greetingNow] = useState(() => new Date())
+ const hour = greetingNow.getHours()
+ const greeting =
+ hour < 10 ? t('greeting_morning') : hour < 17 ? t('greeting_day') : t('greeting_evening')
+ const dateLine = new Intl.DateTimeFormat('sv-SE', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ }).format(greetingNow)
return (
+
+ {/* Greeting hero (concept scene 14) */}
+
+
+ {userFirstName ? `${greeting}, ${userFirstName}.` : `${greeting}.`}
+
+
+ {dateLine}
+ {company?.name ? ` · ${company.name}` : ''}
+
+
+
+
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
- of a full-screen onboarding takeover. Once the assistant is built the
- dashboard leads with the metrics + the unified "Att göra" worklist
- below; we deliberately drop a next-best-action hero here so the page
- has a single CTA surface instead of two that point at the same work. */}
+ of a full-screen onboarding takeover. */}
{!agentBuilt && (
{/* Non-payers keep seeing the hero (conversion surface) but it
@@ -120,87 +133,20 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
)}
- {/* Key metrics: 4 compact cards */}
-
-
-
-
- {t('result')}
- = 0 ? 'text-success' : 'text-destructive'
- )}>
- {formatLargeNumber(summary.mtd.net)}
- kr
-
-
- {formatCurrency(summary.ytd.net)} {t('this_year_short')}
-
-
-
-
-
-
-
-
-
- {summary.unpaidInvoicesCount}
- {t('units') && {t('units')} }
-
-
- {formatCurrency(summary.unpaidInvoicesTotal)}
-
-
-
-
-
- {summary.bankBalance !== null ? (
-
-
- {t('bank_balance')}
-
- {formatLargeNumber(summary.bankBalance)}
- kr
-
-
-
- ) : (
-
-
-
-
-
{t('bank_balance')}
-
-
- {t('connect_bank')}
-
-
-
- )}
-
-
-
- {t('todo')}
-
- {todoCount > 0 ? (
-
- {todoCount}
- {t('units') && {t('units')} }
-
- ) : (
-
- )}
-
-
-
-
-
+ {/* The two panes (concept hem-grid). When nothing is in progress the
+ right pane renders null and Att göra takes the full width. */}
+
0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
+ }
+ >
+
+
+
{/* Connect-Skatteverket nudge for existing companies. Gated on
agentBuilt so it never stacks under the build-assistant hero:
@@ -211,64 +157,6 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
connected={!!onboardingProgress?.hasSkatteverketConnected}
/>
)}
-
- {/* Att göra: the unified worklist. One section, every actionable item,
- same counts as the sidebar badges (lib/worklist). */}
-
-
- {/* Result: revenue / expenses (always visible) */}
-
-
-
-
- {t('revenue')}
-
- {formatLargeNumber(summary.mtd.income)}
- kr
-
- {t('this_month')}
-
-
{t('this_year_block')}
-
{formatCurrency(summary.ytd.income)}
-
-
-
-
-
-
- {t('expenses')}
-
- {formatLargeNumber(summary.mtd.expenses)}
- kr
-
- {t('this_month')}
-
-
{t('this_year_block')}
-
{formatCurrency(summary.ytd.expenses)}
-
-
-
-
-
-
- {/* Upcoming deadlines */}
- {summary.deadlines && summary.deadlines.length > 0 && (
-
- )}
-
- {/* Tax todo */}
- {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
-
- )}
)
}
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index c9cbcd66..f8006791 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -722,12 +722,20 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{/* Nav items in their own scroll container so the user block
below stays sticky (concept PR 2). */}
-
- {collapsed ? (
- /* The rail/full swap remounts, so each side slides+fades in
- while the aside width animates: one smooth movement. */
+
+ {/* Both states stay mounted and crossfade past each other while
+ the aside width animates: no DOM swap, one continuous motion.
+ The inactive layer is absolute (no layout), faded, nudged
+ sideways and inert. */}
{railItems.map((item) => renderRailItem(item))}
@@ -757,9 +765,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
})}
- ) : (
{/* Top section: flat, no header. Hem, Assistent. */}
@@ -838,7 +852,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
))}
- )}
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
diff --git a/components/dashboard/ResumePane.tsx b/components/dashboard/ResumePane.tsx
new file mode 100644
index 00000000..871a5c94
--- /dev/null
+++ b/components/dashboard/ResumePane.tsx
@@ -0,0 +1,128 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Badge } from '@/components/ui/badge'
+import { cn, formatCurrency } from '@/lib/utils'
+import { BookOpen, ChevronRight, FileText, HandCoins } from 'lucide-react'
+import type { ResumeItem } from '@/lib/worklist/resume'
+
+/**
+ * "Fortsätt" (concept scene 14, right pane): in-progress work you can jump
+ * back into, derived purely from draft state (lib/worklist/resume). Renders
+ * null when empty: never a blank-but-present card.
+ */
+export default function ResumePane({ items }: { items: ResumeItem[] }) {
+ const t = useTranslations('dashboard')
+
+ // Captured once: render must stay pure and the labels stable per mount.
+ const [nowMs] = useState(() => Date.now())
+
+ if (items.length === 0) return null
+
+ const relativeLabel = (iso: string): string => {
+ const days = Math.floor((nowMs - new Date(iso).getTime()) / 86_400_000)
+ if (days <= 0) return t('rel_today')
+ if (days === 1) return t('rel_yesterday')
+ return t('rel_days', { count: days })
+ }
+
+ const rowFor = (item: ResumeItem) => {
+ switch (item.kind) {
+ case 'invoice_draft':
+ return {
+ icon: FileText,
+ title: t('resume_invoice_draft', { customer: item.context ?? '' }),
+ sub: [
+ item.amount != null ? formatCurrency(item.amount, item.currency ?? 'SEK') : null,
+ t('resume_edited', { when: relativeLabel(item.updated_at) }),
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ }
+ case 'invoice_unsent':
+ return {
+ icon: FileText,
+ title: t('resume_invoice_unsent', { number: item.number ?? '' }),
+ sub: [
+ item.context,
+ item.amount != null ? formatCurrency(item.amount, item.currency ?? 'SEK') : null,
+ t('resume_edited', { when: relativeLabel(item.updated_at) }),
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ }
+ case 'salary_run': {
+ const key =
+ item.salaryStatus === 'review'
+ ? 'resume_salary_review'
+ : item.salaryStatus === 'approved'
+ ? 'resume_salary_approved'
+ : item.salaryStatus === 'paid'
+ ? 'resume_salary_paid'
+ : 'resume_salary_draft'
+ return {
+ icon: HandCoins,
+ title: t(key, { period: item.context ?? '' }),
+ sub: t('resume_edited', { when: relativeLabel(item.updated_at) }),
+ }
+ }
+ case 'journal_draft':
+ default:
+ return {
+ icon: BookOpen,
+ title: t('resume_journal_draft'),
+ sub: [item.context, t('resume_edited', { when: relativeLabel(item.updated_at) })]
+ .filter(Boolean)
+ .join(' · '),
+ }
+ }
+ }
+
+ return (
+
+ {/* Pane header: Geist title over a hairline */}
+
+
{t('resume_title')}
+
+
+ {items.map((item) => {
+ const row = rowFor(item)
+ const Icon = row.icon
+ return (
+
+
+
+
+
+ {row.title}
+ {row.sub && (
+
+ {row.sub}
+
+ )}
+
+
+ {item.late && (
+
+ {t('resume_late')}
+
+ )}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/components/reports/RecentReportsShelf.tsx b/components/reports/RecentReportsShelf.tsx
index b8ff5f7b..a30026e8 100644
--- a/components/reports/RecentReportsShelf.tsx
+++ b/components/reports/RecentReportsShelf.tsx
@@ -32,7 +32,7 @@ export function RecentReportsShelf({
return (
-
+
{t('recent_heading')}
diff --git a/components/reports/ReportLibrary.tsx b/components/reports/ReportLibrary.tsx
index 53747fd9..1d859e0c 100644
--- a/components/reports/ReportLibrary.tsx
+++ b/components/reports/ReportLibrary.tsx
@@ -35,7 +35,7 @@ export function ReportLibrary({
{sections.map((section) => (
-
+
{t(section.labelKey)}
diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx
index 153d8b5d..d5a402e8 100644
--- a/components/transactions/SkattekontoInboxCard.tsx
+++ b/components/transactions/SkattekontoInboxCard.tsx
@@ -69,7 +69,7 @@ export default function SkattekontoInboxCard({
diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx
index 324e64f2..9828c0c9 100644
--- a/components/transactions/TransactionHistoryList.tsx
+++ b/components/transactions/TransactionHistoryList.tsx
@@ -330,7 +330,7 @@ function BankHistoryRow({
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx
index d014d52b..fec29da7 100644
--- a/components/transactions/TransactionInboxCard.tsx
+++ b/components/transactions/TransactionInboxCard.tsx
@@ -302,7 +302,7 @@ export default function TransactionInboxCard({
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 7286e717..820d5675 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
): ResumeItem {
+ return {
+ kind: 'journal_draft',
+ ref: `journal:${Math.abs(overrides.updated_at?.length ?? 1)}`,
+ href: '/bookkeeping/x',
+ context: null,
+ updated_at: '2026-07-01T10:00:00Z',
+ ...overrides,
+ }
+}
+
+describe('mergeResumeItems', () => {
+ it('orders by updated_at desc', () => {
+ const merged = mergeResumeItems([
+ item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
+ item({ ref: 'b', updated_at: '2026-07-03T10:00:00Z' }),
+ item({ ref: 'c', updated_at: '2026-07-02T10:00:00Z' }),
+ ])
+ expect(merged.map((i) => i.ref)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('boosts late items above newer non-late items', () => {
+ const merged = mergeResumeItems([
+ item({ ref: 'fresh', updated_at: '2026-07-20T10:00:00Z' }),
+ item({ ref: 'late-old', late: true, updated_at: '2026-05-01T10:00:00Z' }),
+ ])
+ expect(merged[0].ref).toBe('late-old')
+ })
+
+ it('caps at RESUME_MAX_ROWS', () => {
+ const many = Array.from({ length: 7 }, (_, i) =>
+ item({ ref: `r${i}`, updated_at: `2026-07-0${i + 1}T10:00:00Z` }),
+ )
+ expect(mergeResumeItems(many)).toHaveLength(RESUME_MAX_ROWS)
+ })
+
+ it('keeps late-first ordering stable within groups', () => {
+ const merged = mergeResumeItems([
+ item({ ref: 'late-new', late: true, updated_at: '2026-07-10T10:00:00Z' }),
+ item({ ref: 'late-old', late: true, updated_at: '2026-06-10T10:00:00Z' }),
+ item({ ref: 'plain', updated_at: '2026-07-22T10:00:00Z' }),
+ ])
+ expect(merged.map((i) => i.ref)).toEqual(['late-new', 'late-old', 'plain'])
+ })
+
+ it('does not mutate the input array', () => {
+ const input = [
+ item({ ref: 'a', updated_at: '2026-07-01T10:00:00Z' }),
+ item({ ref: 'b', updated_at: '2026-07-02T10:00:00Z' }),
+ ]
+ const before = [...input]
+ mergeResumeItems(input)
+ expect(input).toEqual(before)
+ })
+})
+
+describe('isSalaryRunLate', () => {
+ const now = new Date('2026-07-23T12:00:00Z')
+
+ it('flags a run two months past its period', () => {
+ expect(isSalaryRunLate(2026, 5, now)).toBe(true)
+ })
+
+ it('does not flag last month', () => {
+ expect(isSalaryRunLate(2026, 6, now)).toBe(false)
+ })
+
+ it('does not flag the current period', () => {
+ expect(isSalaryRunLate(2026, 7, now)).toBe(false)
+ })
+
+ it('handles year boundaries', () => {
+ expect(isSalaryRunLate(2025, 12, now)).toBe(true)
+ })
+})
diff --git a/lib/worklist/resume.ts b/lib/worklist/resume.ts
new file mode 100644
index 00000000..0c6a4121
--- /dev/null
+++ b/lib/worklist/resume.ts
@@ -0,0 +1,164 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+/**
+ * Resume list ("Fortsätt") for the homepage: in-progress work derived purely
+ * from draft/mid-lifecycle state (dev_docs/last_session_resume.md §6).
+ * No event log, no presence table: a completed flow can never render here
+ * because only draft-state rows are ever fetched (the reliability invariant).
+ *
+ * Lives in lib/worklist (owns pending-work predicates) but is deliberately
+ * NOT part of WorklistCounts.total: Att göra = obligations the system
+ * imposes; Fortsätt = work the user started and left. An item renders in
+ * exactly one surface.
+ */
+
+export type ResumeItemKind =
+ | 'journal_draft'
+ | 'invoice_draft'
+ | 'invoice_unsent'
+ | 'salary_run'
+
+export type ResumeSalaryStatus = 'draft' | 'review' | 'approved' | 'paid'
+
+export interface ResumeItem {
+ kind: ResumeItemKind
+ /** Stable reference, e.g. 'invoice:'. */
+ ref: string
+ href: string
+ /** Free-text context: verifikat description, customer name, or period. */
+ context: string | null
+ /** Invoice number (unsent invoices). */
+ number?: string | null
+ amount?: number | null
+ currency?: string | null
+ salaryStatus?: ResumeSalaryStatus
+ /** Deadline boost: unsent invoices and stale salary runs outrank newer
+ * trivial drafts (Iqbal & Horvitz: deadline-tied tasks matter more). */
+ late?: boolean
+ updated_at: string
+}
+
+export const RESUME_MAX_ROWS = 3
+
+/** A salary run whose period lies >= this many months back is "late". */
+const SALARY_LATE_MONTHS = 2
+
+export function isSalaryRunLate(
+ periodYear: number,
+ periodMonth: number,
+ now: Date,
+): boolean {
+ const monthsBehind =
+ (now.getFullYear() - periodYear) * 12 + (now.getMonth() + 1 - periodMonth)
+ return monthsBehind >= SALARY_LATE_MONTHS
+}
+
+/**
+ * Pure ordering + cap: late items first (deadline boost), then most recently
+ * touched. Deterministic and explainable: no scoring, no ML.
+ */
+export function mergeResumeItems(items: ResumeItem[]): ResumeItem[] {
+ return [...items]
+ .sort((a, b) => {
+ if (Boolean(a.late) !== Boolean(b.late)) return a.late ? -1 : 1
+ return b.updated_at.localeCompare(a.updated_at)
+ })
+ .slice(0, RESUME_MAX_ROWS)
+}
+
+/**
+ * Fetch resume candidates for a company. Every query soft-fails to empty:
+ * the worst case is a missing pane, never a broken homepage.
+ */
+export async function listResumeItems(
+ supabase: SupabaseClient,
+ companyId: string,
+ now: Date = new Date(),
+): Promise {
+ const [journalRes, invoiceRes, salaryRes] = await Promise.all([
+ supabase
+ .from('journal_entries')
+ .select('id, description, updated_at')
+ .eq('company_id', companyId)
+ .eq('status', 'draft')
+ .order('updated_at', { ascending: false })
+ .limit(RESUME_MAX_ROWS + 1)
+ .then((r) => r, () => ({ data: null })),
+ supabase
+ .from('invoices')
+ .select('id, invoice_number, total, currency, updated_at, customer:customers(name)')
+ .eq('company_id', companyId)
+ .eq('status', 'draft')
+ .order('updated_at', { ascending: false })
+ .limit(RESUME_MAX_ROWS + 1)
+ .then((r) => r, () => ({ data: null })),
+ supabase
+ .from('salary_runs')
+ .select('id, period_year, period_month, status, updated_at')
+ .eq('company_id', companyId)
+ // Enumerated on purpose: `!= 'booked'` would resurface 'corrected'.
+ .in('status', ['draft', 'review', 'approved', 'paid'])
+ .order('updated_at', { ascending: false })
+ .limit(RESUME_MAX_ROWS + 1)
+ .then((r) => r, () => ({ data: null })),
+ ])
+
+ const items: ResumeItem[] = []
+
+ for (const row of (journalRes.data ?? []) as Array<{
+ id: string
+ description: string | null
+ updated_at: string
+ }>) {
+ items.push({
+ kind: 'journal_draft',
+ ref: `journal:${row.id}`,
+ href: `/bookkeeping/${row.id}`,
+ context: row.description,
+ updated_at: row.updated_at,
+ })
+ }
+
+ for (const row of (invoiceRes.data ?? []) as Array<{
+ id: string
+ invoice_number: string | null
+ total: number | null
+ currency: string | null
+ updated_at: string
+ customer: { name: string } | { name: string }[] | null
+ }>) {
+ const customer = Array.isArray(row.customer) ? row.customer[0] : row.customer
+ const unsent = !!row.invoice_number
+ items.push({
+ kind: unsent ? 'invoice_unsent' : 'invoice_draft',
+ ref: `invoice:${row.id}`,
+ href: `/invoices/${row.id}`,
+ context: customer?.name ?? null,
+ number: row.invoice_number,
+ amount: row.total,
+ currency: row.currency,
+ late: unsent,
+ updated_at: row.updated_at,
+ })
+ }
+
+ for (const row of (salaryRes.data ?? []) as Array<{
+ id: string
+ period_year: number
+ period_month: number
+ status: ResumeSalaryStatus
+ updated_at: string
+ }>) {
+ items.push({
+ kind: 'salary_run',
+ ref: `salary:${row.id}`,
+ href: `/salary/runs/${row.id}`,
+ context: `${row.period_year}-${String(row.period_month).padStart(2, '0')}`,
+ salaryStatus: row.status,
+ late: isSalaryRunLate(row.period_year, row.period_month, now),
+ updated_at: row.updated_at,
+ })
+ }
+
+ return mergeResumeItems(items)
+}
diff --git a/messages/en.json b/messages/en.json
index 4e0bdf98..d3ec280e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5115,7 +5115,23 @@
"suggested_view": "View transaction",
"suggested_confirmed_toast": "Match recorded",
"suggested_failed_toast": "Match failed",
- "row_deadlines": "VAT and tax deadlines"
+ "row_deadlines": "VAT and tax deadlines",
+ "greeting_morning": "Good morning",
+ "greeting_day": "Good day",
+ "greeting_evening": "Good evening",
+ "resume_title": "Continue",
+ "resume_invoice_draft": "Draft: invoice to {customer}",
+ "resume_invoice_unsent": "Send invoice {number}",
+ "resume_journal_draft": "Finish journal entry draft",
+ "resume_salary_draft": "Calculate payroll run {period}",
+ "resume_salary_review": "Review payroll run {period}",
+ "resume_salary_approved": "Mark payroll run {period} paid",
+ "resume_salary_paid": "Book payroll run {period}",
+ "resume_edited": "edited {when}",
+ "resume_late": "Late",
+ "rel_today": "today",
+ "rel_yesterday": "yesterday",
+ "rel_days": "{count} days ago"
},
"reports": {
"title": "Reports",
diff --git a/messages/sv.json b/messages/sv.json
index 653e5710..d21d568f 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -5115,7 +5115,23 @@
"suggested_view": "Visa transaktionen",
"suggested_confirmed_toast": "Matchning bokförd",
"suggested_failed_toast": "Matchningen misslyckades",
- "row_deadlines": "Moms- och skattedeadlines"
+ "row_deadlines": "Moms- och skattedeadlines",
+ "greeting_morning": "God morgon",
+ "greeting_day": "God dag",
+ "greeting_evening": "God kväll",
+ "resume_title": "Fortsätt",
+ "resume_invoice_draft": "Utkast: faktura till {customer}",
+ "resume_invoice_unsent": "Skicka faktura {number}",
+ "resume_journal_draft": "Slutför verifikatutkast",
+ "resume_salary_draft": "Beräkna lönekörning {period}",
+ "resume_salary_review": "Granska lönekörning {period}",
+ "resume_salary_approved": "Markera lönekörning {period} utbetald",
+ "resume_salary_paid": "Bokför lönekörning {period}",
+ "resume_edited": "ändrad {when}",
+ "resume_late": "Försenad",
+ "rel_today": "i dag",
+ "rel_yesterday": "i går",
+ "rel_days": "för {count} dagar sedan"
},
"reports": {
"title": "Rapporter",