* 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>
575 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|