diff --git a/CLAUDE.md b/CLAUDE.md index e6b1c483..db6a535c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -526,3 +526,69 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who - Keyboard-navigable with visible focus rings - Respect `prefers-reduced-motion` - Color never sole indicator of state — always pair with icons, text, or shape +- Touch targets ≥ 40px (shadcn `Button size="icon"` default) — bump higher to 44px (WCAG AAA) for new mobile-critical surfaces +- Icon-only buttons must have `aria-label` + +### Design System Tokens + +These conventions are locked. Don't reinvent them in new code; deviating from them on existing pages is a regression. + +**Spacing scale.** Only use Tailwind values `1, 2, 3, 4, 6, 8, 10, 12`. **Forbidden:** `2.5`, `5`, hardcoded pixels in page logic. + +| Token | Tailwind | Use for | +|---|---|---| +| 4 | `1` | icon padding | +| 8 | `2` | tight inline gaps | +| 12 | `3` | dense list rows, badge gaps | +| 16 | `4` | default form / control / grid gap | +| 24 | `6` | **card padding default** (`p-6`) | +| 32 | `8` | **between page sections** (`space-y-8` on page root) | +| 40 | `10` | hero spacing | +| 48 | `12` | top of page after header | + +Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail cards use `p-6`. Never mix `p-5`. + +**Layout.** +- Sidebar width: `md:w-64` (256px). Main content offset: `md:pl-64`. +- Main container: `max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10` (via `components/dashboard/MainContainer.tsx`). +- Page root: `
`. + +**Primitives — always use these, don't hand-roll.** + +| Need | Component | Notes | +|---|---|---| +| Page title + action | `components/ui/page-header.tsx` `PageHeader` | Use this, not bespoke `

` + `

` blocks. Drop the `description` prop when it just paraphrases the title. | +| Data table | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `` when the table is a card's primary content. Add `tabular-nums` to numeric cells. | +| Status indicator | `components/ui/badge.tsx` `` | Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. | +| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `

`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). | +| Loading placeholder | `components/ui/skeleton.tsx` `` | Don't hand-roll `bg-muted rounded animate-pulse` divs. | +| Inline help / formulas | `components/ui/info-tooltip.tsx` `InfoTooltip` | Hover-revealed; don't use always-visible info buttons. | +| Fiscal year picker | `components/common/FiscalYearSelector.tsx` | Don't use raw `` duplicating desktop tabs in code — use a single Tabs primitive or a single grouped `Select`. +- Hand-rolled icon buttons smaller than `h-10 w-10`. Use shadcn `Button size="icon"`. +- Color-coded status using full-rainbow Tailwind palette (`bg-amber-100`, `bg-emerald-500/10`, etc.). Use Badge variants tied to the brand palette. diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index ad7248bf..e910d819 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -9,6 +9,7 @@ import { AccountNumber } from '@/components/ui/account-number' import { Textarea } from '@/components/ui/textarea' import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatDate } from '@/lib/utils' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' @@ -84,9 +85,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const res = await fetch(`/api/bookkeeping/journal-entries/${id}`, { method: 'DELETE' }) const result = await res.json() if (res.ok) { + const wasDraft = result.data?.was_draft === true toast({ - title: 'Verifikat raderat', - description: `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`, + title: wasDraft ? 'Utkast raderat' : 'Verifikat raderat', + description: wasDraft + ? 'Utkastet har tagits bort.' + : `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`, }) router.push('/bookkeeping') } else { @@ -156,7 +160,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const fullChain = [entry, ...chain] return ( -
+
{/* Back link */} {entry.description}

- {entry.status === 'posted' && ( + {(entry.status === 'posted' || entry.status === 'draft') && (
- {isLastInSeries && ( + {(entry.status === 'draft' || isLastInSeries) && ( )} {canCorrect && ( @@ -206,17 +210,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Skapa ändringsverifikation )} - + {entry.status === 'posted' && ( + + )}
)}
@@ -230,7 +236,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
Datum - {entry.entry_date} + {formatDate(entry.entry_date)}
{entry.committed_at && (
@@ -260,11 +266,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {!editingNotes && canWrite && ( )}
@@ -381,8 +387,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {/* Desktop table */}
- - + + @@ -530,8 +536,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i onOpenChange={setShowDeleteConfirm} onConfirm={handleDelete} isSubmitting={isDeleting} - title="Radera verifikat" - warningText={`Verifikat ${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''} raderas permanent. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.`} + title={entry?.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'} + warningText={ + entry?.status === 'draft' + ? 'Utkastet har aldrig bokförts och kan tas bort utan att påverka verifikationsserien. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.' + : `Verifikat ${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''} raderas permanent. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.` + } confirmLabel="Radera permanent" >
@@ -539,9 +549,9 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i

Permanent radering

- Verifikatet och dess kontorader tas bort. Kopplade transaktioner och - fakturor behåller sina uppgifter men markeras som ej bokförda. - Underlag (kvitton, dokument) behålls men avlänkas. + {entry?.status === 'draft' + ? 'Utkastet och dess kontorader tas bort. Kopplade fakturor och transaktioner påverkas inte — de stannar kvar som obokförda. Underlag (kvitton, dokument) behålls men avlänkas.' + : 'Verifikatet och dess kontorader tas bort. Kopplade transaktioner och fakturor behåller sina uppgifter men markeras som ej bokförda. Underlag (kvitton, dokument) behålls men avlänkas.'}

diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 6f1736f8..52e591b7 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -11,6 +11,7 @@ import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsMana import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { useToast } from '@/components/ui/use-toast' import { Lock, Loader2, Copy } from 'lucide-react' +import { PageHeader } from '@/components/ui/page-header' import type { JournalEntry, JournalEntryLine } from '@/types' interface CopyPrefill { @@ -129,25 +130,18 @@ export default function BookkeepingPage() { }, [refreshKey]) return ( -
-
-
-

Bokföring

-

- Skapa verifikationer, hantera kontoplanen och bifoga underlag -

-
- -
- - {activeTab === 'journal' && ( - - )} +
+ + + + Årsbokslut + + + } + /> setActiveTab(v as TabValue)}> @@ -163,7 +157,8 @@ export default function BookkeepingPage() { Kontoplan - + + diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index ea9557b4..0bbca857 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -6,7 +6,7 @@ import { SupportLink } from '@/components/ui/support-link' export default function YearEndPage() { return ( -
+

Årsbokslut

- - - - Lägg till kund - - - - -
+
+ + + + + + + Lägg till kund + + + + + } + /> {/* Search */}
setSearchTerm(e.target.value)} className="pl-10" @@ -167,15 +165,13 @@ export default function CustomersPage() {
) : filteredCustomers.length === 0 ? ( - + {searchTerm ? ( -
- -

Inga träffar

-

- Inga kunder matchar "{searchTerm}" -

-
+ ) : ( setIsDialogOpen(true)} /> )} diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 6230c340..6a5b8773 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -197,9 +197,9 @@ export default function DeadlinesPage() { if (isLoading) { return ( -
+
-
+
{[1, 2, 3, 4].map((i) => (
diff --git a/app/(dashboard)/extensions/page.tsx b/app/(dashboard)/extensions/page.tsx index 79f7fb19..5b21b959 100644 --- a/app/(dashboard)/extensions/page.tsx +++ b/app/(dashboard)/extensions/page.tsx @@ -17,7 +17,7 @@ export default function ExtensionsPage() { {/* General extensions */} {generalSector && ( -
+

{generalSector.name}

diff --git a/app/(dashboard)/help/page.tsx b/app/(dashboard)/help/page.tsx index 64b05525..8f2e9aaf 100644 --- a/app/(dashboard)/help/page.tsx +++ b/app/(dashboard)/help/page.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { HelpLink } from '@/components/ui/info-tooltip' +import { PageHeader } from '@/components/ui/page-header' import { Search, BookOpen, @@ -319,13 +320,10 @@ export default function HelpPage() { return (
- {/* Header */} -
-

Hjälp & Ordlista

-

- Förklaringar av skatte- och bokföringstermer på ren svenska. -

-
+ {/* Search */}
@@ -369,7 +367,7 @@ export default function HelpPage() {
{/* Terms list */} -
+
{filteredTerms.length === 0 ? ( @@ -433,7 +431,6 @@ export default function HelpPage() { Externa resurser - Mer hjälp från officiella källor
diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 2193f76c..5cac3af2 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -149,7 +149,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Header */}
-
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 2b5cdb11..eef0da73 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -369,11 +369,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const isDeliveryNote = docType === 'delivery_note' const isRealInvoice = docType === 'invoice' return ( -
+
{/* Header */}
-
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index b881e945..40b872cc 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -56,6 +56,10 @@ type FormData = z.infer const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] +function RequiredMark() { + return +} + export default function NewInvoicePage() { const router = useRouter() const { toast } = useToast() @@ -438,9 +442,9 @@ export default function NewInvoicePage() { } return ( -
+
-
@@ -475,7 +479,7 @@ export default function NewInvoicePage() { {/* Customer selection */} - Kund + Kund Välj vilken kund fakturan ska skickas till @@ -564,6 +568,7 @@ export default function NewInvoicePage() { type="number" step="0.01" inputMode="decimal" + className="text-right tabular-nums" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
@@ -594,6 +599,7 @@ export default function NewInvoicePage() { type="number" step="any" inputMode="decimal" + className="text-right tabular-nums" {...register(`items.${index}.unit_price`, { valueAsNumber: true })} />
@@ -729,13 +735,13 @@ export default function NewInvoicePage() {
- - + +
- - + +
{watchDocumentType === 'invoice' && ( diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index a420c876..dc10fc46 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -16,19 +16,19 @@ import { cn } from '@/lib/utils' import { invoiceNumberDisplay } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' import { Plus, Search, Receipt, Lock } from 'lucide-react' -import { EmptyInvoices } from '@/components/ui/empty-state' +import { EmptyInvoices, EmptyState } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { Invoice, InvoiceStatus } from '@/types' -const statusConfig: Record = { - draft: { label: 'Utkast', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, - sent: { label: 'Skickad', variant: 'default', borderColor: 'border-warning/50' }, - paid: { label: 'Betald', variant: 'success', borderColor: 'border-success/50' }, - partially_paid: { label: 'Delbetalad', variant: 'warning', borderColor: 'border-warning/50' }, - overdue: { label: 'Förfallen', variant: 'destructive', borderColor: 'border-destructive/50' }, - cancelled: { label: 'Makulerad', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, - credited: { label: 'Krediterad', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, +const statusConfig: Record = { + draft: { label: 'Utkast', variant: 'secondary' }, + sent: { label: 'Skickad', variant: 'default' }, + paid: { label: 'Betald', variant: 'success' }, + partially_paid: { label: 'Delbetalad', variant: 'warning' }, + overdue: { label: 'Förfallen', variant: 'destructive' }, + cancelled: { label: 'Makulerad', variant: 'secondary' }, + credited: { label: 'Krediterad', variant: 'secondary' }, } function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null { @@ -134,10 +134,9 @@ export default function InvoicesPage() { } return ( -
+
@@ -158,58 +157,33 @@ export default function InvoicesPage() { } /> - {/* Stats */} -
- {isLoading ? ( - <> - {[1, 2, 3].map((i) => ( - - -
-
-
-
- - - ))} - - ) : ( - <> - - -

Totalt antal

-

{invoices.length}

-
-
- - -

Obetalda

-
-

{stats.unpaid}

- {stats.overdue > 0 && ( - - {stats.overdue} förfallna - - )} -
-
-
- - -

Att få in

-

{formatCurrency(stats.unpaidAmount)}

-
-
- - )} -
+ {/* Inline summary */} + {!isLoading && invoices.length > 0 && ( +

+ {invoices.length} {invoices.length === 1 ? 'faktura' : 'fakturor'} + {stats.unpaid > 0 && ( + <> + {' · '} + {stats.unpaid} obetalda + {' · '} + {formatCurrency(stats.unpaidAmount)} att få in + {stats.overdue > 0 && ( + <> + {' · '} + {stats.overdue} förfallna + + )} + + )} +

+ )} {/* Search and tabs */}
setSearchTerm(e.target.value)} className="pl-10" @@ -265,30 +239,26 @@ export default function InvoicesPage() {
) : filteredInvoices.length === 0 ? ( - + {searchTerm ? ( -
- -

Inga träffar

-

- Inga fakturor matchar "{searchTerm}" -

-
+ ) : invoices.length === 0 ? ( ) : ( -
- -

Inga fakturor i denna kategori

-

- Prova att byta flik för att se fler fakturor -

-
+ )}
) : ( -
+
{filteredInvoices.map((invoice) => { const status = statusConfig[invoice.status] const isCreditNote = !!invoice.credited_invoice_id @@ -299,9 +269,7 @@ export default function InvoicesPage() { return (
diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index e6214247..7e55fb05 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -1,48 +1,35 @@ 'use client' import { useState, useEffect, useCallback } from 'react' -import { Label } from '@/components/ui/label' import { Card, CardContent } from '@/components/ui/card' +import { Skeleton } from "@/components/ui/skeleton" +import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { KPIHeroCards } from '@/components/kpi/KPIHeroCards' import { KPITrendChart } from '@/components/kpi/KPITrendChart' import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog' import { getDefaultPreferences } from '@/lib/reports/kpi-definitions' -import type { FiscalPeriod, KPIReport, KPIPreferences } from '@/types' +import type { KPIReport, KPIPreferences } from '@/types' export default function KpiPage() { - const [periods, setPeriods] = useState([]) - const [selectedPeriod, setSelectedPeriod] = useState('') + const [selectedPeriod, setSelectedPeriod] = useState('') const [report, setReport] = useState(null) const [preferences, setPreferences] = useState(getDefaultPreferences()) - const [isLoadingInit, setIsLoadingInit] = useState(true) const [isLoadingReport, setIsLoadingReport] = useState(false) const [isSavingPrefs, setIsSavingPrefs] = useState(false) const [error, setError] = useState(null) useEffect(() => { - async function init() { + let cancelled = false + ;(async () => { try { - const [periodsRes, prefsRes] = await Promise.all([ - fetch('/api/bookkeeping/fiscal-periods'), - fetch('/api/kpi/preferences'), - ]) - const { data: periodsData } = await periodsRes.json() - const { data: prefsData } = await prefsRes.json() - - const today = new Date().toISOString().split('T')[0] - const activePeriods = (periodsData || []).filter((p: FiscalPeriod) => p.period_start <= today) - setPeriods(activePeriods) - if (prefsData) setPreferences(prefsData) - if (activePeriods.length > 0) { - setSelectedPeriod(activePeriods[0].id) - } + const res = await fetch('/api/kpi/preferences') + const { data } = await res.json() + if (!cancelled && data) setPreferences(data) } catch { - setError('Kunde inte hämta data') - } finally { - setIsLoadingInit(false) + // Silently fall back to defaults } - } - init() + })() + return () => { cancelled = true } }, []) const fetchReport = useCallback(async (periodId: string) => { @@ -63,7 +50,6 @@ export default function KpiPage() { useEffect(() => { if (!selectedPeriod) return let cancelled = false - fetchReport(selectedPeriod).then(() => { if (cancelled) setReport(null) }) @@ -81,11 +67,7 @@ export default function KpiPage() { if (!res.ok) throw new Error() const { data } = await res.json() setPreferences(data) - - // Re-fetch report if account overrides changed (calculations may differ) - if (selectedPeriod) { - await fetchReport(selectedPeriod) - } + if (selectedPeriod) await fetchReport(selectedPeriod) } catch { // Silently fail — user can retry } finally { @@ -93,25 +75,10 @@ export default function KpiPage() { } } - if (isLoadingInit) { - return ( -
-
-

Nyckeltal

-

Översikt av företagets ekonomiska hälsa

-
- -
- ) - } - return ( -
+
-
-

Nyckeltal

-

Översikt av företagets ekonomiska hälsa

-
+

Nyckeltal

- {/* Period selector */} - {periods.length > 0 && ( -
- - -
- )} + setSelectedPeriod(id || '')} + includeAllOption={false} + hideFuturePeriods + /> {error && ( @@ -153,36 +109,28 @@ export default function KpiPage() { {report.months.length > 0 && } )} - - {!isLoadingReport && !error && !report && periods.length === 0 && ( - - -

Inget räkenskapsår hittades. Skapa ett räkenskapsår för att se nyckeltal.

-
-
- )}
) } function LoadingSkeleton() { return ( -
+
{[1, 2, 3, 4].map((i) => ( - -
-
-
+ + + + ))}
- -
-
+ + +
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 829ee5c7..0576584b 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -97,7 +97,7 @@ export default async function DashboardLayout({ />
@@ -147,7 +147,7 @@ export default async function DashboardLayout({ isSandbox={false} extensionNavItems={getExtensionNavItems()} /> -
+
{children}
@@ -222,7 +222,7 @@ export default async function DashboardLayout({ isSandbox={isSandbox} extensionNavItems={getExtensionNavItems()} /> -
+
{children}
{!isSandbox && ( diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx index 05c14e18..71a54ae5 100644 --- a/app/(dashboard)/loading.tsx +++ b/app/(dashboard)/loading.tsx @@ -2,15 +2,9 @@ import { Skeleton } from '@/components/ui/skeleton' export default function DashboardLoading() { return ( -
- {/* Header */} -
- - -
- - {/* Summary cards */} -
+
+ {/* Metrics row */} +
{[1, 2, 3, 4].map((i) => (
@@ -20,23 +14,24 @@ export default function DashboardLoading() { ))}
- {/* Quick actions */} -
- {[1, 2, 3, 4].map((i) => ( - + {/* Income / Expenses */} +
+ {[1, 2].map((i) => ( +
+ + + +
))}
- {/* Alerts + Deadlines */} -
- {/* Alerts */} + {/* Alerts + deadlines */} +
- - {/* Deadlines */}
{[1, 2, 3].map((i) => ( diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 55091bbd..919dc621 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -55,7 +55,6 @@ export default async function DashboardPage() { // Fetch all data in parallel const [ - { data: profile }, { data: settings }, { count: customerCount }, { count: invoiceCount }, @@ -77,7 +76,6 @@ export default async function DashboardPage() { { count: uncategorizedCount }, { count: skatteverketTokenCount }, ] = await Promise.all([ - supabase.from('profiles').select('full_name').eq('id', user.id).single(), supabase.from('company_settings').select('*').eq('company_id', companyId).single(), supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId), @@ -108,8 +106,6 @@ export default async function DashboardPage() { supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id), ]) - const firstName = profile?.full_name?.split(' ')[0] || null - // If onboarding is not complete, redirect to onboarding if (!settings?.onboarding_complete) { redirect('/onboarding') @@ -236,9 +232,7 @@ export default async function DashboardPage() { return ( {showBulkControls && bulkEligible.length > 0 && ( -
+
)} {op.status === 'committed' && ( - + Godkänd )} {op.status === 'rejected' && ( - + Avvisad diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 07113398..dba8909b 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -3,14 +3,16 @@ import React, { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from "@/components/ui/skeleton" import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Download, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react' +import { formatDate } from '@/lib/utils' import { AccountNumber } from '@/components/ui/account-number' import { useCompany } from '@/contexts/CompanyContext' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' +import { ReportsNav } from '@/components/reports/ReportsNav' import { NEDeclarationView } from '@/components/reports/NEDeclarationView' import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView' import { BankReconciliationView } from '@/components/reports/BankReconciliationView' @@ -89,14 +91,9 @@ export default function ReportsPage() { const isAktiebolag = company?.entity_type === 'aktiebolag' return ( -
+
-
-

Rapporter

-

- Generera skattedeklarationer, resultaträkningar och exportera till Skatteverket -

-
+

Rapporter

@@ -125,19 +122,19 @@ export default function ReportsPage() {
{[1, 2, 3, 4].map((i) => (
-
+
-
-
-
+ + +
))}
-
-
+ +
@@ -166,173 +163,44 @@ export default function ReportsPage() { )} - - {/* Mobile: compact select dropdown */} -
- -
- - {/* Desktop: inline grouped tab navigation */} -
- {/* Löpande rapporter */} -
- Löpande rapporter - - - Resultatrapport - - - Balansrapport - - - Saldobalans - - -
- -
- - {/* Bokslut */} -
- Bokslut - - - Resultaträkning - - - Balansräkning - - -
- -
- - {/* Skatt & moms */} -
- Skatt & moms - - - Momsdeklaration - - {isEnskildFirma && ( - - NE-bilaga - - )} - {isAktiebolag && ( - - INK2 - - )} - -
- -
- - {/* Huvudböcker */} -
- Huvudböcker - - - Huvudbok - - - Grundbok - - - Kundreskontra - - - Leverantörsreskontra - - -
- -
- - {/* Avstämning */} -
- Avstämning - - - Bankavstämning - - -
-
- - - - - - - - - - - - - - - - - - - - {isEnskildFirma && ( - +
+ +
+ {activeTab === 'resultatrapport' && ( + + )} + {activeTab === 'balansrapport' && ( + + )} + {activeTab === 'trial-balance' && ( + + )} + {activeTab === 'income-statement' && ( + + )} + {activeTab === 'balance-sheet' && ( + + )} + {activeTab === 'vat-declaration' && } + {isEnskildFirma && activeTab === 'ne-declaration' && ( - - )} - {isAktiebolag && ( - + )} + {isAktiebolag && activeTab === 'ink2-declaration' && ( - - )} - - - - - - - - - - - - - - - - + )} + {activeTab === 'huvudbok' && ( + + )} + {activeTab === 'grundbok' && } + {activeTab === 'kundreskontra' && } + {activeTab === 'supplier-ledger' && } + {activeTab === 'bank-reconciliation' && } +
+
) : ( @@ -472,8 +340,8 @@ function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string;
{viewMode === 'simplified' ? (
Konto Beskrivning Debet
- - + + @@ -512,8 +380,8 @@ function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string;
Konto Namn Ingående saldo
) : ( - - + + @@ -1350,12 +1218,12 @@ function VatDeclarationView() {
Momsdeklaration - {data.period.start} till {data.period.end} 0 - ? 'bg-orange-100 text-orange-800' + ? 'warning' : data.rutor.ruta49 < 0 - ? 'bg-success/10 text-success' - : 'bg-gray-100 text-gray-800' + ? 'success' + : 'secondary' } > {data.rutor.ruta49 > 0 @@ -1704,8 +1572,8 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
Konto Namn Period debet
- - + + @@ -1936,8 +1804,8 @@ function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: strin
Leverantör Ej förfallet 1-30 dagar
- - + + @@ -2096,8 +1964,8 @@ function JournalRegisterView({ periodId }: { periodId: string }) {
Ver.nr Datum Beskrivning
- - + + @@ -2330,8 +2198,8 @@ function ARLedgerView({ periodId }: { periodId: string }) {
Ver.nr Datum
- - + + @@ -2371,8 +2239,8 @@ function ARLedgerView({ periodId }: { periodId: string }) {
Kund Ej förfallet {inv.invoice_number} - {inv.invoice_date} - förfaller {inv.due_date} + {formatDate(inv.invoice_date)} + förfaller {formatDate(inv.due_date)} {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 19e5f235..fce79eb8 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, use } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from "@/components/ui/skeleton" import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -111,7 +112,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s } else { const result = await res.json() toast({ - title: 'Fel', + title: 'Kunde inte uppdatera anställd', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -133,8 +134,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s if (loading) { return (
-
-
+ +
) } @@ -144,11 +145,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s } return ( -
+
-

diff --git a/app/(dashboard)/salary/employees/new/page.tsx b/app/(dashboard)/salary/employees/new/page.tsx index 18b71ef6..74affe20 100644 --- a/app/(dashboard)/salary/employees/new/page.tsx +++ b/app/(dashboard)/salary/employees/new/page.tsx @@ -68,7 +68,7 @@ export default function NewEmployeePage() { } else { const result = await res.json() toast({ - title: 'Fel', + title: 'Kunde inte skapa anställd', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -78,10 +78,10 @@ export default function NewEmployeePage() { } return ( -
+
-

Ny anställd

diff --git a/app/(dashboard)/salary/employees/page.tsx b/app/(dashboard)/salary/employees/page.tsx index 4c3df846..c1e7ea9d 100644 --- a/app/(dashboard)/salary/employees/page.tsx +++ b/app/(dashboard)/salary/employees/page.tsx @@ -3,7 +3,10 @@ import { useState, useEffect } from 'react' import Link from 'next/link' import { Card, CardContent } from '@/components/ui/card' +import { Skeleton } from "@/components/ui/skeleton" import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/ui/empty-state' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Plus, ArrowLeft, UserCircle } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatCurrency } from '@/lib/utils' @@ -33,11 +36,11 @@ export default function EmployeesPage() { }, []) return ( -
+
-

Anställda

@@ -57,65 +60,62 @@ export default function EmployeesPage() { {loading ? (
{[1, 2, 3].map(i => ( -
+ ))}
) : employees.length === 0 ? ( - - -

Inga anställda registrerade

- {canWrite && ( - - )} + +
) : ( - - - - - - - - - - - - +
NamnPersonnummerTypMånadslönSysselsättningsgradSkattetabell
+ + + Namn + Personnummer + Typ + Månadslön + Sysselsättningsgrad + Skattetabell + + + {employees.map(emp => ( - - - - - - - - + + ))} - -
- + + + {emp.first_name} {emp.last_name} - + + {emp.personnummer} - + + {EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type} - + + {emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'} - + + {emp.employment_degree}% - + + {emp.tax_table_number ? `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` : '—'} -
+ +

)} diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index a4bd11cf..b3416572 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -2,11 +2,16 @@ import { useState, useEffect } from 'react' import Link from 'next/link' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/ui/empty-state' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react' +import { PageHeader } from '@/components/ui/page-header' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { formatCurrency } from '@/lib/utils' +import { formatCurrency, formatDate } from '@/lib/utils' import type { SalaryRun } from '@/types' const STATUS_LABELS: Record = { @@ -17,12 +22,12 @@ const STATUS_LABELS: Record = { booked: 'Bokförd', } -const STATUS_COLORS: Record = { - draft: 'bg-muted text-muted-foreground', - review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', - approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', - paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', - booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', +const STATUS_VARIANTS: Record = { + draft: 'secondary', + review: 'warning', + approved: 'default', + paid: 'success', + booked: 'success', } export default function SalaryPage() { @@ -61,12 +66,12 @@ export default function SalaryPage() { return (
-
-
+ +
{[1, 2, 3].map(i => ( -
+ ))}
@@ -74,35 +79,33 @@ export default function SalaryPage() { } return ( -
- {/* Header */} -
-
-

Löner

-

Hantera anställda och lönekörningar

-
-
- - {canWrite && ( - - )} -
-
+ {canWrite && ( + + )} +
+ } + /> {/* Summary cards */}
- +
@@ -113,7 +116,7 @@ export default function SalaryPage() { - +
@@ -124,7 +127,7 @@ export default function SalaryPage() { - +
@@ -143,63 +146,58 @@ export default function SalaryPage() { {runs.length === 0 ? ( -
- -

Inga lönekörningar ännu

- {canWrite && ( - - )} -
+ ) : ( - - - - - - - - - - - - - +
PeriodUtbetalningsdagBruttoNettoAvgifterStatus
+ + + Period + Utbetalningsdag + Brutto + Netto + Avgifter + Status + + + + {runs.slice(0, 12).map(run => ( - - - - - - - - - + + ))} - -
+ + {run.period_year}-{String(run.period_month).padStart(2, '0')} - - {run.payment_date} - + + + {formatDate(run.payment_date)} + + {formatCurrency(run.total_gross)} - + + {formatCurrency(run.total_net)} - + + {formatCurrency(run.total_avgifter)} - - + + + {STATUS_LABELS[run.status]} - - + + + -
+ + )}
diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 34034c75..31d35a2d 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -7,7 +7,38 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AbsenceCalendar } from '@/components/salary/AbsenceCalendar' import { formatCurrency } from '@/lib/utils' -import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, Employee } from '@/types' +import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types' + +const LINE_ITEM_TYPE_LABELS: Record = { + monthly_salary: 'Månadslön', + hourly_salary: 'Timlön', + overtime: 'Övertid', + bonus: 'Bonus', + commission: 'Provision', + gross_deduction_pension: 'Bruttoavdrag — pension', + gross_deduction_other: 'Bruttoavdrag — övrigt', + benefit_car: 'Bilförmån', + benefit_housing: 'Bostadsförmån', + benefit_meals: 'Kostförmån', + benefit_wellness: 'Friskvård', + benefit_other: 'Övrig förmån', + sick_karens: 'Karensavdrag', + sick_day2_14: 'Sjuklön (dag 2–14, 80 %)', + sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)', + vab: 'VAB (vård av sjukt barn)', + parental_leave: 'Föräldraledighet', + vacation: 'Semester', + traktamente_taxfree: 'Traktamente (skattefritt)', + traktamente_taxable: 'Traktamente (skattepliktigt)', + mileage_taxfree: 'Milersättning (skattefritt)', + mileage_taxable: 'Milersättning (skattepliktigt)', + net_deduction_advance: 'Nettoavdrag — förskott', + net_deduction_union: 'Nettoavdrag — fackavgift', + net_deduction_benefit_payment: 'Nettoavdrag — förmånsbetalning', + net_deduction_other: 'Nettoavdrag — övrigt', + correction: 'Korrigering', + other: 'Övrigt', +} interface DetailResponse { run: SalaryRun @@ -95,7 +126,7 @@ export default function SalaryRunEmployeeDetailPage({ const readOnly = run.status !== 'draft' && run.status !== 'review' return ( -
+
{/* Header */}
) : ( - - - - - - + + + + + + {lineItems.map(li => ( - + diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 637f58fd..38c6098c 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -3,16 +3,19 @@ import { useState, useEffect, use } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen, ArrowLeftCircle, Loader2, Download, } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { formatCurrency } from '@/lib/utils' +import { formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types' import { AGIPanel } from '@/components/salary/AGIPanel' @@ -30,13 +33,13 @@ const STATUS_LABELS: Record = { corrected: 'Korrigerad', } -const STATUS_COLORS: Record = { - draft: 'bg-muted text-muted-foreground', - review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', - approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', - paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', - booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', - corrected: 'bg-muted text-muted-foreground', +const STATUS_VARIANTS: Record = { + draft: 'secondary', + review: 'warning', + approved: 'default', + paid: 'success', + booked: 'success', + corrected: 'secondary', } interface EntryPreview { @@ -113,7 +116,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } else { const result = await res.json() toast({ - title: 'Fel', + title: 'Kunde inte uppdatera status', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -134,7 +137,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } else { const result = await res.json() toast({ - title: 'Fel', + title: 'Kunde inte lägga till anställd', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -208,8 +211,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: if (loading) { return (
-
-
+ +
) } @@ -224,40 +227,42 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id)) return ( -
+
{/* Header */}
-

Lönekörning {periodLabel}

- Utbetalning: {run.payment_date} + Utbetalning: {formatDate(run.payment_date)}

- + {STATUS_LABELS[run.status]} - +
{/* Summary cards */} -
+
{[ { label: 'Brutto', value: run.total_gross }, { label: 'Skatt', value: run.total_tax }, - { label: 'Netto', value: run.total_net }, + { label: 'Netto', value: run.total_net, accent: true }, { label: 'Avgifter', value: run.total_avgifter }, { label: 'Total kostnad', value: run.total_employer_cost }, - ].map(({ label, value }) => ( + ].map(({ label, value, accent }) => ( - -

{label}

-

{formatCurrency(value)}

+ +

{label}

+

+ {formatCurrency(value)} +

))} @@ -292,30 +297,30 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: Inga anställda tillagda ännu

) : ( -
TypBeskrivningAntalBelopp
TypBeskrivningAntalBelopp
{li.item_type}{LINE_ITEM_TYPE_LABELS[li.item_type] ?? li.item_type} {li.description} {li.quantity ?? '—'} {formatCurrency(li.amount)}
- - - - - - - - - - - +
AnställdBruttoSkattNettoAvgifterSemester
+ + + Anställd + Brutto + Skatt + Netto + Avgifter + Semester + + + {employees.map(sre => { const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee const name = employee ? `${employee.first_name} ${employee.last_name}` : `Anställd ${sre.employee_id.slice(0, 8)}...` return ( - router.push(`/salary/runs/${id}/employees/${sre.employee_id}`)} > - - - - - - - + + Brutto {formatCurrency(sre.gross_salary)} + + + {formatCurrency(sre.gross_salary)} + {formatCurrency(sre.tax_withheld)} + {formatCurrency(sre.net_salary)} + {formatCurrency(sre.avgifter_amount)} + {formatCurrency(sre.vacation_accrual)} + ) })} - -
+ {name} - {formatCurrency(sre.gross_salary)}{formatCurrency(sre.tax_withheld)}{formatCurrency(sre.net_salary)}{formatCurrency(sre.avgifter_amount)}{formatCurrency(sre.vacation_accrual)}
+ + )} @@ -384,8 +392,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:

{entry!.description}

- - + + diff --git a/app/(dashboard)/salary/runs/new/page.tsx b/app/(dashboard)/salary/runs/new/page.tsx index 00c1b546..ae2b4f0d 100644 --- a/app/(dashboard)/salary/runs/new/page.tsx +++ b/app/(dashboard)/salary/runs/new/page.tsx @@ -46,7 +46,7 @@ export default function NewSalaryRunPage() { } else { const result = await res.json() toast({ - title: 'Fel', + title: 'Kunde inte skapa lönekörning', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -56,10 +56,10 @@ export default function NewSalaryRunPage() { } return ( -
+
-

Ny lönekörning

diff --git a/app/(dashboard)/settings/banking/page.tsx b/app/(dashboard)/settings/banking/page.tsx index 21243a49..976da378 100644 --- a/app/(dashboard)/settings/banking/page.tsx +++ b/app/(dashboard)/settings/banking/page.tsx @@ -112,7 +112,7 @@ export default function BankingSettingsPage() { }, [searchParams, router, toast]) return ( -
+
{bankConnectionError && (
diff --git a/app/(dashboard)/settings/salary/page.tsx b/app/(dashboard)/settings/salary/page.tsx index e79ab54c..6b10415a 100644 --- a/app/(dashboard)/settings/salary/page.tsx +++ b/app/(dashboard)/settings/salary/page.tsx @@ -1,16 +1,12 @@ 'use client' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { PageHeader } from '@/components/ui/page-header' export default function SalarySettingsPage() { return ( -
-
-

Löneinställningar

-

- Konfiguration för lönemodulen -

-
+
+ diff --git a/app/(dashboard)/settings/skatteverket/page.tsx b/app/(dashboard)/settings/skatteverket/page.tsx index 8ba6f21a..ddf425d9 100644 --- a/app/(dashboard)/settings/skatteverket/page.tsx +++ b/app/(dashboard)/settings/skatteverket/page.tsx @@ -33,7 +33,7 @@ export default function SkatteverketSettingsPage() { }, [searchParams, router, toast]) return ( -
+
) diff --git a/app/(dashboard)/settings/templates/page.tsx b/app/(dashboard)/settings/templates/page.tsx index 3ea33122..c80a8b6d 100644 --- a/app/(dashboard)/settings/templates/page.tsx +++ b/app/(dashboard)/settings/templates/page.tsx @@ -5,7 +5,7 @@ import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTe export default function TemplatesSettingsPage() { return ( -
+
diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 3a39faaf..0666d167 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { @@ -365,69 +366,65 @@ function TransactionTable({ } return ( -
-
Konto Beskrivning Debet
- - - - {showForfallodatum && } - - - - - - - - {rows.map(row => { - const negative = Number(row.belopp_skatteverket) < 0 - const isBooked = !!row.journal_entry_id - return ( - - - {showForfallodatum && ( - +
DatumFörfallodatumBeskrivningBeloppStatus
{row.transaktionsdatum}{row.forfallodatum ?? '–'}
+ + + Datum + {showForfallodatum && Förfallodatum} + Beskrivning + Belopp + Status + + + + + {rows.map(row => { + const negative = Number(row.belopp_skatteverket) < 0 + const isBooked = !!row.journal_entry_id + return ( + + {row.transaktionsdatum} + {showForfallodatum && ( + {row.forfallodatum ?? '–'} + )} + {row.transaktionstext} + + {formatCurrency(Number(row.belopp_skatteverket))} + + + {isBooked ? ( + + + Bokförd + + ) : ( + Ej bokförd )} - - - - - - ) - })} - -
{row.transaktionstext} - {formatCurrency(Number(row.belopp_skatteverket))} - - {isBooked ? ( - - - Bokförd - - ) : ( - Ej bokförd - )} - - {isBooked ? ( - - ) : ( - - )} -
-
+ + + {isBooked ? ( + + ) : ( + + )} + + + ) + })} + + ) } diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 9c7adef2..d085bcda 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useParams, useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' +import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' @@ -12,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatDate } from '@/lib/utils' import Link from 'next/link' import { AccountNumber } from '@/components/ui/account-number' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' @@ -21,15 +23,15 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -const statusColors: Record = { - registered: 'bg-blue-100 text-blue-800', - approved: 'bg-yellow-100 text-yellow-800', - paid: 'bg-success/10 text-success', - partially_paid: 'bg-orange-100 text-orange-800', - overdue: 'bg-destructive/10 text-destructive', - disputed: 'bg-purple-100 text-purple-800', - credited: 'bg-gray-100 text-gray-800', - reversed: 'bg-gray-100 text-gray-500', +const statusVariants: Record = { + registered: 'secondary', + approved: 'default', + paid: 'success', + partially_paid: 'warning', + overdue: 'destructive', + disputed: 'destructive', + credited: 'secondary', + reversed: 'secondary', } const statusLabels: Record = { @@ -177,7 +179,7 @@ export default function SupplierInvoiceDetailPage() { if (isLoading) { return (
-
+
) @@ -202,7 +204,7 @@ export default function SupplierInvoiceDetailPage() { {/* Header */}
-
@@ -210,7 +212,7 @@ export default function SupplierInvoiceDetailPage() {

Ankomst #{invoice.arrival_number}

- + {statusLabels[invoice.status] || invoice.status}
@@ -321,11 +323,11 @@ export default function SupplierInvoiceDetailPage() {
Fakturadatum - {invoice.invoice_date} + {formatDate(invoice.invoice_date)}
Förfallodatum - {invoice.due_date} + {formatDate(invoice.due_date)}
{invoice.delivery_date && (
@@ -341,7 +343,7 @@ export default function SupplierInvoiceDetailPage() { )} {invoice.reverse_charge && (
- Omvänd skattskyldighet + Omvänd skattskyldighet
)} @@ -403,8 +405,8 @@ export default function SupplierInvoiceDetailPage() { {/* Desktop table */}
- - + + @@ -460,8 +462,8 @@ export default function SupplierInvoiceDetailPage() { {/* Desktop table */}
Beskrivning Antal Enhet
- - + + @@ -471,7 +473,7 @@ export default function SupplierInvoiceDetailPage() { {payments.map((p) => ( - +
Datum Belopp Verifikation
{p.payment_date}{formatDate(p.payment_date)} {formatAmount(p.amount)} {p.currency} {p.journal_entry_id ? ( @@ -491,7 +493,7 @@ export default function SupplierInvoiceDetailPage() { {payments.map((p) => (
- {p.payment_date} + {formatDate(p.payment_date)} {formatAmount(p.amount)} {p.currency}
diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 1fd37960..730b4676 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -55,6 +55,10 @@ interface NewSupplierForm { default_expense_account: string } +function RequiredMark() { + return +} + function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } @@ -741,7 +745,7 @@ export default function NewSupplierInvoicePage() { return (
-
@@ -794,7 +798,7 @@ export default function NewSupplierInvoicePage() {
- +
- + {(() => { const { ref: rhfRef, ...rest } = register('supplier_invoice_number') return ( @@ -843,11 +847,11 @@ export default function NewSupplierInvoicePage() {
- +
- +
@@ -879,8 +883,8 @@ export default function NewSupplierInvoicePage() { {/* Desktop table */}
- - + + @@ -916,7 +920,9 @@ export default function NewSupplierInvoicePage() { field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} /> @@ -965,7 +971,7 @@ export default function NewSupplierInvoicePage() {
Rad {index + 1} {fields.length > 1 && ( - )} @@ -994,7 +1000,9 @@ export default function NewSupplierInvoicePage() { field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} /> @@ -1109,7 +1117,7 @@ export default function NewSupplierInvoicePage() { {watchedCurrency !== 'SEK' && (
- +
)}
@@ -1234,7 +1242,7 @@ export default function NewSupplierInvoicePage() {
- + -
-
-

Leverantörsfakturor

-

- Registrera och hantera inkommande fakturor -

-
- {canWrite ? ( - - + + ) : ( + - - ) : ( - - )} -
+ ) + } + /> {/* Tabs */} @@ -106,84 +108,80 @@ export default function SupplierInvoicesPage() {
-
+
{[1, 2, 3, 4].map((i) => (
-
-
-
-
-
-
-
+ + + + + + +
))} ) : filteredInvoices.length === 0 ? ( - - -

Inga fakturor

-

- {activeTab === 'all' - ? 'Registrera din första leverantörsfaktura' - : 'Inga fakturor i denna kategori'} -

- {activeTab === 'all' && canWrite && ( - - - - )} + +
) : ( -
Konto Beskrivning Belopp (exkl.)
- - - - - - - - - - - - - +
AnkomstLeverantörFakturanrFakturadatumFörfallerBeloppKvar att betalaStatus
+ + + Ankomst + Leverantör + Fakturanr + Fakturadatum + Förfaller + Belopp + Kvar att betala + Status + + + {filteredInvoices.map((inv) => ( - - - - - - - - - - + + ))} - -
{inv.arrival_number} + + {inv.arrival_number} + {inv.supplier?.name || '-'} - + + {inv.supplier_invoice_number} - {inv.invoice_date}{inv.due_date}{formatAmount(inv.total)}{formatAmount(inv.remaining_amount)} + + {formatDate(inv.invoice_date)} + {formatDate(inv.due_date)} + {formatAmount(inv.total)} + {formatAmount(inv.remaining_amount)} + {statusLabels[inv.status] || inv.status} -
+ +
)} diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index f8f4b219..43d8e3ef 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -3,13 +3,16 @@ import { useState, useEffect } from 'react' import { useParams, useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' +import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, Edit, Trash2, FileText, Lock } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatDate } from '@/lib/utils' import SupplierForm from '@/components/suppliers/SupplierForm' import Link from 'next/link' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' @@ -101,8 +104,8 @@ export default function SupplierDetailPage() { if (isLoading) { return ( -
-
+
+ @@ -121,13 +124,13 @@ export default function SupplierDetailPage() { ) } - const statusColors: Record = { - registered: 'bg-blue-100 text-blue-800', - approved: 'bg-yellow-100 text-yellow-800', - paid: 'bg-success/10 text-success', - partially_paid: 'bg-orange-100 text-orange-800', - overdue: 'bg-destructive/10 text-destructive', - credited: 'bg-gray-100 text-gray-800', + const statusVariants: Record = { + registered: 'secondary', + approved: 'default', + paid: 'success', + partially_paid: 'warning', + overdue: 'destructive', + credited: 'secondary', } const statusLabels: Record = { @@ -140,10 +143,10 @@ export default function SupplierDetailPage() { } return ( -
+
-
@@ -254,40 +257,40 @@ export default function SupplierDetailPage() { <> {/* Desktop table */}
- - - - - - - - - - - - - +
AnkomstFakturanrDatumFörfallerBeloppKvarStatus
+ + + Ankomst + Fakturanr + Datum + Förfaller + Belopp + Kvar + Status + + + {invoices.map((inv) => ( - - - - - - - - - + + ))} - -
{inv.arrival_number} + + {inv.arrival_number} + {inv.supplier_invoice_number} - {inv.invoice_date}{inv.due_date}{formatAmount(inv.total)} kr{formatAmount(inv.remaining_amount)} kr - + + {formatDate(inv.invoice_date)} + {formatDate(inv.due_date)} + {formatAmount(inv.total)} kr + {formatAmount(inv.remaining_amount)} kr + + {statusLabels[inv.status] || inv.status} -
+ +
{/* Mobile cards */}
@@ -297,12 +300,12 @@ export default function SupplierDetailPage() { {inv.supplier_invoice_number} - + {statusLabels[inv.status] || inv.status}
- {inv.invoice_date} → {inv.due_date} + {formatDate(inv.invoice_date)} → {formatDate(inv.due_date)} {formatAmount(inv.total)} kr
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && ( diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index f3459573..b4917fa5 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { EmptyState } from '@/components/ui/empty-state' import { useToast } from '@/components/ui/use-toast' import { Plus, Search, Building2, Lock } from 'lucide-react' import SupplierForm from '@/components/suppliers/SupplierForm' @@ -107,7 +108,7 @@ export default function SuppliersPage() { ) return ( -
+

Leverantörer

@@ -157,7 +158,7 @@ export default function SuppliersPage() {
{[1, 2, 3].map((i) => ( - +
@@ -169,36 +170,21 @@ export default function SuppliersPage() {
) : filteredSuppliers.length === 0 ? ( - + {searchTerm ? ( -
- -

Inga träffar

-

- Inga leverantörer matchar "{searchTerm}" -

-
+ ) : ( -
- -

Inga leverantörer

-

- Lägg till din första leverantör för att börja registrera inköpsfakturor -

- -
+ setIsDialogOpen(true) : undefined} + /> )}
@@ -210,7 +196,7 @@ export default function SuppliersPage() { return ( - +

{supplier.name} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 73ed3711..f0198632 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -339,7 +339,6 @@ export default function TransactionsPage() { if (result.journal_entry_created) { toast({ title: 'Bokförd', - description: 'Transaktion bokförd och verifikation skapad', action: ( { try { @@ -674,7 +673,7 @@ export default function TransactionsPage() { }, 350) setBookingDialogOpen(false) setBookingDialogTransaction(null) - toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) + toast({ title: 'Bokförd' }) } // Batch mode handlers @@ -889,7 +888,7 @@ export default function TransactionsPage() { {/* Content based on mode */} {isLoading ? ( -
+
{[1, 2, 3].map((i) => ( @@ -911,7 +910,7 @@ export default function TransactionsPage() { onCreateTransaction={() => setIsDialogOpen(true)} /> ) : ( -
+
{uncategorizedTransactions.map((transaction) => ( )} diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 3352c427..4cdc7be1 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -28,16 +28,30 @@ export async function GET(request: Request) { const dateFrom = searchParams.get('date_from') const dateTo = searchParams.get('date_to') const sortDate = searchParams.get('sort_date') // 'asc' | 'desc' + // 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc' + // sort_by overrides sort_date when present. sort_date is kept for backwards + // compatibility with older clients. + const sortBy = searchParams.get('sort_by') + const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc' // Default on: when a fiscal period is selected, include follow-up entries // booked in later periods whose source aggregate (invoice, supplier invoice) // is dated inside the selected period. Pass include_related=false to // restore strict fiscal_period_id filtering. const includeRelated = searchParams.get('include_related') !== 'false' - const dateAscending = sortDate === 'asc' - const sortDateParam = sortDate === 'asc' || sortDate === 'desc' ? sortDate : 'desc' + const dateAscending = sortDate === 'asc' || sortBy === 'date_asc' + const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc' - if (periodId && includeRelated) { + // Voucher-sort path: include_related RPC doesn't support voucher ordering, + // so fall through to the direct query below. This means voucher sort is + // *strict by fiscal_period_id* — cross-period follow-up entries that the + // RPC normally surfaces under date sort are excluded under voucher sort. + // That's intentional: voucher numbers are series-scoped within a fiscal + // year (BFL 5 kap 6–7 §§), so showing series A1, A2 … alongside entries + // belonging to a different year's series would be misleading. The trade-off + // is that the visible row count may differ between sort modes for the same + // period; the strict count is the BFL-compliant view of that year. + if (periodId && includeRelated && !isVoucherSort) { const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', { p_company_id: companyId, p_period_id: periodId, @@ -66,7 +80,12 @@ export async function GET(request: Request) { .select('*, lines:journal_entry_lines(*)', { count: 'exact' }) .eq('company_id', companyId) - if (sortDate === 'asc' || sortDate === 'desc') { + if (isVoucherSort) { + const voucherAscending = sortBy === 'voucher_asc' + query = query + .order('voucher_series', { ascending: voucherAscending }) + .order('voucher_number', { ascending: voucherAscending }) + } else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') { query = query .order('entry_date', { ascending: dateAscending }) .order('voucher_number', { ascending: dateAscending }) diff --git a/app/globals.css b/app/globals.css index a3e6c6d9..2d8e9860 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,13 @@ @plugin "@tailwindcss/typography"; @custom-variant dark (&:where(.dark, .dark *)); +/* ───────────────────────────────────────────────────────────────────────── + * Spacing scale — locked. + * Card padding: p-6. Section gap: space-y-8. Form/control gap: gap-4. + * Allowed Tailwind values: 1, 2, 3, 4, 6, 8, 10, 12. No 2.5 / 5. + * Sidebar width is md:w-64 (256px); main offset matches md:pl-64. + * ───────────────────────────────────────────────────────────────────────── */ + :root { color-scheme: light; /* Grayscale Chrome Palette */ diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index cdac7c9f..c8fd5268 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -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) } diff --git a/components/bookkeeping/ChartOfAccounts.tsx b/components/bookkeeping/ChartOfAccounts.tsx index a4913d0c..cadbff8b 100644 --- a/components/bookkeeping/ChartOfAccounts.tsx +++ b/components/bookkeeping/ChartOfAccounts.tsx @@ -134,8 +134,8 @@ export default function ChartOfAccounts() { {isExpanded && ( - - + + diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index 30ef1756..c14717e4 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -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 && (
Konto Namn SRU
- - + + @@ -497,8 +496,8 @@ export default function ChartOfAccountsManager() { {isExpanded && (
Konto Namn SRU
- - + + diff --git a/components/bookkeeping/CorrectionChain.tsx b/components/bookkeeping/CorrectionChain.tsx index 268c006e..70c79736 100644 --- a/components/bookkeeping/CorrectionChain.tsx +++ b/components/bookkeeping/CorrectionChain.tsx @@ -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) { {entry.voucher_series}{entry.voucher_number} - {entry.entry_date} + {formatDate(entry.entry_date)} {isCurrent && ( diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index b7a434f7..aa7629c3 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -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
{entry.voucher_series}{entry.voucher_number} - {entry.entry_date} + {formatDate(entry.entry_date)} Original

{entry.description}

Konto Namn SRU
- - + + diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index d0e64c84..5519ed98 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -633,8 +633,8 @@ export default function JournalEntryForm({ {/* Entry lines — desktop table */}
Konto Beskrivning Debet
- - + + diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index dca9cdb0..7e95b602 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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>({}) const [showMissingOnly, setShowMissingOnly] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(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) { )} - - +
{entry.voucher_series}{entry.voucher_number} - - {entry.entry_date} + + {formatDate(entry.entry_date)} {entry.out_of_period && ( )} - {(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( - + {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( + )} {entry.description} {attachmentCounts[entry.id] ? ( @@ -352,8 +340,8 @@ export default function JournalEntryList({ periodId }: Props) { > {entry.voucher_series}{entry.voucher_number} - - {entry.entry_date} + + {formatDate(entry.entry_date)} {entry.out_of_period && ( )} + {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( + + )} {attachmentCounts[entry.id] ? ( diff --git a/components/bookkeeping/JournalEntryReviewContent.tsx b/components/bookkeeping/JournalEntryReviewContent.tsx index 4d92c004..0eb87ee0 100644 --- a/components/bookkeeping/JournalEntryReviewContent.tsx +++ b/components/bookkeeping/JournalEntryReviewContent.tsx @@ -101,8 +101,8 @@ export function JournalEntryReviewContent({ {/* Debit/Credit — table on desktop, cards on mobile */}
Konto Beskrivning Debet
- - + + diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 687ca3fe..2de7f6af 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -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 { diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 6b33251e..6bce1667 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -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 ( -
- {/* Header */} -
-

- {greeting}{firstName ? `, ${firstName}` : ''} -

-
- - {/* 4 Key Summary Cards */} -
-
- {/* Card 1: Resultat */} +
+ {/* Key metrics — 4 compact cards */} +
+

Resultat

@@ -276,7 +240,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
- {/* Card 2: Att få betalt */} @@ -295,7 +258,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa - {/* Card 3: Banksaldo */} {summary.bankBalance !== null ? ( @@ -315,27 +277,20 @@ export default function DashboardContent({ firstName, companyId, settings, summa

Koppla bank

-

Importera transaktioner

)} - {/* Card 4: Att göra */}

Att göra

{todoCount > 0 ? ( - <> -

- {todoCount} - st -

-

- Behöver åtgärdas -

- +

+ {todoCount} + st +

) : (
@@ -348,78 +303,46 @@ export default function DashboardContent({ firstName, companyId, settings, summa
- {/* Quick actions */} -
-

Snabbåtgärder

-
- {quickActions.map((action) => { - const Icon = action.icon - return ( - -
-
-

- - {action.label} -

-

{action.desc}

-
-
- - ) - })} - {/* Extension quick actions */} - {extensionQuickActions.map((action) => { - const Icon = resolveIcon(action.icon) - if (action.href) { - return ( - -
-
-

- - {action.label} -

-

{action.description}

-
-
- - ) - } - return ( - - ) - })} + {/* Resultat — intäkter / kostnader (always visible) */} +
+
+ + +

Intäkter

+

+ {formatLargeNumber(summary.mtd.income)} + kr +

+

denna månad

+
+

I år

+

{formatCurrency(summary.ytd.income)}

+
+
+
+ + + +

Kostnader

+

+ {formatLargeNumber(summary.mtd.expenses)} + kr +

+

denna månad

+
+

I år

+

{formatCurrency(summary.ytd.expenses)}

+
+
+
- {/* Alerts section */} + {/* Att hantera */} {alertItems.length > 0 && ( -
+

Att hantera

-
+
{visibleAlerts}
{hasMoreAlerts && ( @@ -436,104 +359,19 @@ export default function DashboardContent({ firstName, companyId, settings, summa
)} - {/* Upcoming deadlines — always visible */} + {/* Upcoming deadlines */} {summary.deadlines && summary.deadlines.length > 0 && ( -
+
)} - {/* Tax todo widget — visible when there are incomplete tax deadlines */} + {/* Tax todo */} {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && ( -
+
)} - - {/* Collapsible details section */} - - - {showMore && ( -
- {/* Uncategorized transactions warning */} - {summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && ( -
- -
- -
-

- {summary.uncategorizedCount} obokförda transaktioner -

-

- {summary.uncategorizedIncome > 0 && ( - {formatCurrency(summary.uncategorizedIncome)} intäkter - )} - {summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '} - {summary.uncategorizedExpenses > 0 && ( - {formatCurrency(summary.uncategorizedExpenses)} kostnader - )} - {' '}saknas i resultatet -

-
-
- -
- )} - - {/* Income/Expenses */} -
-

Resultat

-
- - -

Intäkter

-
-

- {formatLargeNumber(summary.mtd.income)} - kr -

-

denna månad

-
-
-
-

I år

-

{formatCurrency(summary.ytd.income)}

-
-
-
-
- - - -

Kostnader

-
-

- {formatLargeNumber(summary.mtd.expenses)} - kr -

-

denna månad

-
-
-
-

I år

-

{formatCurrency(summary.ytd.expenses)}

-
-
-
-
-
-
-
- )}
) } diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 2b3a6d25..b694e0c3 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -187,7 +187,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un return ( <> {/* Desktop sidebar */} -
Konto Beskrivning Debet
- - - - - - - + + + + + + + diff --git a/components/import/OpeningBalanceColumnMappingStep.tsx b/components/import/OpeningBalanceColumnMappingStep.tsx index f7ea85ad..b70b94c9 100644 --- a/components/import/OpeningBalanceColumnMappingStep.tsx +++ b/components/import/OpeningBalanceColumnMappingStep.tsx @@ -197,10 +197,10 @@ export default function OpeningBalanceColumnMappingStep({
NamnKundtypOrgnrE-postStatus
NamnKundtypOrgnrE-postStatus
- - + + {headers.map((h, i) => ( - ))} diff --git a/components/import/OpeningBalanceEditStep.tsx b/components/import/OpeningBalanceEditStep.tsx index fb992144..9b7e4076 100644 --- a/components/import/OpeningBalanceEditStep.tsx +++ b/components/import/OpeningBalanceEditStep.tsx @@ -199,12 +199,12 @@ export default function OpeningBalanceEditStep({ {/* Table */}
+ {h || `Kolumn ${i + 1}`}
- - - - - - + + + + + + diff --git a/components/import/RegisterColumnMappingStep.tsx b/components/import/RegisterColumnMappingStep.tsx index f5581868..f59be799 100644 --- a/components/import/RegisterColumnMappingStep.tsx +++ b/components/import/RegisterColumnMappingStep.tsx @@ -88,10 +88,10 @@ export default function RegisterColumnMappingStep({
KontoKontonamnDebetKredit
KontoKontonamnDebetKredit
- - + + {headers.map((h, i) => ( - ))} diff --git a/components/import/SuppliersEditStep.tsx b/components/import/SuppliersEditStep.tsx index b5700dbc..540ecc77 100644 --- a/components/import/SuppliersEditStep.tsx +++ b/components/import/SuppliersEditStep.tsx @@ -111,13 +111,13 @@ export default function SuppliersEditStep({
+ {h || `Kolumn ${i + 1}`}
- - - - - - - + + + + + + + diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index 9183eb3d..3cca466b 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -101,8 +101,8 @@ export function InvoiceReviewContent({ {/* Line items — table on desktop, cards on mobile */}
NamnTypOrgnrBankgiro/IBANStatus
NamnTypOrgnrBankgiro/IBANStatus
- - + + diff --git a/components/kpi/KPIHeroCards.tsx b/components/kpi/KPIHeroCards.tsx index ad4d90e2..7f904bbd 100644 --- a/components/kpi/KPIHeroCards.tsx +++ b/components/kpi/KPIHeroCards.tsx @@ -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(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 ( - - -
-

- {def.label} -

- + const tooltipContent = ( +
+

{def.description}

+
+ Formel: + {def.formula} +
+
+ Konton: + {def.accountDescription} +
+ {hasOverride && ( +
+ Anpassade: + + {prefs.accountOverrides[def.id].join(', ')} +
+ )} +
+ ) - {showInfo ? ( -
-

{def.description}

-
- Formel: - {def.formula} -
-
- Konton: - {def.accountDescription} -
- {hasOverride && ( -
- Anpassade konton: - - {prefs.accountOverrides[def.id].join(', ')} - -
- )} -
- ) : ( - <> -

- {formatted} -

-

- {subtitle} -

- - )} + return ( + + + +

{def.label}

+
+

+ {formatted} +

+

+ {subtitle} +

) diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx index b46236bf..94c746d3 100644 --- a/components/onboarding/BankIdCompanyPicker.tsx +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -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 } diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index e21aa7bc..379017cb 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -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() {
Beskrivning Antal Enhet
- - + + @@ -383,14 +383,14 @@ export function BankReconciliationView() { {dryRunResults.map((m) => ( - + - +
Transaktion Datum Belopp
{m.transaction_description}{m.transaction_date}{formatDate(m.transaction_date)} {formatAmount(m.transaction_amount)} {m.voucher_series}{m.voucher_number} {m.entry_description} {m.entry_date}{formatDate(m.entry_date)} {METHOD_LABELS[m.method] || m.method} @@ -422,8 +422,8 @@ export function BankReconciliationView() { - - + + @@ -454,7 +454,7 @@ export function BankReconciliationView() { const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount return ( ) })} @@ -489,8 +489,8 @@ export function BankReconciliationView() {
Datum Beskrivning Belopp
- - + + @@ -506,7 +506,7 @@ export function BankReconciliationView() { - + @@ -544,8 +544,8 @@ export function BankReconciliationView() { {showMatched && (
Ver.nr Datum Beskrivning {line.voucher_series}{line.voucher_number} {line.entry_date}{formatDate(line.entry_date)} {line.line_description || line.entry_description}
- - + + diff --git a/components/reports/ReportsNav.tsx b/components/reports/ReportsNav.tsx new file mode 100644 index 00000000..00e11df6 --- /dev/null +++ b/components/reports/ReportsNav.tsx @@ -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 */} +
+ +
+ + {/* Desktop: vertical left rail */} + + + ) +} diff --git a/components/salary/TaxPaymentPanel.tsx b/components/salary/TaxPaymentPanel.tsx index 612aaa86..220611c1 100644 --- a/components/salary/TaxPaymentPanel.tsx +++ b/components/salary/TaxPaymentPanel.tsx @@ -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', }) diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index 162c68b8..6b13fa70 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -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' }) } } diff --git a/components/settings/BankIdSettings.tsx b/components/settings/BankIdSettings.tsx index 18e1c177..dc5b7148 100644 --- a/components/settings/BankIdSettings.tsx +++ b/components/settings/BankIdSettings.tsx @@ -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} - Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')} + Kopplat {formatDateLong(identity.linked_at)}
Datum Beskrivning Belopp
- - + + @@ -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' }) diff --git a/components/settings/CalendarFeedSettings.tsx b/components/settings/CalendarFeedSettings.tsx index eae0696e..8eadf8a5 100644 --- a/components/settings/CalendarFeedSettings.tsx +++ b/components/settings/CalendarFeedSettings.tsx @@ -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', }) } diff --git a/components/settings/CompanyMembersSection.tsx b/components/settings/CompanyMembersSection.tsx index b430d972..cc750b75 100644 --- a/components/settings/CompanyMembersSection.tsx +++ b/components/settings/CompanyMembersSection.tsx @@ -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() {
- Går ut {new Date(inv.expires_at).toLocaleDateString('sv-SE')} + Går ut {formatDateLong(inv.expires_at)}
diff --git a/components/settings/CounterpartyTemplatesPanel.tsx b/components/settings/CounterpartyTemplatesPanel.tsx index 291572cd..4117f3a2 100644 --- a/components/settings/CounterpartyTemplatesPanel.tsx +++ b/components/settings/CounterpartyTemplatesPanel.tsx @@ -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) } diff --git a/components/settings/SettingsLoadingSkeleton.tsx b/components/settings/SettingsLoadingSkeleton.tsx index 0773156a..17cb9ce8 100644 --- a/components/settings/SettingsLoadingSkeleton.tsx +++ b/components/settings/SettingsLoadingSkeleton.tsx @@ -1,22 +1,24 @@ +import { Skeleton } from '@/components/ui/skeleton' + export function SettingsLoadingSkeleton() { return (
{[1, 2].map(i => (
-
+
-
-
+ +
-
-
+ +
-
-
+ +
))} diff --git a/components/settings/VoucherSeriesManager.tsx b/components/settings/VoucherSeriesManager.tsx index 24bf2398..c232bded 100644 --- a/components/settings/VoucherSeriesManager.tsx +++ b/components/settings/VoucherSeriesManager.tsx @@ -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 ? (
-
-
+ +
) : seriesEntries.length === 0 ? (

diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx index 6bc47257..99de6120 100644 --- a/components/suppliers/SupplierInvoiceReviewContent.tsx +++ b/components/suppliers/SupplierInvoiceReviewContent.tsx @@ -204,8 +204,8 @@ export function SupplierInvoiceReviewContent({ {/* Line items — table on desktop, cards on mobile */}

Konto Beskrivning Typ
- - + + @@ -277,8 +277,8 @@ export function SupplierInvoiceReviewContent({

Verifikation som bokförs

Konto Beskrivning Belopp
- - + + diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 8a421bb7..4b8e86e0 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -244,9 +244,9 @@ export default function TransactionInboxCard({ {/* Delete button — available for all unbooked transactions */} {isDeletable && onDelete && (
Konto Beskrivning Debet(({ className, ...props }, ref) => ( )) diff --git a/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts b/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts index a0fd28cc..50ae8a7a 100644 --- a/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts +++ b/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts @@ -237,4 +237,57 @@ describe('delete_last_voucher.pg — RPC + immutability trigger interaction', () ), ).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/) }) + + // Drafts (voucher_number=0, never committed) can arise as orphans when a + // mark-paid or similar engine flow fails between draft creation and commit. + // They are not part of the verifikationsserie under BFL and must be + // deletable so users can clean up their books. + it('deletes a draft entry without touching the voucher series', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const draftId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, + }) + await insertBalancedLines(draftId) + + await withUserContext(userId, async (client) => { + const result = await client.query<{ delete_last_voucher: { deleted: boolean; was_draft: boolean } }>( + `SELECT public.delete_last_voucher($1::uuid, $2::uuid)`, + [companyId, draftId], + ) + expect(result.rows[0]!.delete_last_voucher.deleted).toBe(true) + expect(result.rows[0]!.delete_last_voucher.was_draft).toBe(true) + + const after = await client.query( + `SELECT 1 FROM public.journal_entries WHERE id = $1`, + [draftId], + ) + expect(after.rowCount).toBe(0) + }) + }) + + it('deletes a draft even when the fiscal period is locked', async () => { + // Drafts are not bokförda — period locks (which protect committed entries) + // do not need to block draft cleanup. + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const draftId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, + }) + await insertBalancedLines(draftId) + await getPool().query( + `UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, + [fiscalPeriodId], + ) + + await withUserContext(userId, async (client) => { + await client.query( + `SELECT public.delete_last_voucher($1::uuid, $2::uuid)`, + [companyId, draftId], + ) + const after = await client.query( + `SELECT 1 FROM public.journal_entries WHERE id = $1`, + [draftId], + ) + expect(after.rowCount).toBe(0) + }) + }) }) diff --git a/lib/utils.ts b/lib/utils.ts index 9655bbfc..c452cb77 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import { format as formatDateFns } from "date-fns" +import { format as formatDateFns, parseISO } from "date-fns" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -16,10 +16,27 @@ export function formatCurrency(amount: number, currency: string = 'SEK'): string } export function formatDate(date: Date | string): string { - const d = typeof date === 'string' ? new Date(date) : date + // parseISO interprets bare 'yyyy-MM-dd' as local midnight, not UTC midnight. + // Using new Date() would shift the displayed day by one in timezones west of + // UTC for bare date strings — that's an off-by-one we don't want for + // accounting data. + const d = typeof date === 'string' ? parseISO(date) : date return formatDateFns(d, 'yyyy-MM-dd') } +/** + * Long-form Swedish date for metadata/audit contexts (e.g. "9 maj 2026"). + * Use formatDate for transaction/voucher/invoice dates that need to align in tables. + */ +export function formatDateLong(date: Date | string): string { + const d = typeof date === 'string' ? parseISO(date) : date + return d.toLocaleDateString('sv-SE', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + export function formatOrgNumber(orgNumber: string): string { // Format Swedish org number: XXXXXX-XXXX const cleaned = orgNumber.replace(/\D/g, '') diff --git a/supabase/migrations/20260509103736_allow_draft_voucher_delete.sql b/supabase/migrations/20260509103736_allow_draft_voucher_delete.sql new file mode 100644 index 00000000..7dc2be6a --- /dev/null +++ b/supabase/migrations/20260509103736_allow_draft_voucher_delete.sql @@ -0,0 +1,186 @@ +-- Allow delete_last_voucher RPC to delete draft entries. +-- +-- Background: createJournalEntry does createDraft + commit atomically. If +-- commit fails the orphan draft is now cancelled (commit 28df5d85), but +-- legacy drafts created before that fix remain stuck — the previous RPC +-- only accepted status='posted'. There is no UI path to remove them. +-- +-- Drafts are not part of the verifikationsserie (voucher_number = 0, +-- never assigned a sequence number) and are not bokförda under BFL. +-- Deletion of a draft does not affect the audit trail and does not +-- require the same protections as posted entries. +-- +-- For drafts we skip: +-- * last-in-series check (drafts have no number) +-- * voucher_sequences row update (no number was issued) +-- * reverses_id un-reversal (drafts cannot be reversed) +-- * fiscal-period closed/locked check (drafts are not posted) +-- +-- We keep: +-- * owner/admin role gate +-- * audit_log entry +-- * gnubok.allow_delete flag so the line-immutability trigger lets +-- the cascade delete its rows +-- +-- For posted entries the existing logic is unchanged. + +CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_entry record; + v_period record; + v_max_voucher integer; + v_ref_count integer; + v_caller_role text; + v_snapshot jsonb; + v_lines_snapshot jsonb; +BEGIN + SELECT cm.role INTO v_caller_role + FROM company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = auth.uid(); + + IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only company owners and admins can delete vouchers'; + END IF; + + SELECT * INTO v_entry + FROM journal_entries + WHERE id = p_entry_id + AND company_id = p_company_id + FOR UPDATE; + + IF v_entry IS NULL THEN + RAISE EXCEPTION 'Journal entry not found'; + END IF; + + IF v_entry.status NOT IN ('posted', 'draft') THEN + RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status; + END IF; + + -- Snapshot for audit log (same shape for draft and posted) + SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot + FROM journal_entry_lines l + WHERE l.journal_entry_id = p_entry_id; + + v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb)); + + -- Draft path: simplified deletion (no series, no period checks needed) + IF v_entry.status = 'draft' THEN + PERFORM set_config('gnubok.allow_delete', 'true', true); + + UPDATE document_attachments + SET journal_entry_id = NULL + WHERE journal_entry_id = p_entry_id; + + DELETE FROM journal_entries WHERE id = p_entry_id; + + INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description) + VALUES ( + v_entry.user_id, + 'DELETE', + 'journal_entries', + p_entry_id, + auth.uid(), + v_snapshot, + 'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')' + ); + + RETURN jsonb_build_object( + 'deleted', true, + 'voucher_series', v_entry.voucher_series, + 'voucher_number', v_entry.voucher_number, + 'was_draft', true + ); + END IF; + + -- Posted path: existing logic unchanged + SELECT * INTO v_period + FROM fiscal_periods + WHERE id = v_entry.fiscal_period_id + FOR UPDATE; + + IF v_period.is_closed THEN + RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period'; + END IF; + + IF v_period.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period'; + END IF; + + PERFORM 1 FROM voucher_sequences + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series + FOR UPDATE; + + SELECT MAX(voucher_number) INTO v_max_voucher + FROM journal_entries + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series + AND status NOT IN ('cancelled', 'draft'); + + IF v_entry.voucher_number != v_max_voucher THEN + RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %', + v_entry.voucher_series, v_entry.voucher_number, v_max_voucher; + END IF; + + SELECT COUNT(*) INTO v_ref_count + FROM journal_entries + WHERE company_id = p_company_id + AND status != 'cancelled' + AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id); + + IF v_ref_count > 0 THEN + RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)', + v_ref_count; + END IF; + + IF v_entry.reverses_id IS NOT NULL THEN + PERFORM set_config('gnubok.allow_delete', 'true', true); + UPDATE journal_entries + SET status = 'posted', reversed_by_id = NULL + WHERE id = v_entry.reverses_id + AND company_id = p_company_id; + END IF; + + PERFORM set_config('gnubok.allow_delete', 'true', true); + + UPDATE document_attachments + SET journal_entry_id = NULL + WHERE journal_entry_id = p_entry_id; + + DELETE FROM journal_entries WHERE id = p_entry_id; + + UPDATE voucher_sequences + SET last_number = GREATEST(last_number - 1, 0) + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series; + + INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description) + VALUES ( + v_entry.user_id, + 'DELETE', + 'journal_entries', + p_entry_id, + auth.uid(), + v_snapshot, + 'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number || + ' (delete_last_voucher RPC, caller: ' || auth.uid() || ')' + ); + + RETURN jsonb_build_object( + 'deleted', true, + 'voucher_series', v_entry.voucher_series, + 'voucher_number', v_entry.voucher_number + ); +END; +$function$; + +NOTIFY pgrst, 'reload schema';