feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements

Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 23:05:49 +01:00
co-authored by Claude Opus 4.6
parent 02ebe50f52
commit 091d043c85
106 changed files with 1177 additions and 326 deletions
+9 -3
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
import Image from 'next/image'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
@@ -100,9 +101,14 @@ export default function LoginPage() {
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<h1 className="font-display text-[2.75rem] leading-none font-medium tracking-tight text-foreground">
Gnubok
</h1>
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={240}
height={240}
className="mx-auto mb-2"
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Logga in med din e-post för att hantera din ekonomi
</p>
+5 -5
View File
@@ -47,10 +47,6 @@ export default function CustomersPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchCustomers()
}, [])
async function fetchCustomers() {
setIsLoading(true)
const { data, error } = await supabase
@@ -70,6 +66,10 @@ export default function CustomersPage() {
setIsLoading(false)
}
useEffect(() => {
fetchCustomers()
}, [])
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreating(true)
@@ -167,7 +167,7 @@ export default function CustomersPage() {
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga kunder matchar "{searchTerm}"
Inga kunder matchar &quot;{searchTerm}&quot;
</p>
</div>
) : (
+6 -7
View File
@@ -13,7 +13,6 @@ import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-rea
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
const statusConfig: Record<string, { label: string; color: string }> = {
@@ -35,18 +34,13 @@ export default function ExpenseDetailPage() {
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [, setEntityType] = useState<EntityType>('enskild_firma')
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
@@ -72,6 +66,11 @@ export default function ExpenseDetailPage() {
}
}
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
+4 -4
View File
@@ -67,10 +67,6 @@ export default function ExpensesPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchExpenses()
}, [])
async function fetchExpenses() {
setIsLoading(true)
const { data, error } = await supabase
@@ -86,6 +82,10 @@ export default function ExpensesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchExpenses()
}, [])
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { SectorSlug } from '@/lib/extensions/types'
import CategoryBadge from '@/components/extensions/CategoryBadge'
import ExtensionToggleButton from '@/components/extensions/ExtensionToggleButton'
import Link from 'next/link'
@@ -15,7 +16,8 @@ export default async function ExtensionDetailPage({
const definition = getExtensionDefinition(sectorSlug, extensionSlug)
if (!definition) notFound()
const sector = getSector(sectorSlug as any)
const sector = getSector(sectorSlug as SectorSlug)
const Icon = resolveIcon(definition.icon)
const dataPatternLabels: Record<string, string> = {
+3 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'
import { getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { SectorSlug } from '@/lib/extensions/types'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import Link from 'next/link'
@@ -10,10 +11,11 @@ export default async function SectorExtensionsPage({
params: Promise<{ sector: string }>
}) {
const { sector: sectorSlug } = await params
const sector = getSector(sectorSlug as any)
const sector = getSector(sectorSlug as SectorSlug)
if (!sector) notFound()
const Icon = resolveIcon(sector.icon)
return (
+40 -1
View File
@@ -12,6 +12,7 @@ import {
Calculator,
Building2,
FileText,
FileDown,
ExternalLink,
ChevronDown,
ChevronUp,
@@ -372,7 +373,7 @@ export default function HelpPage() {
<CardContent className="py-12 text-center">
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
Inga termer hittades för "{searchQuery}"
Inga termer hittades för &quot;{searchQuery}&quot;
</p>
</CardContent>
</Card>
@@ -388,6 +389,44 @@ export default function HelpPage() {
)}
</div>
{/* Document templates */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Dokument & Mallar</CardTitle>
<CardDescription>Lagstadgade mallar för din bokföring — ladda ner, fyll i och spara</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<a
href="/docs/arkivplan-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
<span className="font-medium">Arkivplan</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Mall enligt BFNAR 2013:2 — beskriver var räkenskapsinformation förvaras
</p>
</a>
<a
href="/docs/systemdokumentation-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
<span className="font-medium">Systemdokumentation</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Mall enligt BFL 5 kap. 11 § — beskriver bokföringssystemets uppbyggnad
</p>
</a>
</div>
</CardContent>
</Card>
{/* External resources */}
<Card>
<CardHeader>
+2 -2
View File
@@ -311,13 +311,13 @@ function SIEImportWizard() {
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
const [, setParsed] = useState<ParsedSIEFile | null>(null)
const [mappings, setMappings] = useState<AccountMapping[]>([])
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [issues, setIssues] = useState<ParseIssue[]>([])
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
// Skip the mapping step when all accounts are already mapped
-2
View File
@@ -353,8 +353,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
return (
<div className="space-y-6">
{/* Header */}
+2 -4
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -55,8 +55,6 @@ export default function NewInvoicePage() {
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const searchParams = useSearchParams()
const preselectedCustomerId = searchParams.get('customer_id')
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -68,7 +66,7 @@ export default function NewInvoicePage() {
const [showSendPrompt, setShowSendPrompt] = useState(false)
const [isSending, setIsSending] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
const [, setDefaultNotes] = useState<string | null>(null)
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
+6 -6
View File
@@ -7,11 +7,11 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, FileQuestion, Truck } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import type { Invoice, InvoiceStatus } from '@/types'
@@ -53,10 +53,6 @@ export default function InvoicesPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchInvoices()
}, [])
async function fetchInvoices() {
setIsLoading(true)
const { data, error } = await supabase
@@ -76,6 +72,10 @@ export default function InvoicesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
}, [])
const filteredInvoices = invoices.filter((invoice) => {
const matchesSearch =
invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
+4 -3
View File
@@ -177,8 +177,9 @@ export default async function DashboardPage() {
}))
// Fetch upcoming deadlines (next 7 days + overdue)
const today = new Date().toISOString().split('T')[0]
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const { data: deadlines } = await supabase
.from('deadlines')
@@ -258,7 +259,7 @@ export default async function DashboardPage() {
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
let checkDate = new Date(todayDate)
const checkDate = new Date(todayDate)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
+5 -5
View File
@@ -34,11 +34,6 @@ export default function ReportsPage() {
const [activeTab, setActiveTab] = useState('trial-balance')
const [entityType, setEntityType] = useState<string | null>(null)
useEffect(() => {
fetchPeriods()
fetchEntityType()
}, [])
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
@@ -60,6 +55,11 @@ export default function ReportsPage() {
}
}
useEffect(() => {
fetchPeriods()
fetchEntityType()
}, [])
const isEnskildFirma = entityType === 'enskild_firma'
const isAktiebolag = entityType === 'aktiebolag'
+46 -46
View File
@@ -60,52 +60,6 @@ export default function SettingsPage() {
setMounted(true)
}, [])
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
// Auto-sync transactions after connection
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings?tab=banking')
}
if (bankError) {
toast({
title: 'Anslutning misslyckades',
description: decodeURIComponent(bankError),
variant: 'destructive',
})
router.replace('/settings')
}
}, [searchParams])
async function fetchData() {
setIsLoading(true)
@@ -152,6 +106,52 @@ export default function SettingsPage() {
setIsLoading(false)
}
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
// Auto-sync transactions after connection
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings?tab=banking')
}
if (bankError) {
toast({
title: 'Anslutning misslyckades',
description: decodeURIComponent(bankError),
variant: 'destructive',
})
router.replace('/settings')
}
}, [searchParams])
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!settings) return
@@ -50,10 +50,6 @@ export default function SupplierInvoiceDetailPage() {
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
@@ -67,6 +63,10 @@ export default function SupplierInvoiceDetailPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoice()
}, [params.id])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
+4 -4
View File
@@ -38,10 +38,6 @@ export default function SupplierInvoicesPage() {
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
useEffect(() => {
fetchInvoices()
}, [])
async function fetchInvoices() {
setIsLoading(true)
const res = await fetch('/api/supplier-invoices?status=all')
@@ -50,6 +46,10 @@ export default function SupplierInvoicesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
}, [])
const filteredInvoices = invoices.filter((inv) => {
switch (activeTab) {
case 'registered': return inv.status === 'registered'
+5 -5
View File
@@ -34,11 +34,6 @@ export default function SupplierDetailPage() {
const [isSaving, setIsSaving] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchSupplier()
fetchInvoices()
}, [params.id])
async function fetchSupplier() {
setIsLoading(true)
const res = await fetch(`/api/suppliers/${params.id}`)
@@ -59,6 +54,11 @@ export default function SupplierDetailPage() {
}
}
useEffect(() => {
fetchSupplier()
fetchInvoices()
}, [params.id])
async function handleUpdate(data: CreateSupplierInput) {
setIsSaving(true)
const res = await fetch(`/api/suppliers/${params.id}`, {
+4 -4
View File
@@ -34,10 +34,6 @@ export default function SuppliersPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchSuppliers()
}, [])
async function fetchSuppliers() {
setIsLoading(true)
const { data, error } = await supabase
@@ -57,6 +53,10 @@ export default function SuppliersPage() {
setIsLoading(false)
}
useEffect(() => {
fetchSuppliers()
}, [])
async function handleCreateSupplier(data: CreateSupplierInput) {
setIsCreating(true)
+106 -51
View File
@@ -2,10 +2,11 @@
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
@@ -15,12 +16,12 @@ import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
const STEP_TITLES = [
'Verksamhetsform',
'Företagsuppgifter',
'Skatteregistrering',
'F-skatt',
'Anslut bank',
const STEP_INFO = [
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
{ title: 'Skatt & bokföring', subtitle: 'F-skatt, räkenskapsår och momsregistrering.', label: 'Skatt' },
{ title: 'Preliminärskatt', subtitle: 'Frivilligt — hjälper dig hålla koll på skatten.', label: 'F-skatt' },
{ title: 'Bankuppgifter', subtitle: 'Dessa visas på dina fakturor.', label: 'Bank' },
]
function translatePeriodError(msg: string): string {
@@ -33,7 +34,11 @@ function translatePeriodError(msg: string): string {
export default function OnboardingPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<OnboardingPageContent />
</Suspense>
)
@@ -51,7 +56,6 @@ function OnboardingPageContent() {
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
useEffect(() => {
@@ -63,7 +67,7 @@ function OnboardingPageContent() {
return
}
const { data, error } = await supabase
const { data } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
@@ -82,26 +86,6 @@ function OnboardingPageContent() {
loadSettings()
}, [supabase, router, toast])
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
}, [searchParams])
const saveSettings = async (updates: Partial<CompanySettings>, nextStep?: number) => {
setIsSaving(true)
@@ -146,6 +130,26 @@ function OnboardingPageContent() {
return true
}
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
}, [searchParams])
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Fix org number bug: clear dependent fields when entity type changes
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
@@ -268,7 +272,7 @@ function OnboardingPageContent() {
onConflict: 'user_id,period_start,period_end',
})
}
} catch (err) {
} catch {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.',
@@ -307,13 +311,13 @@ function OnboardingPageContent() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const stepInfo = STEP_INFO[currentStep - 1]
const renderSteps = () => (
<>
@@ -408,26 +412,77 @@ function OnboardingPageContent() {
)
return (
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
{/* Header */}
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b">
<div className="max-w-2xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Steg {currentStep} av {totalSteps}
</span>
<span className="text-sm font-medium">
{stepTitles[currentStep - 1]}
<div className="min-h-screen flex flex-col bg-background">
{/* ── Branded Header ── */}
<header className="relative bg-[#141414] text-white overflow-hidden">
{/* Decorative elements */}
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-serif font-bold text-white/[0.02] leading-none select-none">
{String(currentStep).padStart(2, '0')}
</span>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
{/* Top row: Logo + step indicator + counter */}
<div className="flex items-center justify-between mb-5 md:mb-6">
<div className="flex items-center gap-2.5">
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={30}
height={30}
className="invert opacity-90"
/>
<span className="font-serif text-base tracking-tight">gnubok</span>
</div>
{/* Step indicator — inline with logo row */}
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
const num = i + 1
return (
<div
key={i}
className={cn(
'h-[3px] rounded-full transition-all duration-500',
num === currentStep && 'w-7 bg-white',
num < currentStep && 'w-4 bg-white/50',
num > currentStep && 'w-4 bg-white/[0.1]',
)}
/>
)
})}
</div>
<span className="text-[10px] text-white/30 tracking-[0.15em] uppercase">
{currentStep} / {totalSteps}
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</div>
{/* Content */}
<div className="max-w-2xl mx-auto px-4 py-8">
{renderSteps()}
</div>
{/* Step title — compact */}
<div key={`title-${currentStep}`} className="animate-fade-in">
<h1 className="font-serif text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
{stepInfo.title}
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
{stepInfo.subtitle}
</p>
</div>
</div>
</header>
{/* ── Form Content ── */}
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div key={`step-${currentStep}`} className="animate-slide-up">
{renderSteps()}
</div>
</div>
</main>
</div>
)
}
@@ -31,6 +31,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -45,9 +46,11 @@ describe('GET /api/audit-trail', () => {
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
const req = createMockRequest('/api/audit-trail')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
expect(status).toBe(200)
+1 -1
View File
@@ -60,7 +60,7 @@ export async function PUT(
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
const { data: existing, error: fetchError } = await supabase
const { data: _existing, error: fetchError } = await supabase
.from('deadlines')
.select('*')
.eq('id', id)
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
}
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
if (!transactions || transactions.length === 0) {
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
@@ -89,7 +89,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body: _body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -67,6 +68,7 @@ describe('GET /api/reports/audit-trail', () => {
it('returns CSV format with correct headers', async () => {
mockAuth('user-1')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -88,6 +90,7 @@ describe('GET /api/reports/audit-trail', () => {
it('returns JSON format as downloadable file', async () => {
mockAuth('user-1')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -118,7 +121,9 @@ describe('GET /api/reports/audit-trail', () => {
}))
mockGetAuditLog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce({ data: bigPage as any, count: 600 })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce({ data: lastPage as any, count: 600 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
@@ -9,41 +8,6 @@ import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
/**
* Ensure a fiscal period exists for the given date
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
const existingPeriodId = await findFiscalPeriod(supabase, userId, date)
if (existingPeriodId) return existingPeriodId
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: `${year}-01-01`,
period_end: `${year}-12-31`,
}, {
onConflict: 'user_id,period_start,period_end',
})
.select('id')
.single()
if (error) {
console.error('Failed to create fiscal period:', error)
return null
}
return data?.id || null
}
/**
* POST /api/transactions/[id]/match-supplier-invoice
*
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
import type { Transaction, EntityType } from '@/types'
/**
* POST /api/transactions/suggest-categories
+1 -1
View File
@@ -183,7 +183,7 @@ export default async function RootPage() {
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
let checkDate = new Date(todayDate)
const checkDate = new Date(todayDate)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)