Files
accounted/components/dashboard/DashboardContent.tsx
T
Jakob WennbergandClaude Opus 4.6 38b658205d feat: BFL-compliant descriptions, cancelled status, and setup gate (#58)
* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

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

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

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

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

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

* fix: OAuth callback redirect for local dev and timeout resilience

- Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth
  callbacks work on localhost (not just production)
- Encode consentId/provider in OAuth state (base64url JSON) so the
  callback doesn't depend on session storage
- Add skipAuth flag to extension API routes for OAuth callbacks
  (external provider redirects have no user session cookie)
- Wrap AbortError in descriptive timeout messages in arcim-client
- Make preview endpoint resilient to partial failures (company info
  and SIE fetch are individually non-blocking)
- Simplify login page (remove unused magic link auth mode)

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

* fix: create journal entry before marking invoice as paid

Move journal entry creation before the invoice status update so that
if accounting fails, the invoice is not permanently marked paid without
a corresponding entry. Previously the error was silently swallowed.

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

* fix: update mark-paid tests for journal-first ordering

Reorder mock queue to match new flow (settings before update), update
failure test to expect 500 instead of silent success, add try-catch
with proper error response in route handler.

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

* feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard

Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration:
- Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT
- Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type
- Include ruta 30-32 in ruta 49 formula and totalOutputVat summary
- Display reverse charge section in reports UI and composition chart
- Add comprehensive test coverage for all reverse charge scenarios

Improve mobile UX across the app:
- Convert nav drawer to bottom sheet with drag handle and safe area padding
- Add mobile card layout for PaymentBookingDialog journal lines
- Replace settings tab pills with dropdown selector on mobile
- Make wizard step indicators responsive (collapsed on mobile)
- Ensure all dialog footers stack buttons full-width on mobile
- Add 44px minimum touch targets throughout
- Make onboarding buttons full-width on mobile

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

* fix: address Greptile review — indentation, query efficiency, tab dedup

- Fix misleading try-block indentation in mark-paid route
- Filter reversed entries at DB level (.eq('status', 'posted')) instead
  of fetching then discarding in memory
- Extract shared settingsTabs array so mobile Select and desktop
  TabsList stay in sync automatically

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

* feat: add resilience fallbacks, Arcim retry logic, and client tests

Add FallbackPrompt component and integrate it across banking and migration
error states so users always have a manual import escape hatch. Add retry
with exponential backoff to Arcim API client for transient failures (429,
502, 503, 504) and timeouts. Expand import page deep-linking with ?mode=
parameter. Add persistent error banner on settings page for bank connection
failures. Include 18 new tests for the Arcim client covering retry, backoff,
pagination, timeout, env validation, and singleton resource unwrapping.

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

* fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout

- Add missing setActiveTab('banking') when handling bank_error query
  param so the error banner is actually visible (P1)
- Guard env-var cleanup with try/finally in arcim-client tests to
  prevent state leakage on assertion failure (P2)
- Only mock retry-range setTimeout delays in backoff test, letting
  AbortController timers pass through real setTimeout (P2)
- Remove redundant clearTimeout in catch block — finally handles it (P2)

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

* feat: add BFL-compliant counterparty names to journal descriptions and cancelled entry status

Journal descriptions now include customer/supplier names for traceability
(e.g. "Kundfaktura 1001, Foretag AB"). Failed draft entries are marked as
'cancelled' instead of deleted, respecting immutability constraints.
Includes DB migration for the new journal_entries status value.

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

* fix: address Greptile review — Swedish typos, missing source type, trigger and reversal cleanup

- Fix Swedish spelling: leverantor → leverantör in all supplier description prefixes
- Add supplier_credit_note to supplierSourceTypes in VAT declaration so credit
  notes correctly reduce reverse-charge bases (ruta 20–24)
- Mark orphaned concurrent reversals as cancelled instead of attempting deletion
  that the immutability trigger blocks
- Allow posted → cancelled transition in trigger for orphaned reversal cleanup
- Restrict cancelled entry line trigger to DELETE-only (block INSERT/UPDATE)

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

* fix: use main's Step3TaxRegistration (onboarding restructured in PR #54)

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

* chore: retrigger Greptile review

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

* feat: add TIC company lookup extension, extension nav items, and legacy toggle fallback

Introduces the TIC (Bolagsuppgifter) extension for automatic company data lookup
via org number during onboarding. Adds dynamic extension nav items in the sidebar,
legacy general extension fallback for toggle checks, and company lookup type
definitions in core.

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

* fix: address Greptile review — restore push-notifications, filter nav by toggles, fix timeout error name

- Restore push-notifications to LEGACY_GENERAL_EXTENSIONS (was silently
  dropped when extracting the shared constant)
- Remove tic and arcim-migration from legacy defaults (new extensions
  should not default to enabled for all users)
- Filter getExtensionNavItems() against user's enabled extensions so
  disabled extensions don't appear in the sidebar
- Fix AbortSignal.timeout() error name check — Node.js throws
  TimeoutError, not AbortError

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

* fix: add all bundled extensions to legacy defaults (email, arcim-migration, tic)

Bundled extensions configured in extensions.config.json should default
to enabled. Adds email, arcim-migration, and tic alongside the
existing legacy defaults so they are accessible without explicit
toggle rows.

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

* feat: replace welcome checklist with full-page setup gate

New users now see a full-page onboarding gate instead of a small
dismissible card. The gate enforces a clear two-step sequential flow:
(1) migrate bookkeeping from Fortnox/Bokio/Visma or import SIE file,
(2) connect bank account. The gate blocks all dashboard content until
the user has imported data or explicitly chooses "fresh start".

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

* fix: clean up journal lines after cancelling failed reversal entries

When a reversal fails at the line-insert, post, or CAS stage, the entry
is correctly marked as cancelled. Now also delete the attached lines to
prevent stale data accumulation — the migration trigger allows DELETE on
lines of cancelled entries for this purpose.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:05:39 +01:00

575 lines
24 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
Receipt,
ArrowLeftRight,
ChevronDown,
ChevronRight,
Camera,
Users,
Landmark,
CheckCircle2,
FileWarning,
Clock,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { QuickActionDefinition } from '@/lib/extensions/types'
import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
const SETUP_FRESH_START_KEY = 'erp_setup_fresh_start'
interface DashboardContentProps {
firstName?: string | null
settings: CompanySettings | null
summary: {
ytd: { income: number; expenses: number; net: number }
mtd: { income: number; expenses: number; net: number }
uncategorizedCount: number
uncategorizedIncome: number
uncategorizedExpenses: number
unpaidInvoicesCount: number
unpaidInvoicesTotal: number
unpaidVatTotal: number
overdueInvoicesCount: number
bankBalance: number | null
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
deadlines: Deadline[]
receiptQueue: ReceiptQueueSummary | null
missingUnderlagCount: number
staleUncategorizedCount: number
}
onboardingProgress?: OnboardingProgress
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
}
export default function DashboardContent({ firstName, settings, summary, onboardingProgress, enabledExtensions }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const [showMore, setShowMore] = useState(false)
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
useEffect(() => {
setLiveExtensions(enabledExtensions ?? [])
}, [enabledExtensions])
useEffect(() => {
const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => {
setLiveExtensions(prev => {
if (e.detail.enabled) {
if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev
return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }]
}
return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug))
})
}) as EventListener
window.addEventListener('extension-toggle-changed', handler)
return () => window.removeEventListener('extension-toggle-changed', handler)
}, [])
// Setup gate — blocks dashboard until user imports data or chooses fresh start
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
useEffect(() => {
if (!needsSetup) {
setSetupGateActive(false)
return
}
const freshStart = localStorage.getItem(SETUP_FRESH_START_KEY) === 'true'
const oldDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true'
if (freshStart || oldDismissed) setSetupGateActive(false)
}, [needsSetup])
if (setupGateActive) {
return (
<NewUserChecklist
onFreshStart={() => {
localStorage.setItem(SETUP_FRESH_START_KEY, 'true')
setSetupGateActive(false)
}}
/>
)
}
const formatLargeNumber = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
// Build alert items for "Att hantera" section
const alertItems: React.ReactNode[] = []
if (summary.overdueInvoicesCount > 0) {
alertItems.push(
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-destructive/30 hover:bg-destructive/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
alertItems.push(
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.uncategorizedCount > 0) {
alertItems.push(
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
alertItems.push(
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-primary/30 hover:bg-primary/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.missingUnderlagCount > 0) {
alertItems.push(
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.staleUncategorizedCount > 0) {
alertItems.push(
<Link key="stale-transactions" href="/transactions" className="group">
<Card className="h-full border-destructive/30 hover:bg-destructive/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Clock className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Gamla transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.staleUncategorizedCount} transaktioner äldre än 14 dagar saknar bokföring
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
const conn = summary.expiringBankConnections[0]
alertItems.push(
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
const MAX_VISIBLE_ALERTS = 3
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
// Build extension quick actions from enabled extensions
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions
.map(toggle => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def?.quickAction) return null
return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` }
})
.filter((a): a is QuickActionDefinition & { key: string } => a !== null)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Quick action items
const quickActions = [
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
]
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
const pendingReceiptsCount = summary.receiptQueue
? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count
: 0
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
return (
<div className="stagger-enter">
{/* Header */}
<header className="mb-12">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
</header>
{/* 4 Key Summary Cards */}
<section className="mb-10">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Card 1: Resultat */}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className={cn(
'font-display text-xl font-medium 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)} i år
</p>
</CardContent>
</Card>
{/* Card 2: Att få betalt */}
<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">Att få betalt</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</CardContent>
</Card>
</Link>
{/* Card 3: Banksaldo */}
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="font-display text-xl font-medium 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">Banksaldo</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-xs text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
</Card>
</Link>
)}
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
<div role="status" aria-live="polite">
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Behöver åtgärdas
</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">Allt klart!</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* Quick actions */}
<section id="quick-actions" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Snabbåtgärder</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link key={action.href} href={action.href} className="group">
<div className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150 active:scale-[0.98]',
action.accent
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
)}>
<div className="min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className={cn(
'h-3.5 w-3.5 flex-shrink-0',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.desc}</p>
</div>
</div>
</Link>
)
})}
{/* Extension quick actions */}
{extensionQuickActions.map((action) => {
const Icon = resolveIcon(action.icon)
if (action.href) {
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 active:scale-[0.98] transition-colors duration-150">
<div className="min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.description}</p>
</div>
</div>
</Link>
)
}
return (
<button
key={action.key}
onClick={() => window.dispatchEvent(new Event(action.event!))}
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 active:scale-[0.98] transition-colors duration-150">
<div className="min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.description}</p>
</div>
</div>
</button>
)
})}
</div>
</section>
{/* Alerts section */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<div id="alerts-list" className="grid gap-3 md:grid-cols-2">
{visibleAlerts}
</div>
{hasMoreAlerts && (
<button
onClick={() => setShowAllAlerts(!showAllAlerts)}
aria-expanded={showAllAlerts}
aria-controls="alerts-list"
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{showAllAlerts ? 'Visa färre' : `Visa alla (${alertItems.length})`}
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
</button>
)}
</section>
)}
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-8">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Collapsible details section */}
<button
onClick={() => setShowMore(!showMore)}
aria-expanded={showMore}
aria-controls="details-section"
className="mb-6 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
>
{showMore ? 'Dölj detaljer' : 'Visa detaljer'}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showMore && 'rotate-180')} />
</button>
{showMore && (
<div id="details-section">
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-10">
<Link href="/transactions?tab=uncategorized" className="group">
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} obokförda transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
)}
{' '}saknas i resultatet
</p>
</div>
</div>
</Link>
</section>
)}
{/* Income/Expenses */}
<section className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
<div>
<p className="font-display text-2xl font-medium 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">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-5">
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<div>
<p className="font-display text-2xl font-medium 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">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
</div>
)}
</div>
)
}