feat(home): concept Hem with Att göra + Fortsätt (UI migration PR 11) (#1132)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-23 22:36:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4352862845
commit be9d630347
23 changed files with 642 additions and 347 deletions
+1 -1
View File
@@ -1105,7 +1105,7 @@ export default function JournalEntryList() {
)}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums sensitive-field')}>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
+24 -29
View File
@@ -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 (
<Link
href={href}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors duration-150"
className="group flex w-full items-start gap-3 border-b border-border px-1 py-3.5 transition-colors duration-150 hover:bg-secondary/30"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{label}</p>
{detail && <p className="text-xs text-muted-foreground mt-0.5 truncate">{detail}</p>}
<span className="mt-px w-[18px] shrink-0 text-muted-foreground" aria-hidden>
<Icon className="h-[15px] w-[15px]" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px]">{label}</p>
{detail && <p className="mt-0.5 truncate text-xs text-muted-foreground">{detail}</p>}
</div>
{badge}
<span className="font-display text-base tabular-nums shrink-0">{count}</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
<span className="ml-auto flex shrink-0 items-center gap-2.5 pt-px">
{badge}
<Badge variant="secondary" className="font-normal tabular-nums">
{count}
</Badge>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
</span>
</Link>
)
}
function BandHeader({ children }: { children: React.ReactNode }) {
return (
<p className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<p className="px-1 pt-5 pb-1 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
{children}
</p>
)
@@ -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 (
<section aria-label={t('att_gora_title')}>
<div className="flex items-baseline justify-between mb-4">
<h2 className="font-display text-lg">{t('att_gora_title')}</h2>
<p className="text-sm text-muted-foreground tabular-nums" role="status" aria-live="polite">
{/* Pane header: Geist title + quiet count over a hairline */}
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
<h2 className="font-sans text-sm font-medium">{t('att_gora_title')}</h2>
<p className="text-xs text-muted-foreground tabular-nums" role="status" aria-live="polite">
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
</p>
</div>
<Card>
<CardContent className="p-0">
<div>
{allClear ? (
<EmptyState
icon={CheckCircle2}
@@ -219,20 +222,13 @@ export default function AttGoraSection({
{bokforRows && (
<div>
<BandHeader>{t('band_bokfor')}</BandHeader>
<div className="divide-y divide-border">
<div>
{counts.book_transaction > 0 && (
<WorklistRow
href="/transactions"
icon={ArrowLeftRight}
label={t('row_book_transactions')}
count={counts.book_transaction}
badge={
staleUncategorizedCount > 0 ? (
<Badge variant="warning" className="shrink-0">
{t('row_book_transactions_stale', { count: staleUncategorizedCount })}
</Badge>
) : undefined
}
/>
)}
{matches.length > 0 && (
@@ -319,7 +315,7 @@ export default function AttGoraSection({
{granskaRows && (
<div>
<BandHeader>{t('band_granska')}</BandHeader>
<div className="divide-y divide-border">
<div>
{counts.supplier_invoice_approval > 0 && (
<WorklistRow
href="/supplier-invoices"
@@ -351,7 +347,7 @@ export default function AttGoraSection({
{bevakaRows && (
<div>
<BandHeader>{t('band_bevaka')}</BandHeader>
<div className="divide-y divide-border">
<div>
{counts.overdue_invoice > 0 && (
<WorklistRow
href="/invoices?status=unpaid"
@@ -392,8 +388,7 @@ export default function AttGoraSection({
)}
</div>
)}
</CardContent>
</Card>
</div>
</section>
)
}
+72 -184
View File
@@ -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 (
<div className="stagger-enter space-y-8">
<BackupHealthBanner />
{/* Greeting hero (concept scene 14) */}
<section>
<h1 className="font-display text-2xl leading-8 tracking-tight">
{userFirstName ? `${greeting}, ${userFirstName}.` : `${greeting}.`}
</h1>
<p className="mt-1.5 text-[13px] text-muted-foreground">
{dateLine}
{company?.name ? ` · ${company.name}` : ''}
</p>
</section>
<NewUserChecklist
initialState={initialSetup}
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
@@ -83,12 +98,10 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
hasAgentBuilt={agentBuilt}
/>
{/* 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 && (
<section>
{/* Non-payers keep seeing the hero (conversion surface) but it
@@ -120,87 +133,20 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
</section>
)}
{/* Key metrics: 4 compact cards */}
<section>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
<p className={cn(
'font-display text-xl tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
)}>
{formatLargeNumber(summary.mtd.net)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} {t('this_year_short')}
</p>
</CardContent>
</Card>
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="font-display text-xl tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</CardContent>
</Card>
</Link>
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<p className="font-display text-xl tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
</CardContent>
</Card>
) : (
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="text-sm font-medium text-primary">{t('connect_bank')}</p>
</CardContent>
</Card>
</Link>
)}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
<div role="status" aria-live="polite">
{todoCount > 0 ? (
<p className="font-display text-xl tabular-nums leading-tight">
{todoCount}
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
) : (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">{t('all_done')}</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* The two panes (concept hem-grid). When nothing is in progress the
right pane renders null and Att göra takes the full width. */}
<div
className={
resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
}
>
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches}
expiringBankConnections={expiringBankConnections}
/>
<ResumePane items={resumeItems} />
</div>
{/* 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). */}
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches}
expiringBankConnections={summary.expiringBankConnections}
staleUncategorizedCount={summary.staleUncategorizedCount}
/>
{/* Result: revenue / expenses (always visible) */}
<section>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
<p className="font-display text-2xl tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
<p className="font-display text-2xl tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</CardContent>
</Card>
</div>
</section>
{/* Upcoming deadlines */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section>
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section>
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
</div>
)
}
+21 -8
View File
@@ -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). */}
<div className="flex-1 min-h-0 overflow-y-auto pt-1 pb-2">
{collapsed ? (
/* The rail/full swap remounts, so each side slides+fades in
while the aside width animates: one smooth movement. */
<div className="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden pt-1 pb-2">
{/* 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. */}
<nav
className="flex flex-col items-center gap-px px-2 animate-in fade-in slide-in-from-right-2 duration-200"
aria-hidden={!collapsed}
inert={!collapsed ? true : undefined}
className={cn(
'flex w-16 flex-col items-center gap-px px-2 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
collapsed
? 'opacity-100 translate-x-0'
: 'pointer-events-none absolute inset-x-0 top-1 opacity-0 -translate-x-3',
)}
aria-label={tNav('main_navigation')}
>
{railItems.map((item) => renderRailItem(item))}
@@ -757,9 +765,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
})}
</nav>
) : (
<nav
className="px-3 animate-in fade-in slide-in-from-left-2 duration-200"
aria-hidden={collapsed}
inert={collapsed ? true : undefined}
className={cn(
'w-[248px] px-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
collapsed
? 'pointer-events-none absolute inset-x-0 top-1 opacity-0 translate-x-3'
: 'opacity-100 translate-x-0',
)}
aria-label={tNav('main_navigation')}
>
{/* Top section: flat, no header. Hem, Assistent. */}
@@ -838,7 +852,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
))}
</nav>
)}
</div>
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
+128
View File
@@ -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 (
<section aria-label={t('resume_title')}>
{/* Pane header: Geist title over a hairline */}
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
<h2 className="font-sans text-sm font-medium">{t('resume_title')}</h2>
</div>
<div>
{items.map((item) => {
const row = rowFor(item)
const Icon = row.icon
return (
<Link
key={item.ref}
href={item.href}
className="group flex w-full items-start gap-3 border-b border-border px-1 py-3.5 transition-colors duration-150 hover:bg-secondary/30"
>
<span className="mt-px w-[18px] shrink-0 text-muted-foreground" aria-hidden>
<Icon className="h-[15px] w-[15px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px]">{row.title}</span>
{row.sub && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{row.sub}
</span>
)}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2.5 pt-px">
{item.late && (
<Badge variant="warning" className="font-normal">
{t('resume_late')}
</Badge>
)}
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100',
)}
/>
</span>
</Link>
)
})}
</div>
</section>
)
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function RecentReportsShelf({
return (
<div className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
<h2 className="font-sans text-sm font-medium">
{t('recent_heading')}
</h2>
<div className="flex flex-wrap gap-2">
+1 -1
View File
@@ -35,7 +35,7 @@ export function ReportLibrary({
<div className="space-y-8">
{sections.map((section) => (
<div key={section.category} className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
<h2 className="font-sans text-sm font-medium">
{t(section.labelKey)}
</h2>
<DataList>
@@ -69,7 +69,7 @@ export default function SkattekontoInboxCard({
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
@@ -330,7 +330,7 @@ function BankHistoryRow({
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
title={
@@ -469,7 +469,7 @@ function SkattekontoHistoryRow({
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
@@ -302,7 +302,7 @@ export default function TransactionInboxCard({
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums sensitive-field',
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
+1 -1
View File
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className
)}
{...props}
+2 -2
View File
@@ -32,8 +32,8 @@ const SlideOverContent = React.forwardRef<
className={cn(
'fixed top-[18px] right-[18px] bottom-[18px] z-50 flex w-[480px] max-w-[calc(100vw-36px)] flex-col',
'rounded-xl border border-border bg-background shadow-[var(--shadow-lg)]',
'duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-right-8 data-[state=open]:fade-in-0',
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-8 data-[state=closed]:fade-out-0',
'data-[state=open]:animate-in data-[state=open]:slide-in-from-right-full data-[state=open]:fade-in-0 data-[state=open]:duration-300 data-[state=open]:ease-[cubic-bezier(0.32,0.72,0,1)]',
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-full data-[state=closed]:fade-out-0 data-[state=closed]:duration-200 data-[state=closed]:ease-in',
className,
)}
{...props}