From be9d630347f9047acca5637ae47348883f4c469c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:36:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(home):=20concept=20Hem=20with=20Att=20g?= =?UTF-8?q?=C3=B6ra=20+=20Forts=C3=A4tt=20(UI=20migration=20PR=2011)=20(#1?= =?UTF-8?q?132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(home): concept scene 14: greeting + Att gora/Fortsatt panes Hem becomes the founder-approved two-panel layout: serif time-of-day greeting with date and company, the Att gora worklist restyled to the concept pane (eyebrow header, h-rows with count chips, hover chevrons) and a new Fortsatt pane listing in-progress work derived purely from draft state (lib/worklist/resume: journal drafts, invoice drafts/unsent, mid-lifecycle salary runs; deadline boost, cap 3, tested). A completed flow can never render as a resume row by construction: only draft-state rows are fetched. KPI tiles, revenue/expense cards and the deadline/tax widgets leave the page per dev_docs/last_session_resume.md section 8, which also prunes their fetches (journal-line YTD aggregation, unpaid totals, deadlines): the page got faster. Banners, checklist, build-assistant hero and the Skatteverket nudge survive. Co-Authored-By: Claude Fable 5 * fix(home): serif pane titles for Att gora and Fortsatt Founder feedback: the uppercase eyebrow headers read as a stray font. Both pane titles are now the Hedvig display serif (text-lg) over the hairline, matching the page's heading language; the band headers inside Att gora keep their small uppercase form as grouping devices. Co-Authored-By: Claude Fable 5 * fix(home): Geist pane titles for Att gora and Fortsatt Founder call: the pane titles use the body sans (14px medium), not the display serif. Co-Authored-By: Claude Fable 5 * fix(ui): Geist section headers + drop stale-transactions chip Founder feedback: pane/section headers (Att gora, Fortsatt, the reports groups Lopande/Bokslut/Skatt & moms etc) render in Geist sentence case instead of uppercase eyebrows or serif. The global h1-h3 display-font rule moves into @layer base so utility classes like font-sans can actually override it (unlayered element rules beat Tailwind's layered utilities: this was silently eating the override). Also removes the 'N aldre an 14 dagar' chip from the Bokfora transaktioner row and its stale-count plumbing. Co-Authored-By: Claude Fable 5 * feat(ui): continuous nav crossfade + floating slide-over entrance The rail/full nav states now stay mounted and crossfade past each other (the inactive layer absolute, faded, nudged sideways, inert) while the aside width animates: the switch reads as one continuous motion instead of a DOM swap. The detail slide-over floats in from the right edge (slide-in-from-right-full, 300ms decelerating curve) per the concept, with a quicker ease-in exit. Co-Authored-By: Claude Fable 5 * fix(ui): make transitions and enter/exit animations actually run Two silent app-wide animation killers found while chasing 'the nav still is not smooth': 1. The codebase uses the shadcn animate-in/out vocabulary everywhere but no animate plugin was ever installed: Tailwind v4 silently dropped every such class, so popovers, dialogs, menus and the slide-over all appeared instantly. globals.css now defines the exact subset in use (accEnter/accExit keyframes + var-driven utilities), plugin-free, composing with duration/ease via --tw-duration/--tw-ease and collapsing under prefers-reduced-motion. Dialog drops its bracket-variant slide classes (zoom+fade carries the entrance). 2. The scrollbar auto-hide block's universal '* { transition: scrollbar-color ... }' was unlayered, and unlayered rules beat Tailwind's layered transition-* utilities regardless of specificity: every width/margin/color transition in the app was dead. The rule now lives in @layer base. Verified: the aside animates 248->64 over 300ms and the slide-over runs accEnter at 0.3s with the decelerating curve. Co-Authored-By: Claude Fable 5 * fix(ui): finish the rr-mask session-replay masking sweep Main's #1105 switched one amount cell from the no-op sensitive-field class to rr-mask (rrweb's built-in text-masking class). The reskinned tables introduced more sensitive-field cells; all 12 occurrences now use rr-mask so financial amounts are masked in session replays. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/assets/page.tsx | 2 +- app/(dashboard)/invoices/page.tsx | 2 +- app/(dashboard)/page.tsx | 118 ++------ app/(dashboard)/salary/employees/page.tsx | 2 +- app/(dashboard)/salary/page.tsx | 4 +- app/(dashboard)/supplier-invoices/page.tsx | 4 +- app/globals.css | 88 +++++- components/bookkeeping/JournalEntryList.tsx | 2 +- components/dashboard/AttGoraSection.tsx | 53 ++-- components/dashboard/DashboardContent.tsx | 256 +++++------------- components/dashboard/DashboardNav.tsx | 29 +- components/dashboard/ResumePane.tsx | 128 +++++++++ components/reports/RecentReportsShelf.tsx | 2 +- components/reports/ReportLibrary.tsx | 2 +- .../transactions/SkattekontoInboxCard.tsx | 2 +- .../transactions/TransactionHistoryList.tsx | 4 +- .../transactions/TransactionInboxCard.tsx | 2 +- components/ui/dialog.tsx | 2 +- components/ui/slide-over.tsx | 4 +- lib/worklist/__tests__/resume.test.ts | 83 ++++++ lib/worklist/resume.ts | 164 +++++++++++ messages/en.json | 18 +- messages/sv.json | 18 +- 23 files changed, 642 insertions(+), 347 deletions(-) create mode 100644 components/dashboard/ResumePane.tsx create mode 100644 lib/worklist/__tests__/resume.test.ts create mode 100644 lib/worklist/resume.ts 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')} -

-
-
- - - - -
-

{t('to_be_paid')}

- -
-

- {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')}} -

- ) : ( -
- -

{t('all_done')}

-
- )} -
-
-
-
-
+ {/* 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. */} - ) : (
))} - )}
{/* 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",