refactor(ui): app-wide UI/UX consistency pass (#436)

* refactor(ui): app-wide UI/UX consistency pass

Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.

What changed:

- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
  (Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
  (p-4 for compact metric cards), space-y-8 between page sections.

- **Tables unified**: all 33 thead blocks now share the Resultatrapport
  pattern via shadcn Table primitive (text-[11px] font-medium uppercase
  tracking-wider text-muted-foreground). Hand-rolled <table> instances
  converted where they were data tables; form/edit grids kept distinct.

- **Status badges unified**: every status indicator routes through
  shadcn <Badge variant>. Eliminated raw Tailwind colors
  (bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
  in favor of the gnubok semantic palette (success=sage, warning=ochre,
  destructive=terracotta).

- **Empty states unified**: list pages migrated from hand-rolled
  "flex flex-col items-center py-12" divs to the EmptyState primitive.

- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
  divs replaced with shadcn <Skeleton> across 15 files.

- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
  from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
  9 icon-only navigation buttons.

- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
  table-friendly) vs formatDateLong() for metadata (Swedish long form).
  Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.

- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
  now carries the action ("Kunde inte skapa lönekörning" etc.) with
  description carrying the error detail.

- **Page-level cleanups**:
  - Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
    duplicates + Visa detaljer collapsible.
  - Reports: 5-col mega-menu replaced with left-rail layout
    (new ReportsNav component).
  - Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
    tabs (moved FiscalYearSelector inside journal tab).
  - Bookkeeping: added voucher sort (A1 first / latest first) alongside
    existing date sort. Required matching API param sort_by.
  - KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
    instead of inline info-button toggle; bigger numbers.
  - Salary section: enum values translated to Swedish labels, mobile
    table collapses to Anställd+Netto on <md, KPI typography aligned
    with dashboard.
  - Invoice forms: styled RequiredMark + aria-required, tabular-nums
    on amount inputs.

- **CLAUDE.md**: new "Design System Tokens" subsection documents the
  locked spacing scale, primitives table, typography rules, date helpers,
  and forbidden patterns so future contributors don't drift.

Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.

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

* fix: address PR review feedback (Greptile + compliance bot)

- **formatDate / formatDateLong timezone fix**: switch from new Date() to
  parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
  than UTC midnight, eliminating the off-by-one display in west-of-UTC
  timezones flagged by Greptile.

- **DashboardContentProps cleanup**: removed unused firstName and settings
  fields from the interface, and the corresponding fetch (profiles table)
  + computation in app/(dashboard)/page.tsx. The greeting was dropped in
  the dashboard cleanup; these props were dead weight.

- **Voucher sort behavior documented**: extended the comment in the journal
  entries API route to explain why voucher sort intentionally uses strict
  fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
  series-scoped within a fiscal year). The row-count delta between date
  sort and voucher sort is now a documented design choice.

- **delete_last_voucher migration + draft-delete test included**: the UI
  already shipped the "Radera utkast" path in the previous commit; this
  pulls in the backing RPC migration that allows draft deletes (with the
  full safety logic — drafts skip series/period checks since they have
  voucher_number=0, posted entries go through the existing unchanged
  path). This was originally meant for a separate PR but the UI shipped
  half the feature without it.

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

* chore(migration): rename to match applied version

The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.

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

* fix: address compliance bot findings (payroll label + VAT visibility)

- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
  6 §, karensavdrag is a single calculated amount (20% of one week's
  sjuklön) deducted from the first sick day's pay — not bounded to the
  first day. The qualifier could mislead users when the first sick day
  and return-to-work span a weekend. Swedish-payroll bot recommendation.

- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
  charge indicator is compliance-critical (ML 16 kap) — missing it leads
  to incorrect input VAT deduction. Outline was too subtle; warning's
  ochre fill matches its semantic weight.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-11 16:34:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ed9bdc4c00
commit d0fbc2b616
80 changed files with 1491 additions and 1391 deletions
@@ -46,7 +46,7 @@ export default function BookingTemplatePicker({ onApply, entityType }: Props) {
setTemplates(data || [])
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
+2 -2
View File
@@ -134,8 +134,8 @@ export default function ChartOfAccounts() {
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
@@ -145,7 +145,7 @@ export default function ChartOfAccountsManager() {
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte uppdatera kontot', variant: 'destructive' })
toast({ title: 'Kunde inte uppdatera kontot', variant: 'destructive' })
} finally {
setTogglingAccount(null)
}
@@ -167,8 +167,7 @@ export default function ChartOfAccountsManager() {
await refreshAll()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
title: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
variant: 'destructive',
})
} finally {
@@ -191,7 +190,7 @@ export default function ChartOfAccountsManager() {
}
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte aktivera kontot', variant: 'destructive' })
toast({ title: 'Kunde inte aktivera kontot', variant: 'destructive' })
} finally {
setActivatingAccounts((prev) => {
const next = new Set(prev)
@@ -367,8 +366,8 @@ export default function ChartOfAccountsManager() {
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
@@ -497,8 +496,8 @@ export default function ChartOfAccountsManager() {
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
+2 -1
View File
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { Info } from 'lucide-react'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import { formatDate } from '@/lib/utils'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
@@ -71,7 +72,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
<span className="font-mono text-sm">
{entry.voucher_series}{entry.voucher_number}
</span>
<span className="text-sm text-muted-foreground">{entry.entry_date}</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatDate(entry.entry_date)}</span>
<JournalEntryStatusBadge entry={entry} showStatus={false} />
{isCurrent && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
@@ -17,6 +17,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
interface CorrectionLine {
@@ -132,7 +133,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Fel',
title: 'Kunde inte spara ändringsverifikation',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -165,15 +166,15 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{entry.voucher_series}{entry.voucher_number}</span>
<span>{entry.entry_date}</span>
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
+2 -2
View File
@@ -633,8 +633,8 @@ export default function JournalEntryForm({
{/* Entry lines — desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
+26 -35
View File
@@ -7,8 +7,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
@@ -40,7 +42,7 @@ export default function JournalEntryList({ periodId }: Props) {
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [dateSortDir, setDateSortDir] = useState<'desc' | 'asc'>('desc')
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dateFromInput, setDateFromInput] = useState('')
@@ -106,7 +108,7 @@ export default function JournalEntryList({ periodId }: Props) {
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
sort_date: dateSortDir,
sort_by: sortBy,
})
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
@@ -130,7 +132,7 @@ export default function JournalEntryList({ periodId }: Props) {
useEffect(() => {
fetchEntries()
}, [periodId, page, dateSortDir, dateFrom, dateTo])
}, [periodId, page, sortBy, dateFrom, dateTo])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
@@ -196,32 +198,18 @@ export default function JournalEntryList({ periodId }: Props) {
</Badge>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 sm:hidden"
onClick={() => { setDateSortDir(dateSortDir === 'desc' ? 'asc' : 'desc'); setPage(0) }}
>
{dateSortDir === 'desc' ? (
<ArrowDownNarrowWide className="h-4 w-4" />
) : (
<ArrowUpNarrowWide className="h-4 w-4" />
)}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 hidden sm:inline-flex"
onClick={() => { setDateSortDir(dateSortDir === 'desc' ? 'asc' : 'desc'); setPage(0) }}
>
{dateSortDir === 'desc' ? (
<ArrowDownNarrowWide className="h-4 w-4" />
) : (
<ArrowUpNarrowWide className="h-4 w-4" />
)}
<span className="text-xs">Datum {dateSortDir === 'desc' ? 'nyast först' : 'äldst först'}</span>
</Button>
<Select value={sortBy} onValueChange={(v) => { setSortBy(v as typeof sortBy); setPage(0) }}>
<SelectTrigger className="h-8 w-auto gap-1.5 text-xs sm:w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum, nyast först</SelectItem>
<SelectItem value="date_asc">Datum, äldst först</SelectItem>
<SelectItem value="voucher_asc">Verifikat, A1 först</SelectItem>
<SelectItem value="voucher_desc">Verifikat, senaste först</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="text"
@@ -308,8 +296,8 @@ export default function JournalEntryList({ periodId }: Props) {
>
{entry.voucher_series}{entry.voucher_number}
</Link>
<span className="text-sm text-muted-foreground w-24">
{entry.entry_date}
<span className="text-sm text-muted-foreground tabular-nums w-24">
{formatDate(entry.entry_date)}
</span>
{entry.out_of_period && (
<Badge
@@ -320,8 +308,8 @@ export default function JournalEntryList({ periodId }: Props) {
Efterföljande
</Badge>
)}
{(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed'} />
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
{attachmentCounts[entry.id] ? (
@@ -352,8 +340,8 @@ export default function JournalEntryList({ periodId }: Props) {
>
{entry.voucher_series}{entry.voucher_number}
</Link>
<span className="text-sm text-muted-foreground">
{entry.entry_date}
<span className="text-sm text-muted-foreground tabular-nums">
{formatDate(entry.entry_date)}
</span>
{entry.out_of_period && (
<Badge
@@ -364,6 +352,9 @@ export default function JournalEntryList({ periodId }: Props) {
Efterföljande
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
@@ -101,8 +101,8 @@ export function JournalEntryReviewContent({
{/* Debit/Credit — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
+1 -2
View File
@@ -113,8 +113,7 @@ export default function CustomerForm({
}
} catch {
toast({
title: 'Fel',
description: 'Kunde inte verifiera VAT-nummer',
title: 'Kunde inte verifiera VAT-nummer',
variant: 'destructive',
})
} finally {
+49 -211
View File
@@ -12,23 +12,17 @@ import {
ArrowLeftRight,
ChevronDown,
ChevronRight,
Users,
Landmark,
CheckCircle2,
FileWarning,
Clock,
} from 'lucide-react'
import { getAllExtensions } 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'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
interface DashboardContentProps {
firstName?: string | null
companyId: string
settings: CompanySettings | null
summary: {
ytd: { income: number; expenses: number; net: number }
mtd: { income: number; expenses: number; net: number }
@@ -49,20 +43,12 @@ interface DashboardContentProps {
onboardingProgress?: OnboardingProgress
}
export default function DashboardContent({ firstName, companyId, settings, summary, onboardingProgress }: DashboardContentProps) {
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const [showMore, setShowMore] = useState(false)
const [greeting, setGreeting] = useState('Hej')
// 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(() => {
const hour = new Date().getHours()
setGreeting(hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll')
}, [])
useEffect(() => {
if (!needsSetup) {
setSetupGateActive(false)
@@ -100,7 +86,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
}).format(amount)
}
// Build alert items for "Att hantera" section
const alertItems: React.ReactNode[] = []
if (summary.overdueInvoicesCount > 0) {
@@ -228,19 +213,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
// Build extension quick actions from all compiled extensions
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = getAllExtensions()
.filter(def => def.quickAction)
.map(def => ({ ...def.quickAction!, key: `${def.sector}/${def.slug}` }))
.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 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
@@ -248,18 +220,10 @@ export default function DashboardContent({ firstName, companyId, settings, summa
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 */}
<div className="stagger-enter space-y-8">
{/* 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">Resultat</p>
@@ -276,7 +240,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
</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">
@@ -295,7 +258,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
</Card>
</Link>
{/* Card 3: Banksaldo */}
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
@@ -315,27 +277,20 @@ export default function DashboardContent({ firstName, companyId, settings, summa
<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>
</>
<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>
) : (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
@@ -348,78 +303,46 @@ export default function DashboardContent({ firstName, companyId, settings, summa
</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>
)
})}
{/* Resultat — intäkter / kostnader (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">Intäkter</p>
<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 className="mt-4 pt-3 border-t border-border/30 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>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<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 className="mt-4 pt-3 border-t border-border/30 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>
</CardContent>
</Card>
</div>
</section>
{/* Alerts section */}
{/* Att hantera */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<section id="alerts-section">
<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">
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
{visibleAlerts}
</div>
{hasMoreAlerts && (
@@ -436,104 +359,19 @@ export default function DashboardContent({ firstName, companyId, settings, summa
</section>
)}
{/* Upcoming deadlines — always visible */}
{/* Upcoming deadlines */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-8">
<section>
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{/* Tax todo */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<section>
<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>
)
}
+1 -1
View File
@@ -187,7 +187,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-[232px] md:flex-col">
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/90">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company switcher */}
+1 -1
View File
@@ -174,7 +174,7 @@ export default function WelcomeOnboarding({
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
toast({ title: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
} finally {
setIsSaving(false)
}
+1 -2
View File
@@ -93,8 +93,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
@@ -83,8 +83,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
@@ -1652,7 +1652,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
} catch (err) {
toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
toast({ title: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
}
}, [toast, fetchStatus])
@@ -45,8 +45,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
setCustomers(customersData || [])
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hamta data',
title: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
@@ -74,8 +73,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa deadline',
title: 'Kunde inte skapa deadline',
variant: 'destructive',
})
throw error
@@ -101,8 +99,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
fetchData()
} catch {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera deadline',
title: 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
+7 -7
View File
@@ -121,13 +121,13 @@ export default function CustomersEditStep({
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Namn</th>
<th className="px-3 py-2 text-left font-medium w-44">Kundtyp</th>
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
<th className="px-3 py-2 text-left font-medium">E-post</th>
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left">Namn</th>
<th className="px-3 py-2 text-left w-44">Kundtyp</th>
<th className="px-3 py-2 text-left w-36">Orgnr</th>
<th className="px-3 py-2 text-left">E-post</th>
<th className="px-3 py-2 text-left w-32">Status</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
@@ -197,10 +197,10 @@ export default function OpeningBalanceColumnMappingStep({
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
+6 -6
View File
@@ -199,12 +199,12 @@ export default function OpeningBalanceEditStep({
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium w-28">Konto</th>
<th className="px-3 py-2 text-left font-medium">Kontonamn</th>
<th className="px-3 py-2 text-right font-medium w-32">Debet</th>
<th className="px-3 py-2 text-right font-medium w-32">Kredit</th>
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
@@ -88,10 +88,10 @@ export default function RegisterColumnMappingStep<K extends string>({
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
+7 -7
View File
@@ -111,13 +111,13 @@ export default function SuppliersEditStep({
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Namn</th>
<th className="px-3 py-2 text-left font-medium w-44">Typ</th>
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
<th className="px-3 py-2 text-left font-medium w-32">Bankgiro/IBAN</th>
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left">Namn</th>
<th className="px-3 py-2 text-left w-44">Typ</th>
<th className="px-3 py-2 text-left w-36">Orgnr</th>
<th className="px-3 py-2 text-left w-32">Bankgiro/IBAN</th>
<th className="px-3 py-2 text-left w-32">Status</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
+2 -2
View File
@@ -101,8 +101,8 @@ export function InvoiceReviewContent({
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
+40 -60
View File
@@ -1,8 +1,7 @@
'use client'
import { useState } from 'react'
import { Info, X } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions'
import type { KPIReport, KPIPreferences } from '@/types'
@@ -72,7 +71,6 @@ function getValueColor(
? 'text-[hsl(var(--chart-1))]'
: 'text-[hsl(var(--chart-2))]'
}
// negative-good (e.g. VAT: negative = refund = good, expense ratio: lower = better)
if (colorLogic === 'negative-good') {
return value <= 0
? 'text-[hsl(var(--chart-1))]'
@@ -83,9 +81,7 @@ function getValueColor(
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const prefs = preferences ?? getDefaultPreferences()
const [infoOpen, setInfoOpen] = useState<string | null>(null)
// Build ordered, visible list
const visibleDefs = prefs.kpiOrder
.map((id) => KPI_DEFINITIONS.find((d) => d.id === id))
.filter((d) => d && prefs.visibleKpis.includes(d.id)) as typeof KPI_DEFINITIONS
@@ -100,7 +96,6 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
)
}
// Responsive grid: 2 cols on mobile, up to 4 on desktop
const gridCols =
visibleDefs.length <= 2
? 'grid-cols-2'
@@ -114,66 +109,51 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const { value, subtitle } = getKPIValue(report, def.id)
const formatted = formatKPIValue(value, def.format, def.id)
const color = getValueColor(value, def.colorLogic)
const showInfo = infoOpen === def.id
const hasOverride =
prefs.accountOverrides[def.id] &&
prefs.accountOverrides[def.id].length > 0
return (
<Card key={def.id} className="relative">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-1">
{def.label}
</p>
<button
type="button"
onClick={() =>
setInfoOpen(showInfo ? null : def.id)
}
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors -mt-0.5 -mr-1 p-1"
aria-label={`Visa formel för ${def.label}`}
>
{showInfo ? (
<X className="h-3 w-3" />
) : (
<Info className="h-3 w-3" />
)}
</button>
const tooltipContent = (
<div className="space-y-1.5 text-xs">
<p className="text-foreground/90">{def.description}</p>
<div>
<span className="font-medium">Formel: </span>
<span className="font-mono">{def.formula}</span>
</div>
<div>
<span className="font-medium">Konton: </span>
{def.accountDescription}
</div>
{hasOverride && (
<div className="text-primary">
<span className="font-medium">Anpassade: </span>
<span className="font-mono">
{prefs.accountOverrides[def.id].join(', ')}
</span>
</div>
)}
</div>
)
{showInfo ? (
<div className="space-y-1.5 text-[11px] text-muted-foreground mt-1">
<p className="text-xs text-foreground/80">{def.description}</p>
<div>
<span className="font-medium">Formel: </span>
<span className="font-mono">{def.formula}</span>
</div>
<div>
<span className="font-medium">Konton: </span>
{def.accountDescription}
</div>
{hasOverride && (
<div className="text-primary">
<span className="font-medium">Anpassade konton: </span>
<span className="font-mono">
{prefs.accountOverrides[def.id].join(', ')}
</span>
</div>
)}
</div>
) : (
<>
<p
className={`font-display text-xl tabular-nums tracking-tight ${color}`}
>
{formatted}
</p>
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
</p>
</>
)}
return (
<Card key={def.id}>
<CardContent className="p-6">
<InfoTooltip
content={tooltipContent}
side="top"
maxWidth="320px"
iconClassName="h-3 w-3"
>
<p className="text-xs text-muted-foreground">{def.label}</p>
</InfoTooltip>
<p
className={`font-display text-2xl font-medium tabular-nums tracking-tight mt-2 ${color}`}
>
{formatted}
</p>
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
</p>
</CardContent>
</Card>
)
@@ -87,7 +87,7 @@ export default function BankIdCompanyPicker({
setSetup({ kind: 'opening', companyId })
const result = await switchCompany(companyId)
if (result.error) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: result.error, variant: 'destructive' })
setSetup({ kind: 'idle' })
return
}
+13 -13
View File
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { formatCurrency, formatDate } from '@/lib/utils'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -368,8 +368,8 @@ export function BankReconciliationView() {
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">Transaktion</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28 text-right">Belopp</th>
@@ -383,14 +383,14 @@ export function BankReconciliationView() {
{dryRunResults.map((m) => (
<tr key={m.transaction_id} className="border-b last:border-0">
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
<td className="py-2">{m.transaction_date}</td>
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
<td className="py-2 text-center text-muted-foreground">&harr;</td>
<td className="py-2">
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className="py-2">{m.entry_date}</td>
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
<td className="py-2">
<Badge variant="outline" className="text-xs">
{METHOD_LABELS[m.method] || m.method}
@@ -422,8 +422,8 @@ export function BankReconciliationView() {
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
@@ -454,7 +454,7 @@ export function BankReconciliationView() {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
{line.voucher_series}{line.voucher_number} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
</option>
)
})}
@@ -489,8 +489,8 @@ export function BankReconciliationView() {
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
<td className="py-2 font-mono text-xs">
{line.voucher_series}{line.voucher_number}
</td>
<td className="py-2">{line.entry_date}</td>
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
@@ -544,8 +544,8 @@ export function BankReconciliationView() {
{showMatched && (
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
+143
View File
@@ -0,0 +1,143 @@
'use client'
import { cn } from '@/lib/utils'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { EntityType } from '@/types'
interface ReportItem {
value: string
label: string
entityType?: EntityType
}
interface ReportCategory {
label: string
items: ReportItem[]
}
const CATEGORIES: ReportCategory[] = [
{
label: 'Löpande',
items: [
{ value: 'resultatrapport', label: 'Resultatrapport' },
{ value: 'balansrapport', label: 'Balansrapport' },
{ value: 'trial-balance', label: 'Saldobalans' },
],
},
{
label: 'Bokslut',
items: [
{ value: 'income-statement', label: 'Resultaträkning' },
{ value: 'balance-sheet', label: 'Balansräkning' },
],
},
{
label: 'Skatt & moms',
items: [
{ value: 'vat-declaration', label: 'Momsdeklaration' },
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
],
},
{
label: 'Huvudböcker',
items: [
{ value: 'huvudbok', label: 'Huvudbok' },
{ value: 'grundbok', label: 'Grundbok' },
{ value: 'kundreskontra', label: 'Kundreskontra' },
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
],
},
{
label: 'Avstämning',
items: [
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
],
},
]
interface ReportsNavProps {
active: string
onChange: (value: string) => void
entityType?: EntityType
}
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
const filtered = CATEGORIES
.map(cat => ({
...cat,
items: cat.items.filter(item => !item.entityType || item.entityType === entityType),
}))
.filter(cat => cat.items.length > 0)
return (
<>
{/* Mobile: grouped select */}
<div className="sm:hidden">
<Select value={active} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{filtered.map(cat => (
<SelectGroup key={cat.label}>
<SelectLabel>{cat.label}</SelectLabel>
{cat.items.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{/* Desktop: vertical left rail */}
<nav
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
aria-label="Rapportkategorier"
>
<ul className="space-y-6">
{filtered.map(cat => (
<li key={cat.label}>
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
{cat.label}
</p>
<ul className="space-y-px">
{cat.items.map(item => {
const isActive = active === item.value
return (
<li key={item.value}>
<button
type="button"
onClick={() => onChange(item.value)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'w-full text-left px-3 py-1.5 rounded-md text-[13px] transition-colors',
isActive
? 'bg-primary/10 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{item.label}
</button>
</li>
)
})}
</ul>
</li>
))}
</ul>
</nav>
</>
)
}
+1 -1
View File
@@ -88,7 +88,7 @@ export function TaxPaymentPanel({
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte markera som betald' }))
toast({
title: 'Fel',
title: 'Kunde inte markera som betald',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
+4 -4
View File
@@ -168,7 +168,7 @@ export function ApiKeysPanel() {
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
}
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
@@ -189,7 +189,7 @@ export function ApiKeysPanel() {
const json = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: json.error, variant: 'destructive' })
toast({ title: json.error, variant: 'destructive' })
return
}
@@ -200,7 +200,7 @@ export function ApiKeysPanel() {
setNewKeyScopes(new Set(ALL_SCOPES))
fetchKeys()
} catch {
toast({ title: 'Fel', description: 'Kunde inte skapa nyckel', variant: 'destructive' })
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -219,7 +219,7 @@ export function ApiKeysPanel() {
setKeys((prev) => prev.filter((k) => k.id !== id))
toast({ title: 'Nyckel återkallad' })
} catch {
toast({ title: 'Fel', description: 'Kunde inte återkalla nyckel', variant: 'destructive' })
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatDateLong } from '@/lib/utils'
interface BankIdIdentity {
given_name: string | null
@@ -122,7 +123,7 @@ export function BankIdSettings() {
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')}
Kopplat {formatDateLong(identity.linked_at)}
</span>
</div>
<Button
@@ -44,7 +44,7 @@ export function BookingTemplatesPanel() {
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
@@ -61,7 +61,7 @@ export function BookingTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
@@ -82,7 +82,7 @@ export function BookingTemplatesPanel() {
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: 'Fel', description: 'Kunde inte exportera mallar', variant: 'destructive' })
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
}
}
@@ -304,8 +304,8 @@ function TemplateSection({
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
)}
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
@@ -384,7 +384,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
})
if (!res.ok) {
const json = await res.json()
toast({ title: 'Fel', description: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
+4 -8
View File
@@ -62,8 +62,7 @@ export function CalendarFeedSettings() {
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa kalenderfeed.',
title: 'Kunde inte skapa kalenderfeed.',
variant: 'destructive',
})
} finally {
@@ -91,8 +90,7 @@ export function CalendarFeedSettings() {
setFeed(data)
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera inställning.',
title: 'Kunde inte uppdatera inställning.',
variant: 'destructive',
})
} finally {
@@ -129,8 +127,7 @@ export function CalendarFeedSettings() {
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa ny länk.',
title: 'Kunde inte skapa ny länk.',
variant: 'destructive',
})
} finally {
@@ -149,8 +146,7 @@ export function CalendarFeedSettings() {
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte kopiera länken.',
title: 'Kunde inte kopiera länken.',
variant: 'destructive',
})
}
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { formatDateLong } from '@/lib/utils'
import { Loader2, Plus, Trash2, Mail, Clock, Users } from 'lucide-react'
interface CompanyMemberItem {
@@ -85,7 +86,7 @@ export function CompanyMembersSection() {
const data = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
toast({ title: data.error, variant: 'destructive' })
return
}
@@ -102,7 +103,7 @@ export function CompanyMembersSection() {
setInviteRole('viewer')
fetchMembers()
} catch {
toast({ title: 'Fel', description: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
} finally {
setIsSending(false)
}
@@ -115,14 +116,14 @@ export function CompanyMembersSection() {
const data = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
toast({ title: data.error, variant: 'destructive' })
return
}
toast({ title: 'Medlem borttagen' })
fetchMembers()
} catch {
toast({ title: 'Fel', description: 'Kunde inte ta bort medlem.', variant: 'destructive' })
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
} finally {
setRemovingId(null)
}
@@ -135,14 +136,14 @@ export function CompanyMembersSection() {
const data = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
toast({ title: data.error, variant: 'destructive' })
return
}
toast({ title: 'Inbjudan återkallad' })
fetchMembers()
} catch {
toast({ title: 'Fel', description: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
} finally {
setRevokingId(null)
}
@@ -286,7 +287,7 @@ export function CompanyMembersSection() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
Går ut {new Date(inv.expires_at).toLocaleDateString('sv-SE')}
Går ut {formatDateLong(inv.expires_at)}
</span>
</div>
</div>
@@ -57,7 +57,7 @@ export function CounterpartyTemplatesPanel() {
setTemplates(json.data)
}
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
@@ -76,14 +76,14 @@ export function CounterpartyTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
if (expandedId === id) setExpandedId(null)
toast({ title: 'Mall borttagen' })
} catch {
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
} finally {
setDeletingId(null)
}
@@ -1,22 +1,24 @@
import { Skeleton } from '@/components/ui/skeleton'
export function SettingsLoadingSkeleton() {
return (
<div className="space-y-8 animate-in fade-in duration-300">
{[1, 2].map(i => (
<div key={i} className="space-y-4">
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
<Skeleton className="h-3.5 w-24" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<Skeleton className="h-3.5 w-20" />
<Skeleton className="h-10" />
</div>
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-10" />
</div>
</div>
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-10" />
</div>
</div>
))}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
interface VoucherSeries {
@@ -52,8 +53,8 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
{isLoading ? (
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
) : seriesEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
@@ -204,8 +204,8 @@ export function SupplierInvoiceReviewContent({
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-20">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
@@ -277,8 +277,8 @@ export function SupplierInvoiceReviewContent({
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
<div className="hidden sm:block">
<table className="w-full text-sm font-mono">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="text-left">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
@@ -244,9 +244,9 @@ export default function TransactionInboxCard({
{/* Delete button — available for all unbooked transactions */}
{isDeletable && onDelete && (
<Button
size="sm"
variant="ghost"
className="h-9 w-9 p-0 ml-auto text-muted-foreground hover:text-destructive"
size="icon"
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() => onDelete(transaction.id)}
disabled={isProcessing || isDisabled}
aria-label="Ta bort transaktion"
+2 -2
View File
@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
@@ -86,7 +86,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))