Files
accounted/app/(dashboard)/settings/page.tsx
T
Jakob WennbergandClaude Opus 4.6 091d043c85 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>
2026-03-05 23:05:49 +01:00

743 lines
28 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { Separator } from '@/components/ui/separator'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Loader2,
CreditCard,
LogOut,
Sun,
Moon,
Monitor,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import type { CompanySettings } from '@/types'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
const BankingPanel = getSettingsPanel('enable-banking')
export default function SettingsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const initialTab = searchParams.get('tab') || 'company'
useEffect(() => {
setMounted(true)
}, [])
async function fetchData() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
// Fetch settings
const { data: settingsData } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, calendarToggleRes] = await Promise.all([
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null),
])
if (bankingToggleRes?.ok) {
const { data } = await bankingToggleRes.json()
setHasBankingExtension(data?.enabled || false)
} else {
// Fallback: check for existing bank connections
const { data: connections } = await supabase
.from('bank_connections')
.select('id')
.eq('user_id', user.id)
.eq('status', 'active')
.limit(1)
setHasBankingExtension((connections && connections.length > 0) || false)
}
if (calendarToggleRes?.ok) {
const { data } = await calendarToggleRes.json()
setHasCalendarExtension(data?.enabled || false)
}
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
setIsSaving(true)
const formData = new FormData(e.currentTarget)
const updates: Record<string, unknown> = {
company_name: formData.get('company_name') as string,
org_number: formData.get('org_number') as string,
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
invoice_prefix: formData.get('invoice_prefix') as string || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
accounting_method: formData.get('accounting_method') as string || 'accrual',
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
}
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Kunde inte spara inställningar')
}
toast({
title: 'Sparat',
description: 'Dina inställningar har uppdaterats',
})
setSettings({ ...settings, ...updates } as typeof settings)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara inställningar',
variant: 'destructive',
})
}
setIsSaving(false)
}
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
}
async function handleDeleteAccount() {
if (deleteConfirmText !== 'RADERA') return
setIsDeleting(true)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte radera kontot',
variant: 'destructive',
})
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Inställningar</h1>
<p className="text-muted-foreground">
Hantera dina företags- och kontoinställningar
</p>
</div>
<Tabs defaultValue={initialTab} className="space-y-6">
<TabsList className="flex-wrap h-auto gap-1">
<TabsTrigger value="company">
Företag
</TabsTrigger>
<TabsTrigger value="banking">
Bank (PSD2)
</TabsTrigger>
{hasCalendarExtension && (
<TabsTrigger value="calendar">
Kalender
</TabsTrigger>
)}
<TabsTrigger value="appearance">
Utseende
</TabsTrigger>
<TabsTrigger value="account">
Konto
</TabsTrigger>
</TabsList>
{/* Company settings */}
<TabsContent value="company">
<form onSubmit={handleSaveSettings} className="space-y-6">
{/* Företagsuppgifter */}
<Card>
<CardHeader>
<CardTitle>Företagsuppgifter</CardTitle>
<CardDescription>
Namn, organisationsnummer och adress
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Input
id="address_line1"
name="address_line1"
defaultValue={settings?.address_line1 || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings?.postal_code || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={settings?.city || ''}
/>
</div>
</div>
</CardContent>
</Card>
{/* Bankuppgifter */}
<Card>
<CardHeader>
<CardTitle>Bankuppgifter</CardTitle>
<CardDescription>
Betalningsuppgifter som visas på dina fakturor
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
name="bank_name"
defaultValue={settings?.bank_name || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Input
id="clearing_number"
name="clearing_number"
defaultValue={settings?.clearing_number || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
name="account_number"
defaultValue={settings?.account_number || ''}
/>
</div>
</div>
</CardContent>
</Card>
{/* Fakturainställningar */}
<Card>
<CardHeader>
<CardTitle>Fakturainställningar</CardTitle>
<CardDescription>
Numrering, betalningsvillkor och bokföringsmetod
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
defaultValue={settings?.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings?.next_invoice_number || 1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings?.invoice_default_days || 30}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings?.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
</select>
<p className="text-xs text-muted-foreground">
{settings?.entity_type === 'aktiebolag'
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
defaultValue={settings?.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
</p>
</div>
</CardContent>
</Card>
{/* Skatteinställningar */}
<Card>
<CardHeader>
<CardTitle>Skatteinställningar</CardTitle>
<CardDescription>
Preliminärskatt och F-skatt
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
</Label>
<Input
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings?.preliminary_tax_monthly || ''}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
</form>
</TabsContent>
{/* Banking settings — loaded dynamically from extension */}
<TabsContent value="banking" className="space-y-6">
{hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
Gå till Tillägg
</Link>
</Button>
</CardContent>
</Card>
)}
</TabsContent>
{/* Calendar feed settings */}
{hasCalendarExtension && (
<TabsContent value="calendar">
<CalendarFeedSettings />
</TabsContent>
)}
{/* Appearance settings */}
<TabsContent value="appearance">
<Card>
<CardHeader>
<CardTitle>Utseende</CardTitle>
<CardDescription>
Välj hur applikationen ska se ut
</CardDescription>
</CardHeader>
<CardContent>
{mounted && (
<div className="grid grid-cols-3 gap-4">
{/* Light */}
<button
type="button"
onClick={() => setTheme('light')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'light'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-white p-2">
<div className="h-full w-3 rounded-sm bg-[hsl(222,47%,35%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,14%,96%)]" />
</div>
</div>
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Ljust</span>
</div>
</button>
{/* Dark */}
<button
type="button"
onClick={() => setTheme('dark')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'dark'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-[hsl(222,16%,10%)] p-2">
<div className="h-full w-3 rounded-sm bg-[hsl(222,50%,55%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,12%,20%)]" />
</div>
</div>
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Mörkt</span>
</div>
</button>
{/* System */}
<button
type="button"
onClick={() => setTheme('system')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'system'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 overflow-hidden rounded-md border">
<div className="flex flex-1 items-end gap-1 bg-white p-2">
<div className="h-full w-2 rounded-sm bg-[hsl(222,47%,35%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
</div>
</div>
<div className="flex flex-1 items-end gap-1 bg-[hsl(222,16%,10%)] p-2">
<div className="h-full w-2 rounded-sm bg-[hsl(222,50%,55%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">System</span>
</div>
</button>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Account settings */}
<TabsContent value="account" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Logga ut</p>
<p className="text-sm text-muted-foreground">
Logga ut från ditt konto
</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
</Button>
</div>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Radera konto</CardTitle>
<CardDescription>
Permanent borttagning av ditt konto och all data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
</Link>
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
Radera mitt konto
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete account confirmation dialog */}
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
setShowDeleteDialog(open)
if (!open) setDeleteConfirmText('')
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="RADERA"
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false)
setDeleteConfirmText('')
}}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera permanent'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}