This commit is contained in:
Jakob Wennberg
2026-02-19 14:32:54 +01:00
parent cdf1dcc4c8
commit e8743e6e03
104 changed files with 4551 additions and 4172 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"mcp__ide__getDiagnostics",
"Bash(grep:*)",
"Bash(xargs:*)",
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(find:*)"
]
}
}
-379
View File
@@ -1,379 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, 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 { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
getSchablonavdragSummary,
groupMileageEntriesByMonth,
formatMonthKey,
SCHABLONAVDRAG_RATES,
} from '@/lib/tax/schablonavdrag'
import SchablonavdragSettings from '@/components/settings/SchablonavdragSettings'
import MileageEntry from '@/components/transactions/MileageEntry'
import {
Loader2,
Home,
Car,
Plus,
Trash2,
Calendar,
MapPin,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import type {
CompanySettings,
SchablonavdragSettings as SchablonavdragSettingsType,
MileageEntry as MileageEntryType,
CreateMileageEntryInput,
} from '@/types'
export default function DeductionsPage() {
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [mileageEntries, setMileageEntries] = useState<MileageEntryType[]>([])
const [showAddForm, setShowAddForm] = useState(false)
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set())
useEffect(() => {
fetchData()
}, [])
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)
// Fetch mileage entries for current year
const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const { data: entries } = await supabase
.from('mileage_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', startOfYear)
.order('date', { ascending: false })
setMileageEntries(entries || [])
setIsLoading(false)
}
async function handleSaveSettings(newSettings: SchablonavdragSettingsType) {
if (!settings) return
const { error } = await supabase
.from('company_settings')
.update({ schablonavdrag_settings: newSettings })
.eq('id', settings.id)
if (error) {
throw error
}
setSettings({
...settings,
schablonavdrag_settings: newSettings,
} as CompanySettings & { schablonavdrag_settings: SchablonavdragSettingsType })
}
async function handleAddMileageEntry(entry: CreateMileageEntryInput) {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { data, error } = await supabase
.from('mileage_entries')
.insert({
user_id: user.id,
date: entry.date,
distance_km: entry.distance_km,
purpose: entry.purpose,
from_location: entry.from_location || null,
to_location: entry.to_location || null,
})
.select()
.single()
if (error) {
throw error
}
setMileageEntries([data, ...mileageEntries])
setShowAddForm(false)
}
async function handleDeleteMileageEntry(id: string) {
const { error } = await supabase.from('mileage_entries').delete().eq('id', id)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort körningen',
variant: 'destructive',
})
return
}
setMileageEntries(mileageEntries.filter((e) => e.id !== id))
toast({
title: 'Borttaget',
description: 'Körningen har tagits bort',
})
}
function toggleMonth(monthKey: string) {
const newExpanded = new Set(expandedMonths)
if (newExpanded.has(monthKey)) {
newExpanded.delete(monthKey)
} else {
newExpanded.add(monthKey)
}
setExpandedMonths(newExpanded)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
// Get schablonavdrag settings
const schablonavdragSettings = ((settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettingsType })?.schablonavdrag_settings) || {
hemmakontor_enabled: false,
bil_enabled: false,
}
// Calculate summary
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const summary = getSchablonavdragSummary(
schablonavdragSettings,
mileageEntries,
currentYear,
currentMonth
)
// Group mileage entries by month
const entriesByMonth = groupMileageEntriesByMonth(mileageEntries)
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Schablonavdrag</h1>
<p className="text-muted-foreground">
Hantera dina schablonmässiga avdrag för hemmakontor och bilkostnader
</p>
</div>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
{/* Total deduction */}
<Card className="bg-gradient-to-br from-success/10 via-success/5 to-background border-success/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Totalt avdrag {currentYear}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-success">
{formatCurrency(summary.total_deduction)}
</div>
</CardContent>
</Card>
{/* Hemmakontor */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Home className="h-4 w-4" />
Hemmakontor
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{summary.hemmakontor.enabled ? formatCurrency(summary.hemmakontor.deduction) : '-'}
</div>
{summary.hemmakontor.enabled && (
<p className="text-xs text-muted-foreground">
{summary.hemmakontor.months_active} månader
</p>
)}
</CardContent>
</Card>
{/* Bilkostnader */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Car className="h-4 w-4" />
Bilkostnader
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{summary.mileage.enabled ? formatCurrency(summary.mileage.total_deduction) : '-'}
</div>
{summary.mileage.enabled && summary.mileage.total_km > 0 && (
<p className="text-xs text-muted-foreground">
{summary.mileage.total_km.toFixed(0)} km loggade
</p>
)}
</CardContent>
</Card>
</div>
<Tabs defaultValue="settings" className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Inställningar</TabsTrigger>
<TabsTrigger value="mileage" disabled={!schablonavdragSettings.bil_enabled}>
Körjournal
{schablonavdragSettings.bil_enabled && mileageEntries.length > 0 && (
<Badge variant="secondary" className="ml-2">
{mileageEntries.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* Settings tab */}
<TabsContent value="settings">
<SchablonavdragSettings
settings={schablonavdragSettings}
onSave={handleSaveSettings}
/>
</TabsContent>
{/* Mileage log tab */}
<TabsContent value="mileage" className="space-y-6">
{/* Add new entry button/form */}
{showAddForm ? (
<MileageEntry onSave={handleAddMileageEntry} onCancel={() => setShowAddForm(false)} />
) : (
<Button onClick={() => setShowAddForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Ny körning
</Button>
)}
{/* Entries list grouped by month */}
{mileageEntries.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center">
<Car className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium">Ingen körning loggad</h3>
<p className="mt-2 text-sm text-muted-foreground">
Börja logga dina tjänsteresor för att få milersättning
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{Array.from(entriesByMonth.entries()).map(([monthKey, monthData]) => (
<Card key={monthKey}>
<CardHeader
className="cursor-pointer"
onClick={() => toggleMonth(monthKey)}
>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{formatMonthKey(monthKey)}
</CardTitle>
<CardDescription>
{monthData.entries.length} körningar · {monthData.totalKm.toFixed(0)} km
</CardDescription>
</div>
<div className="flex items-center gap-4">
<span className="text-lg font-medium text-success">
{formatCurrency(monthData.totalDeduction)}
</span>
{expandedMonths.has(monthKey) ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</div>
</CardHeader>
{expandedMonths.has(monthKey) && (
<CardContent className="pt-0">
<div className="space-y-3">
{monthData.entries.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between p-3 rounded-lg border bg-muted/30"
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
<span>{formatDate(entry.date)}</span>
<Badge variant="secondary">
{Number(entry.distance_km).toFixed(1)} km
</Badge>
</div>
<p className="text-sm">{entry.purpose}</p>
{(entry.from_location || entry.to_location) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
{entry.from_location && entry.to_location
? `${entry.from_location} → ${entry.to_location}`
: entry.from_location || entry.to_location}
</div>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-success">
{formatCurrency(Number(entry.total_deduction))}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteMileageEntry(entry.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
)
}
+7 -7
View File
@@ -119,8 +119,8 @@ export default function InvoicesPage() {
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
<Receipt className="h-6 w-6 text-primary" />
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Totalt antal</p>
@@ -132,8 +132,8 @@ export default function InvoicesPage() {
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-warning/10 flex items-center justify-center">
<Clock className="h-6 w-6 text-warning" />
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Obetalda</p>
@@ -149,11 +149,11 @@ export default function InvoicesPage() {
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-success/5 to-transparent border-success/20">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-success/10 flex items-center justify-center">
<Send className="h-6 w-6 text-success" />
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Send className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Att få in</p>
+2 -2
View File
@@ -42,8 +42,8 @@ export default async function DashboardLayout({
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
/>
<main id="main-content" className="pb-20 md:pb-0 md:pl-60" role="main">
<div className="max-w-4xl mx-auto px-6 py-10 md:py-12">
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
@@ -1,145 +0,0 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowLeft, BookOpen, BarChart3, FileDown, Cog } from 'lucide-react'
import { getModuleBySlug, CATEGORY_LABELS, type ModuleCategory } from '@/lib/modules-data'
import { Badge } from '@/components/ui/badge'
import { ModuleToggle } from '@/components/modules/ModuleToggle'
const CATEGORY_META: Record<ModuleCategory, {
icon: typeof BookOpen
color: string
bgColor: string
borderColor: string
}> = {
bokforing: {
icon: BookOpen,
color: 'text-primary',
bgColor: 'bg-primary/8',
borderColor: 'border-primary/10',
},
rapport: {
icon: BarChart3,
color: 'text-accent',
bgColor: 'bg-accent/8',
borderColor: 'border-accent/10',
},
import: {
icon: FileDown,
color: 'text-success',
bgColor: 'bg-success/8',
borderColor: 'border-success/10',
},
operativ: {
icon: Cog,
color: 'text-muted-foreground',
bgColor: 'bg-muted/60',
borderColor: 'border-border',
},
}
export default async function ModuleDetailPage({
params,
}: {
params: Promise<{ sector: string; module: string }>
}) {
const { sector: sectorSlug, module: moduleSlug } = await params
const result = getModuleBySlug(sectorSlug, moduleSlug)
if (!result) {
notFound()
}
const { sector, module: mod } = result
const SectorIcon = sector.icon
const catMeta = CATEGORY_META[mod.cat]
const CatIcon = catMeta.icon
return (
<div className="space-y-8 stagger-enter">
{/* Navigation */}
<div>
<Link
href={`/modules/${sectorSlug}`}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
{sector.name}
</Link>
</div>
{/* Header */}
<div className="space-y-4">
<div className="flex items-start gap-5">
<div className={`flex items-center justify-center w-12 h-12 rounded-xl ${catMeta.bgColor} ring-1 ${catMeta.borderColor}`}>
<CatIcon className={`h-6 w-6 ${catMeta.color}`} strokeWidth={1.5} />
</div>
<div className="flex-1 min-w-0">
<h1 className="font-display text-2xl font-semibold tracking-tight">
{mod.name}
</h1>
<p className="text-muted-foreground mt-1">
{mod.desc}
</p>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2">
<Badge
className={`${catMeta.bgColor} ${catMeta.color} border-transparent`}
>
{CATEGORY_LABELS[mod.cat]}
</Badge>
<Badge variant="secondary" className="gap-1.5">
<SectorIcon className="h-3 w-3" strokeWidth={1.5} />
{sector.name}
</Badge>
</div>
</div>
{/* Separator */}
<div className="h-px bg-border" />
{/* Long description */}
<div className="prose prose-sm max-w-none">
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
{mod.longDesc.split('. Nyckelfunktioner: ').map((part, i) => {
if (i === 0) {
return (
<div key={i}>
<h2 className="text-base font-semibold text-foreground mt-0 mb-2">
Beskrivning
</h2>
<p className="text-sm text-muted-foreground leading-relaxed m-0">
{part}{mod.longDesc.includes('Nyckelfunktioner') ? '' : ''}
</p>
</div>
)
}
return (
<div key={i}>
<h2 className="text-base font-semibold text-foreground mt-0 mb-2">
Nyckelfunktioner
</h2>
<ul className="space-y-1.5 m-0 p-0 list-none">
{part.split('; ').map((feature, j) => (
<li
key={j}
className="flex items-start gap-2 text-sm text-muted-foreground"
>
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-primary/40 flex-shrink-0" />
{feature.replace(/,\s*$/, '')}
</li>
))}
</ul>
</div>
)
})}
</div>
</div>
{/* Toggle */}
<ModuleToggle sectorSlug={sectorSlug} moduleSlug={moduleSlug} />
</div>
)
}
-151
View File
@@ -1,151 +0,0 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowLeft, BookOpen, BarChart3, FileDown, Cog, ChevronRight } from 'lucide-react'
import { getSectorBySlug, CATEGORY_LABELS, type ModuleCategory } from '@/lib/modules-data'
import { Badge } from '@/components/ui/badge'
const CATEGORY_ORDER: ModuleCategory[] = ['bokforing', 'rapport', 'import', 'operativ']
const CATEGORY_META: Record<ModuleCategory, {
icon: typeof BookOpen
color: string
bgColor: string
borderColor: string
}> = {
bokforing: {
icon: BookOpen,
color: 'text-primary',
bgColor: 'bg-primary/8',
borderColor: 'border-primary/10',
},
rapport: {
icon: BarChart3,
color: 'text-accent',
bgColor: 'bg-accent/8',
borderColor: 'border-accent/10',
},
import: {
icon: FileDown,
color: 'text-success',
bgColor: 'bg-success/8',
borderColor: 'border-success/10',
},
operativ: {
icon: Cog,
color: 'text-muted-foreground',
bgColor: 'bg-muted/60',
borderColor: 'border-border',
},
}
export default async function SectorPage({
params,
}: {
params: Promise<{ sector: string }>
}) {
const { sector: slug } = await params
const sector = getSectorBySlug(slug)
if (!sector) {
notFound()
}
const Icon = sector.icon
const grouped = CATEGORY_ORDER.map((cat) => ({
cat,
label: CATEGORY_LABELS[cat],
meta: CATEGORY_META[cat],
modules: sector.modules.filter((m) => m.cat === cat),
})).filter((g) => g.modules.length > 0)
const totalModules = sector.modules.length
return (
<div className="space-y-10 stagger-enter">
{/* Navigation */}
<div>
<Link
href="/modules"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Alla branscher
</Link>
</div>
{/* Hero header */}
<div className="relative">
<div className="flex items-start gap-5">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-primary/8 text-primary ring-1 ring-primary/10">
<Icon className="h-7 w-7" strokeWidth={1.5} />
</div>
<div className="flex-1 min-w-0">
<h1 className="font-display text-2xl font-semibold tracking-tight">
{sector.name}
</h1>
<p className="text-muted-foreground mt-1">
{sector.description}
</p>
<div className="flex items-center gap-3 mt-3">
<Badge variant="secondary" className="tabular-nums">
{totalModules} moduler
</Badge>
<Badge variant="secondary" className="tabular-nums">
{grouped.length} kategorier
</Badge>
</div>
</div>
</div>
</div>
{/* Separator */}
<div className="h-px bg-border" />
{/* Category sections */}
<div className="space-y-10">
{grouped.map((group) => {
const CatIcon = group.meta.icon
return (
<section key={group.cat}>
{/* Category header */}
<div className="flex items-center gap-3 mb-4">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${group.meta.bgColor}`}>
<CatIcon className={`h-4 w-4 ${group.meta.color}`} strokeWidth={1.5} />
</div>
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-semibold text-foreground">
{group.label}
</h2>
<span className="text-xs text-muted-foreground/60 tabular-nums">
{group.modules.length}
</span>
</div>
</div>
{/* Module cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{group.modules.map((mod) => (
<Link
key={mod.slug}
href={`/modules/${slug}/${mod.slug}`}
className={`group relative flex items-center gap-3 rounded-xl border ${group.meta.borderColor} bg-card px-4 py-3.5 transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:border-primary/20 hover:-translate-y-px`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground group-hover:text-primary transition-colors duration-200">
{mod.name}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5 line-clamp-1">
{mod.desc}
</p>
</div>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/30 flex-shrink-0 transition-all duration-200 group-hover:text-primary/60 group-hover:translate-x-0.5" />
</Link>
))}
</div>
</section>
)
})}
</div>
</div>
)
}
-50
View File
@@ -1,50 +0,0 @@
import Link from 'next/link'
import { Layers } from 'lucide-react'
import { sectors } from '@/lib/modules-data'
export default function ModulesPage() {
return (
<div className="space-y-8">
<div>
<h1 className="font-display text-2xl font-semibold tracking-tight">
Företagsmoduler
</h1>
<p className="text-muted-foreground mt-1">
Välj din bransch för att se anpassade moduler och funktioner.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Link
href="/modules/alla"
className="group flex items-center gap-3.5 rounded-xl border border-accent/20 bg-accent/5 px-4 py-3.5 transition-all duration-200 hover:border-accent/40 hover:bg-accent/10"
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-accent/12 text-accent shrink-0 transition-transform duration-200 group-hover:scale-105">
<Layers className="h-5 w-5" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-accent truncate">
Visa alla
</span>
</Link>
{sectors.map((sector) => {
const Icon = sector.icon
return (
<Link
key={sector.slug}
href={`/modules/${sector.slug}`}
className="group flex items-center gap-3.5 rounded-xl border border-border/50 bg-card px-4 py-3.5 transition-all duration-200 hover:border-border hover:bg-secondary/50 hover:shadow-sm"
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted/60 text-muted-foreground shrink-0 transition-all duration-200 group-hover:bg-primary/10 group-hover:text-primary group-hover:scale-105">
<Icon className="h-5 w-5" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-foreground/80 truncate transition-colors duration-200 group-hover:text-foreground">
{sector.name}
</span>
</Link>
)
})}
</div>
</div>
)
}
-8
View File
@@ -122,13 +122,6 @@ export default async function DashboardPage() {
(inv) => inv.status === 'overdue'
).length
// Fetch mileage entries for schablonavdrag
const { data: mileageEntries } = await supabase
.from('mileage_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', startOfYear.split('T')[0])
// Fetch bank balance (if connected)
const { data: bankConnections } = await supabase
.from('bank_connections')
@@ -225,7 +218,6 @@ export default async function DashboardPage() {
unpaidVatTotal,
overdueInvoicesCount: overdueCount,
bankBalance,
mileageEntries: mileageEntries || [],
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
}}
+1 -344
View File
@@ -1,344 +1 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '@/components/receipts/ReceiptDashboard'
import ReceiptReviewView from '@/components/receipts/ReceiptReviewView'
import TransactionMatcher from '@/components/receipts/TransactionMatcher'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Selected receipt for review/match
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
// Fetch receipts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/receipts'),
fetch('/api/receipts/queue'),
])
const [receiptsData, queueData] = await Promise.all([
receiptsRes.json(),
queueRes.json(),
])
if (receiptsData.data) {
setReceipts(receiptsData.data)
}
if (queueData.data?.summary) {
setSummary(queueData.data.summary)
}
} catch (error) {
console.error('Fetch error:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Filter receipts based on tab
const filteredReceipts = receipts.filter((r) => {
if (listFilter === 'pending') return r.status === 'extracted'
if (listFilter === 'confirmed') return r.status === 'confirmed'
return true
})
// Handle scan receipt
const handleScanReceipt = () => {
router.push('/receipts/scan')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
setViewMode('list')
}
// Handle view transaction queue
const handleViewTransactionQueue = () => {
router.push('/transactions?filter=unmatched')
}
// Handle receipt selection for review
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
setSelectedReceipt(receipt)
if (receipt.status === 'extracted') {
setViewMode('review')
} else {
setViewMode('match')
}
}
// Handle confirm receipt
const handleConfirmReceipt = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/receipts/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match receipt to transaction
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/receipts/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
}
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
<ReceiptReviewView
receipt={selectedReceipt}
onConfirm={handleConfirmReceipt}
onCancel={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onFindMatches={() => setViewMode('match')}
/>
)
}
// Render transaction matcher
if (viewMode === 'match' && selectedReceipt) {
return (
<TransactionMatcher
receipt={selectedReceipt}
onMatch={handleMatchReceipt}
onSkip={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onClose={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kvittohantering</h1>
<Button onClick={handleScanReceipt}>
<Camera className="mr-2 h-4 w-4" />
Skanna kvitto
</Button>
</div>
{/* Dashboard or List view toggle */}
<Tabs
value={viewMode === 'list' ? 'list' : 'dashboard'}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList>
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === 'dashboard' && (
<>
{isLoading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
) : summary ? (
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
) : (
<Card>
<CardContent className="pt-4 text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p>Kunde inte ladda data</p>
<Button variant="outline" className="mt-4" onClick={fetchData}>
Försök igen
</Button>
</CardContent>
</Card>
)}
</>
)}
{viewMode === 'list' && (
<>
{/* List filters */}
<div className="flex gap-2">
<Button
variant={listFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('all')}
>
Alla
</Button>
<Button
variant={listFilter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('pending')}
>
<Clock className="mr-1 h-3 w-3" />
Att granska
</Button>
<Button
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('confirmed')}
>
<Check className="mr-1 h-3 w-3" />
Bekräftade
</Button>
</div>
{/* Receipt list */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : filteredReceipts.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Inga kvitton hittades</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredReceipts.map((receipt) => (
<Card
key={receipt.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleSelectReceipt(receipt)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{receipt.image_url ? (
<img
src={receipt.image_url}
alt="Receipt"
className="h-16 w-12 object-cover rounded"
/>
) : (
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">
{formatDate(receipt.receipt_date)}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant={
receipt.status === 'confirmed'
? 'default'
: receipt.status === 'extracted'
? 'secondary'
: receipt.status === 'error'
? 'destructive'
: 'outline'
}
>
{receipt.status === 'confirmed' && 'Bekräftat'}
{receipt.status === 'extracted' && 'Att granska'}
{receipt.status === 'processing' && 'Analyserar...'}
{receipt.status === 'pending' && 'Väntar'}
{receipt.status === 'error' && 'Fel'}
</Badge>
{receipt.matched_transaction_id && (
<Badge variant="outline">Kopplat</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
)
}
export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage'
+1 -157
View File
@@ -1,157 +1 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import ReceiptCamera from '@/components/receipts/ReceiptCamera'
import ReceiptReviewView from '@/components/receipts/ReceiptReviewView'
import TransactionMatcher from '@/components/receipts/TransactionMatcher'
import { Loader2 } from 'lucide-react'
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
export default function ScanReceiptPage() {
const router = useRouter()
const [pageState, setPageState] = useState<PageState>('camera')
const [uploadError, setUploadError] = useState<string | null>(null)
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
// Handle image capture
const handleCapture = async (imageData: string, mimeType: string) => {
setPageState('uploading')
setUploadError(null)
try {
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
// Create form data
const formData = new FormData()
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/receipts/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setReceipt(data.data)
setPageState('review')
} else {
setUploadError(data.error || 'Kunde inte analysera kvittot')
setPageState('camera')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError('Nätverksfel. Försök igen.')
setPageState('camera')
}
}
// Handle close/cancel
const handleClose = () => {
router.push('/receipts')
}
// Handle confirm receipt
const handleConfirm = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!receipt) return
const response = await fetch(`/api/receipts/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
const updatedData = await response.json()
setReceipt(updatedData.data)
setPageState('match')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match to transaction
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/receipts/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
router.push('/receipts')
}
}
// Handle skip matching
const handleSkipMatch = () => {
router.push('/receipts')
}
// Render uploading state
if (pageState === 'uploading') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Analyserar kvitto...</p>
<p className="text-sm text-muted-foreground mt-1">
AI läser av artiklar och belopp
</p>
</div>
)
}
// Render review view
if (pageState === 'review' && receipt) {
return (
<ReceiptReviewView
receipt={receipt}
onConfirm={handleConfirm}
onCancel={handleClose}
onFindMatches={() => setPageState('match')}
/>
)
}
// Render match view
if (pageState === 'match' && receipt) {
return (
<TransactionMatcher
receipt={receipt}
onMatch={handleMatch}
onSkip={handleSkipMatch}
onClose={handleClose}
/>
)
}
// Render camera (default)
return (
<>
{uploadError && (
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
{uploadError}
</div>
)}
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
</>
)
}
export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage'
+36 -292
View File
@@ -6,7 +6,9 @@ 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, FileText, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react'
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
import type {
FiscalPeriod,
TrialBalanceRow,
@@ -14,7 +16,6 @@ import type {
BalanceSheetReport,
VatDeclaration,
VatPeriodType,
NEDeclaration,
} from '@/types'
function formatAmount(amount: number): string {
@@ -25,9 +26,11 @@ export default function ReportsPage() {
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [activeTab, setActiveTab] = useState('trial-balance')
const [entityType, setEntityType] = useState<string | null>(null)
useEffect(() => {
fetchPeriods()
fetchEntityType()
}, [])
async function fetchPeriods() {
@@ -39,13 +42,27 @@ export default function ReportsPage() {
}
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Ignore - entity type is optional for tab visibility
}
}
const isEnskildFirma = entityType === 'enskild_firma'
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rapporter</h1>
<p className="text-muted-foreground">
Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga och SIE-export
Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga, SRU-export och SIE-export
</p>
</div>
</div>
@@ -97,9 +114,15 @@ export default function ReportsPage() {
<Receipt className="h-4 w-4 mr-1" />
Momsdeklaration
</TabsTrigger>
<TabsTrigger value="ne-declaration">
<Briefcase className="h-4 w-4 mr-1" />
NE-bilaga
{isEnskildFirma && (
<TabsTrigger value="ne-declaration">
<Briefcase className="h-4 w-4 mr-1" />
NE-bilaga
</TabsTrigger>
)}
<TabsTrigger value="sru-export">
<FileDown className="h-4 w-4 mr-1" />
SRU-export
</TabsTrigger>
</TabsList>
@@ -115,8 +138,13 @@ export default function ReportsPage() {
<TabsContent value="vat-declaration">
<VatDeclarationView />
</TabsContent>
<TabsContent value="ne-declaration">
<NEDeclarationView periodId={selectedPeriod} />
{isEnskildFirma && (
<TabsContent value="ne-declaration">
<NEDeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
<TabsContent value="sru-export">
<SRUExportView periodId={selectedPeriod} />
</TabsContent>
</Tabs>
) : (
@@ -870,287 +898,3 @@ function VatRutaRow({
</>
)
}
function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ne-declaration?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta NE-bilaga')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/reports/ne-declaration?period_id=${periodId}&format=sru`, '_blank')
}
// NE ruta labels
const rutaLabels: Record<string, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat',
}
// Categorize rutor
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
</p>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
{data.fiscalYear.name}
</Badge>
</div>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{revenueRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right">
{formatAmount(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{expenseRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
isExpense
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right">
-{formatAmount(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-bold text-xl">Årets resultat</span>
</div>
<span
className={`text-2xl font-bold ${
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatAmount(data.rutor.R11)} kr
</span>
</div>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta NE-bilaga&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
</div>
)
}
function NEDeclarationRow({
ruta,
label,
amount,
accounts,
isExpense,
}: {
ruta: string
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
// Don't show rows with zero values
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 font-mono">{acc.accountNumber}</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} from 'lucide-react'
import type { CompanySettings, BankConnection } from '@/types'
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
import { NotificationSettings } from '@/components/settings/NotificationSettings'
import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
export default function SettingsPage() {
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateChatResponse } from '@/lib/ai/chatbot/chain'
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest } from '@/types/chat'
// Simple in-memory rate limiting (per user)
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { streamChatResponse } from '@/lib/ai/chatbot/chain'
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
// Simple in-memory rate limiting (per user)
@@ -1,14 +1,14 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateNEDeclaration } from '@/lib/reports/ne-declaration'
import { generateNEDeclaration } from '@/extensions/ne-bilaga/ne-engine'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/sru-generator'
} from '@/extensions/sru-export/lib/sru-generator'
/**
* GET /api/reports/ne-declaration
* GET /api/extensions/ne-bilaga
*
* Generate NE declaration (NE-bilaga) for enskild firma.
*
@@ -1,18 +1,22 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
} from '@/lib/push/notification-scheduler'
} from '@/extensions/push-notifications/notification-scheduler'
/**
* GET /api/push/cron
* GET /api/extensions/push-notifications/cron
* Daily cron job to send push notifications
* Runs at 09:00 every day
*
* Vercel Cron: "0 9 * * *"
*/
export async function GET(request: Request) {
// Ensure extensions are loaded so event handlers are registered
loadExtensions()
// Verify cron secret for security
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
@@ -0,0 +1,61 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSettings, saveSettings } from '@/extensions/push-notifications'
/**
* GET /api/extensions/push-notifications/settings
* Get the current user's push-notification extension settings
*/
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await getSettings(user.id)
return NextResponse.json({ data: settings })
}
/**
* PATCH /api/extensions/push-notifications/settings
* Update the current user's push-notification extension settings
*/
export async function PATCH(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const allowedKeys = [
'periodLockedEnabled',
'periodYearClosedEnabled',
'invoiceSentEnabled',
'receiptExtractedEnabled',
'receiptMatchedEnabled',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(user.id, filtered)
return NextResponse.json({ data: settings })
}
@@ -1,9 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getVapidPublicKey } from '@/lib/push/web-push'
import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender'
/**
* GET /api/push/subscribe
* GET /api/extensions/push-notifications/subscribe
* Get the VAPID public key for client-side subscription
*/
export async function GET() {
@@ -20,7 +20,7 @@ export async function GET() {
}
/**
* POST /api/push/subscribe
* POST /api/extensions/push-notifications/subscribe
* Save a new push subscription
*/
export async function POST(request: Request) {
@@ -96,7 +96,7 @@ export async function POST(request: Request) {
}
/**
* DELETE /api/push/subscribe
* DELETE /api/extensions/push-notifications/subscribe
* Remove a push subscription
*/
export async function DELETE(request: Request) {
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findTransactionMatches } from '@/lib/receipts/receipt-matcher'
import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { Receipt, Transaction } from '@/types'
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
@@ -0,0 +1,29 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSRUCoverage } from '@/extensions/sru-export/sru-engine'
/**
* GET /api/extensions/sru-export/coverage
*
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
* and a list of accounts missing codes.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const coverage = await getSRUCoverage(user.id)
return NextResponse.json({ data: coverage })
} catch (err) {
console.error('Error fetching SRU coverage:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' },
{ status: 500 }
)
}
}
+150
View File
@@ -0,0 +1,150 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { aggregateBalancesBySRU } from '@/extensions/sru-export/sru-engine'
import {
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
SRU_CODE_DESCRIPTIONS,
} from '@/extensions/sru-export/sru-generator'
import type { SRUFormType } from '@/extensions/sru-export/sru-generator'
import type { EntityType } from '@/types'
/**
* GET /api/extensions/sru-export
*
* Generate SRU export from chart_of_accounts sru_code mappings.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for downloadable file
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
// Fetch company settings to determine entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, company_name, org_number')
.eq('user_id', user.id)
.single()
if (!settings) {
return NextResponse.json(
{ error: 'Company settings not found. Complete onboarding first.' },
{ status: 400 }
)
}
// Determine form type from entity type
const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE'
// Fetch fiscal period for date range
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', periodId)
.eq('user_id', user.id)
.single()
if (!period) {
return NextResponse.json(
{ error: 'Fiscal period not found' },
{ status: 404 }
)
}
// Aggregate balances by SRU code
const sruBalances = await aggregateBalancesBySRU(user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateGenericSRU({
formType,
orgNumber: settings.org_number,
companyName: settings.company_name || 'Okänt företag',
fiscalYearStart: period.period_start,
fiscalYearEnd: period.period_end,
sruBalances,
})
const validation = validateSRUFile(sruFile)
if (!validation.isValid) {
return NextResponse.json(
{ error: 'SRU validation failed', details: validation.errors },
{ status: 500 }
)
}
const sruContent = sruFileToString(sruFile)
const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON preview
const balancesArray = Array.from(sruBalances.values())
.sort((a, b) => a.sruCode.localeCompare(b.sruCode))
.map((b) => ({
sruCode: b.sruCode,
description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`,
amount: b.amount,
accounts: b.accounts,
}))
const warnings: string[] = []
if (!period.is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (balancesArray.length === 0) {
warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.')
}
return NextResponse.json({
data: {
formType,
entityType: settings.entity_type as EntityType,
companyName: settings.company_name,
orgNumber: settings.org_number,
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
},
balances: balancesArray,
warnings,
},
})
} catch (err) {
console.error('Error generating SRU export:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate SRU export' },
{ status: 500 }
)
}
}
+61 -62
View File
@@ -8,85 +8,84 @@ html, :root {
@media (prefers-color-scheme: dark) {
:root {
--background: 40 30% 97% !important;
--foreground: 30 10% 15% !important;
--card: 40 40% 99% !important;
--card-foreground: 30 10% 15% !important;
--popover: 40 40% 99% !important;
--popover-foreground: 30 10% 15% !important;
--primary: 28 40% 35% !important;
--primary-foreground: 40 40% 99% !important;
--secondary: 35 25% 93% !important;
--secondary-foreground: 30 10% 15% !important;
--muted: 35 15% 91% !important;
--muted-foreground: 30 8% 45% !important;
--accent: 18 45% 55% !important;
--accent-foreground: 40 40% 99% !important;
--destructive: 0 45% 55% !important;
--destructive-foreground: 40 40% 99% !important;
--border: 35 20% 88% !important;
--input: 35 20% 88% !important;
--ring: 28 40% 35% !important;
--background: 220 14% 96% !important;
--foreground: 224 12% 13% !important;
--card: 0 0% 100% !important;
--card-foreground: 224 12% 13% !important;
--popover: 0 0% 100% !important;
--popover-foreground: 224 12% 13% !important;
--primary: 222 47% 35% !important;
--primary-foreground: 0 0% 100% !important;
--secondary: 220 13% 93% !important;
--secondary-foreground: 224 12% 13% !important;
--muted: 218 12% 91% !important;
--muted-foreground: 218 8% 46% !important;
--accent: 213 72% 50% !important;
--accent-foreground: 0 0% 100% !important;
--destructive: 0 68% 50% !important;
--destructive-foreground: 0 0% 100% !important;
--border: 218 14% 89% !important;
--input: 218 14% 89% !important;
--ring: 222 47% 35% !important;
}
}
:root {
/* Nordic Warmth Color Palette */
/* Inspired by Swedish nature: forest, stone, linen, birch */
/* Clean Slate — Modern ERP Palette */
--background: 40 30% 97%; /* Warm paper white */
--foreground: 30 10% 15%; /* Soft charcoal, not harsh black */
--background: 220 14% 96%; /* Cool off-white #F2F4F7 */
--foreground: 224 12% 13%; /* Deep slate #1D2029 */
--card: 40 40% 99%; /* Natural white with warmth */
--card-foreground: 30 10% 15%;
--card: 0 0% 100%; /* Pure white */
--card-foreground: 224 12% 13%;
--popover: 40 40% 99%;
--popover-foreground: 30 10% 15%;
--popover: 0 0% 100%;
--popover-foreground: 224 12% 13%;
/* Primary - Leather Brown (warm, dark) */
--primary: 28 40% 35%; /* Dark leather brown */
--primary-foreground: 40 40% 99%;
/* Primary — Refined navy-blue */
--primary: 222 47% 35%; /* #304D83 */
--primary-foreground: 0 0% 100%;
/* Secondary - Warm Linen */
--secondary: 35 25% 93%; /* Soft linen/oat */
--secondary-foreground: 30 10% 15%;
/* Secondary — Cool pale gray */
--secondary: 220 13% 93%; /* #EBEDF1 */
--secondary-foreground: 224 12% 13%;
/* Muted - Stone */
--muted: 35 15% 91%; /* Warm stone gray */
--muted-foreground: 30 8% 45%; /* Soft warm gray */
/* Muted — Neutral gray */
--muted: 218 12% 91%; /* #E3E6EB */
--muted-foreground: 218 8% 46%; /* #6B7280 */
/* Accent - Terracotta */
--accent: 18 45% 55%; /* Muted terracotta/clay */
--accent-foreground: 40 40% 99%;
/* Accent — Clear blue for interactive highlights */
--accent: 213 72% 50%; /* #2373D8 */
--accent-foreground: 0 0% 100%;
/* Destructive - Muted Rose */
--destructive: 0 45% 55%; /* Soft dusty rose */
--destructive-foreground: 40 40% 99%;
/* Destructive — Clean red */
--destructive: 0 68% 50%; /* #D63232 */
--destructive-foreground: 0 0% 100%;
/* Borders & Input - Soft warm line */
--border: 35 20% 88%;
--input: 35 20% 88%;
--ring: 28 40% 35%;
/* Borders & Input */
--border: 218 14% 89%; /* #DEE1E7 */
--input: 218 14% 89%;
--ring: 222 47% 35%;
/* Radius - Subtle, refined */
/* Radius */
--radius: 0.5rem;
/* Success - Forest green */
--success: 150 30% 40%;
--success-foreground: 40 40% 99%;
/* Success — Clean green */
--success: 152 48% 38%; /* #329460 */
--success-foreground: 0 0% 100%;
/* Warning - Warm honey */
--warning: 38 70% 55%;
--warning-foreground: 30 10% 15%;
/* Warning — Clear amber */
--warning: 38 92% 50%; /* #F59E0B */
--warning-foreground: 224 12% 13%;
/* Custom tokens */
--font-display: var(--font-fraunces), Georgia, serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
/* Refined shadows */
--shadow-sm: 0 1px 2px rgba(30, 25, 20, 0.04);
--shadow-md: 0 4px 12px rgba(30, 25, 20, 0.06);
--shadow-lg: 0 12px 40px rgba(30, 25, 20, 0.08);
/* Shadows — cool-toned */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.08);
/* Animation */
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
@@ -151,7 +150,7 @@ body {
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 500;
letter-spacing: -0.02em;
letter-spacing: -0.015em;
font-optical-sizing: auto;
}
@@ -244,13 +243,13 @@ h1, h2, h3 {
/* Refined hover effects */
.hover-lift {
transition: transform var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Soft focus states */
+1 -1
View File
@@ -33,7 +33,7 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
themeColor: "#1a1a1a",
themeColor: "#304D83",
width: "device-width",
initialScale: 1,
};
-8
View File
@@ -86,13 +86,6 @@ export default async function RootPage() {
(inv) => inv.status === 'overdue'
).length
// Fetch mileage entries for schablonavdrag
const { data: mileageEntries } = await supabase
.from('mileage_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', startOfYear.split('T')[0])
// Fetch bank balance (if connected)
const { data: bankConnections } = await supabase
.from('bank_connections')
@@ -192,7 +185,6 @@ export default async function RootPage() {
unpaidVatTotal,
overdueInvoicesCount: overdueCount,
bankBalance,
mileageEntries: mileageEntries || [],
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
}}
@@ -25,6 +25,8 @@ export default function ChartOfAccounts() {
const [loading, setLoading] = useState(true)
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editingSRU, setEditingSRU] = useState<string | null>(null)
const [sruValue, setSruValue] = useState('')
useEffect(() => {
fetchAccounts()
@@ -37,6 +39,20 @@ export default function ChartOfAccounts() {
setLoading(false)
}
async function updateSRUCode(accountId: string, newSruCode: string) {
const supabase = createClient()
const trimmed = newSruCode.trim() || null
await supabase
.from('chart_of_accounts')
.update({ sru_code: trimmed })
.eq('id', accountId)
setAccounts((prev) =>
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
)
setEditingSRU(null)
}
const toggleClass = (cls: number) => {
const next = new Set(expandedClasses)
if (next.has(cls)) {
@@ -121,6 +137,7 @@ export default function ChartOfAccounts() {
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-24 text-center">Normal</th>
</tr>
@@ -135,6 +152,32 @@ export default function ChartOfAccounts() {
>
<td className="py-2 font-mono">{account.account_number}</td>
<td className="py-2">{account.account_name}</td>
<td className="py-2 text-center">
{editingSRU === account.id ? (
<Input
value={sruValue}
onChange={(e) => setSruValue(e.target.value)}
onBlur={() => updateSRUCode(account.id, sruValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateSRUCode(account.id, sruValue)
if (e.key === 'Escape') setEditingSRU(null)
}}
className="h-7 w-16 text-xs text-center px-1"
autoFocus
/>
) : (
<button
className="text-xs font-mono text-muted-foreground hover:text-foreground cursor-pointer min-w-[2rem]"
onClick={(e) => {
e.stopPropagation()
setEditingSRU(account.id)
setSruValue(account.sru_code || '')
}}
>
{account.sru_code || '—'}
</button>
)}
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{account.account_type === 'asset'
+1 -1
View File
@@ -45,7 +45,7 @@ export default function JournalEntryList({ periodId }: Props) {
const statusLabel = (status: string) => {
switch (status) {
case 'posted':
return <Badge className="bg-green-100 text-green-800">Bokförd</Badge>
return <Badge variant="success">Bokförd</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
case 'reversed':
+11 -11
View File
@@ -120,14 +120,14 @@ export function CalendarDayView({
{paidInvoices.map((invoice) => (
<div
key={invoice.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/30"
className="p-2 rounded-md bg-success/10 border border-success/30"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0" />
<div className="w-3 h-3 rounded-full bg-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700">
<div className="text-sm font-medium text-success">
Faktura {invoice.invoice_number}
<span className="ml-2 text-xs bg-green-500/20 px-1.5 py-0.5 rounded">
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
Betald
</span>
</div>
@@ -151,18 +151,18 @@ export function CalendarDayView({
'p-2 rounded-md',
isDeadlineOverdue(deadline)
? 'bg-destructive/10 border border-destructive/30'
: 'bg-orange-500/10 border border-orange-500/30'
: 'bg-warning/10 border border-warning/30'
)}
>
<div className="flex items-center gap-2">
<div className={cn(
'w-3 h-3 rounded-sm flex-shrink-0',
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-orange-500'
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-warning'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-orange-700'
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-warning-foreground'
)}>
{deadline.title}
{isDeadlineOverdue(deadline) && (
@@ -197,17 +197,17 @@ export function CalendarDayView({
{completedDeadlines.map((deadline) => (
<div
key={deadline.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/30 opacity-60"
className="p-2 rounded-md bg-success/10 border border-success/30 opacity-60"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm bg-green-500 flex-shrink-0" />
<div className="w-3 h-3 rounded-sm bg-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700 line-through">
<div className="text-sm font-medium text-success line-through">
{deadline.title}
</div>
<div className="text-xs text-muted-foreground">
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
<span className="ml-2 bg-green-500/20 px-1.5 py-0.5 rounded">Klar</span>
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded">Klar</span>
</div>
</div>
</div>
+1 -1
View File
@@ -114,7 +114,7 @@ export function CalendarWeekView({
'text-xs p-1 mb-1 rounded truncate',
isDeadlineOverdue(deadline)
? 'bg-destructive/20 text-destructive'
: 'bg-orange-500/20 text-orange-700'
: 'bg-warning/20 text-warning-foreground'
)}
title={deadline.title}
>
+1 -1
View File
@@ -76,7 +76,7 @@ export function DeadlineCard({
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-orange-500'
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-warning'
)}
/>
)}
+8 -8
View File
@@ -26,8 +26,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{/* Expected */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-primary" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förväntat</p>
@@ -41,8 +41,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{summary.overdueCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="h-4 w-4 text-destructive" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förfallen</p>
@@ -60,8 +60,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{/* Paid */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-success/10 flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-success" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Betald</p>
@@ -77,8 +77,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-warning/10 flex items-center justify-center">
<Clock className="h-4 w-4 text-warning" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Väntande</p>
+4 -4
View File
@@ -112,11 +112,11 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
}
return (
<Card className="border-orange-200 bg-orange-50/30">
<Card className="border-warning/30 bg-warning/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-orange-600" />
<FileText className="h-5 w-5 text-warning-foreground" />
Att göra - Skatt
</CardTitle>
<div className="flex gap-2">
@@ -143,7 +143,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
isOverdue
? 'border-destructive/50 bg-destructive/5'
: isActionNeeded
? 'border-orange-300 bg-orange-50'
? 'border-warning/40 bg-warning/10'
: 'border-border bg-background'
}`}
>
@@ -152,7 +152,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{isOverdue ? (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
) : (
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0 mt-0.5" />
<Clock className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{deadline.title}</p>
+2 -2
View File
@@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
try {
abortControllerRef.current = new AbortController()
const response = await fetch('/api/chat/stream', {
const response = await fetch('/api/extensions/ai-chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
setError(null)
try {
const response = await fetch(`/api/chat/sessions/${id}`)
const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`)
if (!response.ok) {
throw new Error('Failed to load session')
}
+143 -175
View File
@@ -5,13 +5,12 @@ import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import {
calculateEFTax,
calculateABTax,
getEnhancedTaxWarningStatus
} from '@/lib/tax/calculator'
import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
@@ -23,11 +22,9 @@ import {
ChevronDown,
ArrowRight,
Camera,
HelpCircle,
Users,
FileText,
} from 'lucide-react'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
@@ -43,7 +40,6 @@ interface DashboardContentProps {
unpaidVatTotal: number
overdueInvoicesCount: number
bankBalance: number | null
mileageEntries: MileageEntry[]
deadlines: Deadline[]
receiptQueue: ReceiptQueueSummary | null
}
@@ -58,19 +54,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const currentMonth = new Date().getMonth() + 1
const preliminaryTaxPaidYTD = preliminaryTaxMonthly * currentMonth
const schablonavdragSettings = (settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettings })?.schablonavdrag_settings || null
const schablonavdragSummary = getSchablonavdragSummary(
schablonavdragSettings,
summary.mileageEntries,
new Date().getFullYear(),
currentMonth
)
const totalTaxableIncome = summary.ytd.net
const taxEstimate =
entityType === 'enskild_firma'
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, schablonavdragSummary, summary.unpaidVatTotal)
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, null, summary.unpaidVatTotal)
: calculateABTax(totalTaxableIncome, 0, preliminaryTaxPaidYTD, summary.unpaidVatTotal)
const taxWarning = getEnhancedTaxWarningStatus(taxEstimate, preliminaryTaxMonthly, currentMonth)
@@ -91,24 +79,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.overdueInvoicesCount > 0) {
alertItems.push(
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-4 border-l-destructive hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-destructive/10">
<Receipt className="h-4 w-4 text-destructive" />
</div>
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} förfallna
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
<Badge variant="destructive" className="mt-1.5 animate-pulse">
Kräver åtgärd
</Badge>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -119,21 +102,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
alertItems.push(
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<Receipt className="h-4 w-4 text-warning-foreground" />
</div>
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} obetalda · {formatCurrency(summary.unpaidInvoicesTotal)}
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -144,21 +125,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.uncategorizedCount > 0) {
alertItems.push(
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
</div>
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-sm text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} okategoriserade
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -169,23 +148,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
alertItems.push(
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-l-4 border-l-primary hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-500/10">
<Camera className="h-4 w-4 text-blue-500" />
</div>
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-sm text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -207,34 +184,26 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
<div className="stagger-enter">
{/* Hero section - personalized greeting */}
<header className="mb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight mb-1">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? ` ${firstName}` : ''}{settings?.company_name ? `, ${settings.company_name}` : ''}
</h1>
<p className="text-muted-foreground text-lg">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
</p>
</div>
<Link href="/help" className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<HelpCircle className="h-4 w-4" />
Hjälp
</Link>
</div>
{/* Header */}
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground mt-1">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
</p>
</header>
{/* TODO section - actionable items */}
{/* Status pills */}
{(() => {
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date())
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
@@ -246,7 +215,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
}
if (summary.uncategorizedCount > 0) {
todoItems.push({ label: 'okategoriserade transaktioner', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
}
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
@@ -255,13 +224,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (todoItems.length === 0) return null
return (
<section className="mb-8">
<div className="flex flex-wrap gap-2">
<section className="mb-10">
<div className="flex flex-wrap gap-1.5">
{todoItems.map((item) => (
<Link key={item.href} href={item.href}>
<Badge
variant={item.variant === 'destructive' ? 'destructive' : item.variant === 'warning' ? 'outline' : 'secondary'}
className="px-3 py-1.5 text-sm cursor-pointer hover:opacity-80 transition-opacity"
className="px-2.5 py-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
>
{item.count} {item.label}
</Badge>
@@ -274,7 +243,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* New user checklist */}
{onboardingProgress && (
<section className="mb-8">
<section className="mb-10">
<NewUserChecklist
hasCustomers={onboardingProgress.hasCustomers}
hasInvoices={onboardingProgress.hasInvoices}
@@ -284,32 +253,41 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Upcoming deadlines - prominent placement */}
{/* Upcoming deadlines */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-8">
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Quick actions - moved up, as visual cards */}
<section id="quick-actions" className="mb-12">
<h2 className="font-display text-xl font-medium mb-6">Snabbåtgärder</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{/* Quick actions */}
<section id="quick-actions" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Snabbåtgärder</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link key={action.href} href={action.href} className="group">
<Card className={`h-full hover-lift text-center ${action.accent ? 'bg-primary/5 border-primary/20' : ''}`}>
<CardContent className="p-4 flex flex-col items-center gap-2">
<div className={`p-3 rounded-xl ${action.accent ? 'bg-primary/10' : 'bg-muted/50'} group-hover:scale-105 transition-transform`}>
<Icon className={`h-5 w-5 ${action.accent ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground hidden md:block">{action.desc}</p>
</div>
</CardContent>
</Card>
<div className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150',
action.accent
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
)}>
<div className={cn(
'p-2 rounded-lg',
action.accent ? 'bg-primary/8' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-4 w-4',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
</div>
</div>
</Link>
)
})}
@@ -317,24 +295,24 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
{/* F-skatt warning */}
<section id="fskatt-section" className="mb-8">
<section id="fskatt-section" className="mb-10">
<FSkattWarningCard
warningStatus={taxWarning}
onAdjustClick={() => { window.location.href = '/settings' }}
/>
</section>
{/* Alerts section - with urgency indicators and limit */}
{/* Alerts section */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-16">
<h2 className="font-display text-xl font-medium mb-6">Att hantera</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<div className="grid gap-3 md:grid-cols-2">
{visibleAlerts}
</div>
{hasMoreAlerts && !showAllAlerts && (
<button
onClick={() => setShowAllAlerts(true)}
className="mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla ({alertItems.length})
<ChevronDown className="h-3 w-3" />
@@ -345,84 +323,74 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-8">
<Link href="/transactions?tab=uncategorized">
<Card className="border-warning/50 bg-warning/5 hover:border-warning transition-colors">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
</div>
<div className="flex-1">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner påverkar ditt resultat
</p>
<p className="text-sm text-muted-foreground mt-1">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} i potentiella intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ' och '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} i potentiella kostnader</span>
)}
{' '}visas inte i resultatet. Kategorisera dem för korrekt redovisning.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
</div>
</CardContent>
</Card>
<section className="mb-10">
<Link href="/transactions?tab=uncategorized" className="group">
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
)}
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
</div>
</Link>
</section>
)}
{/* Income/Expenses - Side by side editorial */}
<section className="mb-16">
<h2 className="font-display text-xl font-medium mb-6">Resultat</h2>
<div className="grid md:grid-cols-2 gap-6">
<Card className="hover-lift">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="h-4 w-4 text-success" />
{/* Income/Expenses */}
<section className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
<p className="font-display text-3xl font-medium tabular-nums">
{formatLargeNumber(summary.mtd.income)}
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
</p>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(summary.ytd.income)}
</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<TrendingDown className="h-4 w-4 text-destructive" />
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
<p className="font-display text-3xl font-medium tabular-nums">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
</p>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(summary.ytd.expenses)}
</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</div>
</CardContent>
+36 -134
View File
@@ -15,16 +15,12 @@ import {
BarChart3,
Settings,
LogOut,
Calculator,
Upload,
Calendar,
Camera,
Menu,
X,
HelpCircle,
ChevronDown,
ChevronUp,
Building2,
} from 'lucide-react'
import type { EntityType } from '@/types'
@@ -48,10 +44,7 @@ const navItems: NavItem[] = [
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
{ href: '/modules', label: 'Företagsmoduler', icon: Building2, group: 'moduler' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
@@ -61,7 +54,6 @@ const navItems: NavItem[] = [
const groupLabels: Record<string, string> = {
main: 'Huvudmeny',
finans: 'Finans',
moduler: 'Företagsmoduler',
övrigt: 'Övrigt',
}
@@ -93,40 +85,35 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
const mainItems = filteredItems.filter(i => i.group === 'main')
const finansItems = filteredItems.filter(i => i.group === 'finans')
const modulerItems = filteredItems.filter(i => i.group === 'moduler')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-60 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/40 bg-card">
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
{/* Logo */}
<div className="px-6 mb-10">
<h1 className="font-display text-lg font-medium tracking-tight truncate">
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-[232px] md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/80">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company name */}
<div className="px-5 mb-8">
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
{companyName}
</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
Ekonomi
</p>
</div>
{/* Navigation with group headers */}
<nav className="flex-1 px-3" aria-label="Huvudnavigation">
{/* Huvudmeny group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
{groupLabels.main}
</p>
<div className="space-y-0.5">
<div className="space-y-px">
{mainItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -135,15 +122,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -152,15 +139,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Finans group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
{groupLabels.finans}
</p>
<div className="space-y-0.5">
<div className="space-y-px">
{finansItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -169,15 +153,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -186,58 +170,20 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Moduler group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{groupLabels.moduler}
</p>
<div className="space-y-0.5">
{modulerItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</Link>
)
})}
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Övrigt group - collapsible */}
<div className="mb-4">
<button
onClick={() => setIsOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
{isOvrigtExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isOvrigtExpanded && "rotate-180"
)} />
</button>
{isOvrigtExpanded && (
<div className="space-y-0.5 animate-fade-in">
<div className="space-y-px animate-fade-in">
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -246,15 +192,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -267,13 +213,13 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
{/* Logout button */}
<div className="flex-shrink-0 p-4 border-t border-border/40">
<div className="flex-shrink-0 px-3 py-3 border-t border-border/20">
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground text-sm"
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
onClick={handleLogout}
>
<LogOut className="mr-3 h-4 w-4" />
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
Logga ut
</Button>
</div>
@@ -286,23 +232,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
{mobileNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const isScan = 'isScan' in item && item.isScan
if (isScan) {
return (
<Link
key={item.href}
href={item.href}
aria-label="Skanna kvitto"
className="flex flex-col items-center justify-center flex-1 h-full -mt-3"
>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-foreground shadow-md">
<Icon className="h-6 w-6" />
</div>
<span className="text-[10px] mt-0.5 text-accent font-medium">{item.label}</span>
</Link>
)
}
return (
<Link
@@ -424,33 +353,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
})}
</div>
{/* Moduler section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Företagsmoduler
</p>
{modulerItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<Icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</div>
{/* Other section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
+3 -3
View File
@@ -44,9 +44,9 @@ export default function FSkattWarningCard({
},
info: {
icon: Info,
iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20',
borderColor: 'border-blue-200 dark:border-blue-800',
iconColor: 'text-primary',
bgColor: 'bg-primary/5',
borderColor: 'border-primary/20',
badgeVariant: 'outline' as const,
},
warning: {
-87
View File
@@ -1,87 +0,0 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
interface ModuleToggleProps {
sectorSlug: string
moduleSlug: string
}
export function ModuleToggle({ sectorSlug, moduleSlug }: ModuleToggleProps) {
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [isPending, startTransition] = useTransition()
const supabase = createClient()
useEffect(() => {
async function fetchToggle() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setLoading(false)
return
}
const { data } = await supabase
.from('module_toggles')
.select('enabled')
.eq('user_id', user.id)
.eq('sector_slug', sectorSlug)
.eq('module_slug', moduleSlug)
.maybeSingle()
setEnabled(data?.enabled ?? false)
setLoading(false)
}
fetchToggle()
}, [sectorSlug, moduleSlug, supabase])
function handleToggle(checked: boolean) {
setEnabled(checked)
startTransition(async () => {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
await supabase
.from('module_toggles')
.upsert(
{
user_id: user.id,
sector_slug: sectorSlug,
module_slug: moduleSlug,
enabled: checked,
},
{ onConflict: 'user_id,sector_slug,module_slug' }
)
})
}
if (loading) {
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card px-5 py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Laddar…</span>
</div>
)
}
return (
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-4 cursor-pointer transition-colors hover:border-primary/20">
<div>
<p className="text-sm font-medium text-foreground">Aktivera modul</p>
<p className="text-xs text-muted-foreground mt-0.5">
{enabled ? 'Modulen är aktiverad för ditt konto' : 'Aktivera för att använda modulen'}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isPending}
/>
</label>
)
}
@@ -1,204 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { SCHABLONAVDRAG_RATES, type HousingType } from '@/lib/tax/schablonavdrag'
import { Home, Car, Loader2, Info, Building } from 'lucide-react'
import type { SchablonavdragSettings as SchablonavdragSettingsType } from '@/types'
interface SchablonavdragSettingsProps {
settings: SchablonavdragSettingsType
onSave: (settings: SchablonavdragSettingsType) => Promise<void>
}
export default function SchablonavdragSettings({
settings: initialSettings,
onSave,
}: SchablonavdragSettingsProps) {
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<SchablonavdragSettingsType>(initialSettings)
const hasChanges =
settings.hemmakontor_enabled !== initialSettings.hemmakontor_enabled ||
settings.hemmakontor_housing_type !== initialSettings.hemmakontor_housing_type ||
settings.bil_enabled !== initialSettings.bil_enabled
// Get the current hemmakontor amount based on housing type
const housingType = settings.hemmakontor_housing_type || 'apartment'
const hemmakontorAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
async function handleSave() {
setIsSaving(true)
try {
await onSave(settings)
toast({
title: 'Sparat',
description: 'Dina schablonavdragsinställningar har uppdaterats',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara inställningar',
variant: 'destructive',
})
}
setIsSaving(false)
}
function setHousingType(type: HousingType) {
setSettings({ ...settings, hemmakontor_housing_type: type })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Schablonavdrag
</CardTitle>
<CardDescription>
Aktivera schablonmässiga avdrag som inte kräver kvitton
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Hemmakontor */}
<div className="p-4 border rounded-lg space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Home className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<Label htmlFor="hemmakontor" className="text-base font-medium">
Hemmakontor
</Label>
<p className="text-sm text-muted-foreground">
Schablonavdrag för hemmakontor (minst 800 tim/år)
</p>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-success">
{formatCurrency(hemmakontorAmount)}
</span>
<span className="text-muted-foreground">per år</span>
</div>
</div>
</div>
<Switch
id="hemmakontor"
checked={settings.hemmakontor_enabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, hemmakontor_enabled: checked })
}
/>
</div>
{/* Housing type selection */}
{settings.hemmakontor_enabled && (
<div className="ml-14 space-y-2">
<Label className="text-sm text-muted-foreground">Typ av bostad</Label>
<div className="flex gap-2">
<Button
type="button"
variant={housingType === 'apartment' ? 'default' : 'outline'}
size="sm"
onClick={() => setHousingType('apartment')}
className="flex items-center gap-2"
>
<Building className="h-4 w-4" />
Hyresrätt/Bostadsrätt
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)})</span>
</Button>
<Button
type="button"
variant={housingType === 'villa' ? 'default' : 'outline'}
size="sm"
onClick={() => setHousingType('villa')}
className="flex items-center gap-2"
>
<Home className="h-4 w-4" />
Villa
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)})</span>
</Button>
</div>
</div>
)}
</div>
{/* Bilkostnader */}
<div className="flex items-start justify-between p-4 border rounded-lg">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Car className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<Label htmlFor="bil" className="text-base font-medium">
Bilkostnader (körjournal)
</Label>
<p className="text-sm text-muted-foreground">
Milersättning för tjänsteresor med egen bil
</p>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-success">
{SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr/mil
</span>
<span className="text-muted-foreground">
({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km)
</span>
</div>
</div>
</div>
<Switch
id="bil"
checked={settings.bil_enabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, bil_enabled: checked })
}
/>
</div>
{/* Info box */}
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/50">
<Info className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">Om schablonavdrag</p>
<ul className="mt-2 space-y-1 list-disc list-inside">
<li>
<strong>Hemmakontor:</strong> Kräver minst 800 timmars arbete hemifrån per år.
Avdrag: {formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)} (lägenhet) eller{' '}
{formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)} (villa).
</li>
<li>
<strong>Bilkostnader:</strong> Kräver att du loggar dina resor i körjournalen.
Du får {SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr per mil ({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km).
</li>
<li>Schablonavdrag minskar din beskattningsbara inkomst</li>
</ul>
</div>
</div>
{/* Save button */}
{hasChanges && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
-242
View File
@@ -1,242 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { SCHABLONAVDRAG_RATES, validateMileageEntry } from '@/lib/tax/schablonavdrag'
import { Car, Loader2, MapPin, Calendar, FileText, X } from 'lucide-react'
import type { CreateMileageEntryInput } from '@/types'
interface MileageEntryProps {
onSave: (entry: CreateMileageEntryInput) => Promise<void>
onCancel?: () => void
initialDate?: string
}
export default function MileageEntry({
onSave,
onCancel,
initialDate,
}: MileageEntryProps) {
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const today = new Date().toISOString().split('T')[0]
const [formData, setFormData] = useState<CreateMileageEntryInput>({
date: initialDate || today,
distance_km: 0,
purpose: '',
from_location: '',
to_location: '',
})
// Calculate deduction preview
const deductionPreview = formData.distance_km > 0
? formData.distance_km * SCHABLONAVDRAG_RATES.bil.rate_per_km
: 0
function handleChange(
field: keyof CreateMileageEntryInput,
value: string | number
) {
setFormData((prev) => ({ ...prev, [field]: value }))
setErrors([]) // Clear errors on change
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// Validate
const validation = validateMileageEntry({
date: formData.date,
distance_km: formData.distance_km,
purpose: formData.purpose,
})
if (!validation.valid) {
setErrors(validation.errors)
return
}
setIsSaving(true)
try {
await onSave(formData)
toast({
title: 'Körning registrerad',
description: `${formData.distance_km} km - avdrag ${formatCurrency(deductionPreview)}`,
})
// Reset form
setFormData({
date: today,
distance_km: 0,
purpose: '',
from_location: '',
to_location: '',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara körningen',
variant: 'destructive',
})
}
setIsSaving(false)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Car className="h-5 w-5" />
Registrera körning
</CardTitle>
<CardDescription>
Logga en tjänsteresa för milersättning
</CardDescription>
</div>
{onCancel && (
<Button variant="ghost" size="icon" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error display */}
{errors.length > 0 && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<ul className="text-sm text-destructive space-y-1">
{errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</div>
)}
{/* Date and distance row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Datum
</Label>
<Input
id="date"
type="date"
value={formData.date}
max={today}
onChange={(e) => handleChange('date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="distance_km">Avstånd (km)</Label>
<Input
id="distance_km"
type="number"
step="0.1"
min="0.1"
max="10000"
placeholder="0"
value={formData.distance_km || ''}
onChange={(e) =>
handleChange('distance_km', parseFloat(e.target.value) || 0)
}
required
/>
</div>
</div>
{/* Locations row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from_location" className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Från
</Label>
<Input
id="from_location"
placeholder="T.ex. Hemma"
value={formData.from_location || ''}
onChange={(e) => handleChange('from_location', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="to_location" className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Till
</Label>
<Input
id="to_location"
placeholder="T.ex. Kundens kontor"
value={formData.to_location || ''}
onChange={(e) => handleChange('to_location', e.target.value)}
/>
</div>
</div>
{/* Purpose */}
<div className="space-y-2">
<Label htmlFor="purpose" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Ändamål
</Label>
<Textarea
id="purpose"
placeholder="Beskriv syftet med resan, t.ex. kundmöte, fotografering, leverans..."
value={formData.purpose}
onChange={(e) => handleChange('purpose', e.target.value)}
rows={2}
required
/>
</div>
{/* Deduction preview */}
{formData.distance_km > 0 && (
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Beräknat avdrag</p>
<p className="text-xs text-muted-foreground">
{formData.distance_km} km × {SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km
</p>
</div>
<span className="text-xl font-bold text-success">
{formatCurrency(deductionPreview)}
</span>
</div>
</div>
)}
{/* Submit button */}
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Avbryt
</Button>
)}
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara körning'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}
@@ -341,13 +341,13 @@ export default function SwipeCategorizationView({
{/* Potential Invoice Match */}
{currentTransaction.potential_invoice && (
<div className="p-4 rounded-lg border-2 border-green-500/50 bg-green-50/50 dark:bg-green-950/20 space-y-2">
<div className="p-4 rounded-lg border-2 border-success/40 bg-success/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-green-600">
<div className="flex items-center gap-2 text-success">
<FileText className="h-5 w-5" />
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
</div>
<Badge variant="outline" className="text-green-600 border-green-600">
<Badge variant="outline" className="text-success border-success">
Match
</Badge>
</div>
@@ -358,7 +358,7 @@ export default function SwipeCategorizationView({
<p className="text-muted-foreground">
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
</p>
<p className="font-medium text-green-600">
<p className="font-medium text-success">
{formatCurrency(
currentTransaction.potential_invoice.total,
currentTransaction.potential_invoice.currency
@@ -410,7 +410,7 @@ export default function SwipeCategorizationView({
{/* Invoice match button - primary action when there's a match */}
{currentTransaction.potential_invoice && onMatchInvoice && (
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
className="w-full bg-success hover:bg-success/90 text-success-foreground"
onClick={handleMatchInvoice}
disabled={isProcessing}
>
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm transition-shadow duration-300 hover:shadow-md",
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[0_1px_3px_rgba(30,25,20,0.04)]",
className
)}
{...props}
+3 -6
View File
@@ -47,11 +47,8 @@ export function EmptyState({
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
{Icon && (
<div className="relative mb-6">
{/* Background pattern */}
<div className="absolute inset-0 -m-4 rounded-full bg-gradient-to-br from-secondary/60 to-muted/30 blur-xl" />
{/* Icon container */}
<div className="relative p-5 rounded-full bg-gradient-to-br from-muted/80 to-secondary/50 ring-1 ring-border/20">
<div className="mb-6">
<div className="p-5 rounded-full bg-muted">
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
</div>
@@ -63,7 +60,7 @@ export function EmptyState({
<div className="flex flex-col sm:flex-row items-center gap-3">
{actionHref && actionLabel && (
<Link href={actionHref}>
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground">
<Button>
<Plus className="mr-2 h-4 w-4" />
{actionLabel}
</Button>
+3 -3
View File
@@ -8,11 +8,11 @@ interface PageHeaderProps {
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<h1 className="font-display text-2xl font-medium tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground text-balance">{description}</p>
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
)}
</div>
{action && <div>{action}</div>}
+15
View File
@@ -239,6 +239,21 @@ create table extension_data (
The base never imports from `extensions/`. Dependency flows one direction: extensions import from `lib/core/`, `lib/events/`, `lib/extensions/`.
### Type ownership
Extension-specific types live in their extension directory (e.g. `extensions/ne-bilaga/types.ts`). For convenience, `types/index.ts` re-exports them so existing importers continue to work with `import type { NEDeclaration } from '@/types'`. The canonical source is always the extension file.
| Extension | Type file | Types |
|-----------|-----------|-------|
| `push-notifications` | `extensions/push-notifications/types.ts` | `PushSubscription`, `NotificationSettings`, `NotificationType`, `NotificationLog` |
| `receipt-ocr` | `extensions/receipt-ocr/types.ts` | `Receipt`, `ReceiptLineItem`, `ReceiptExtractionResult`, `ExtractedLineItem`, `ReceiptMatchCandidate`, `ReceiptQueueSummary`, `CameraQualityFeedback`, etc. |
| `ne-bilaga` | `extensions/ne-bilaga/types.ts` | `NEDeclaration`, `NEDeclarationRutor`, `NEAccountMapping`, `SRURecord`, `SRUFile`, `NE_RUTA_LABELS` |
| `sru-export` | `extensions/sru-export/types.ts` | `SRUExportResult`, `SRUCoverageStats` |
### API route convention
All extension API routes live under `app/api/extensions/<extension-name>/`. No extension routes should exist outside this namespace.
---
## Add-ons
@@ -0,0 +1,260 @@
# NE-bilaga Extension
## Overview
The NE-bilaga extension generates the NE appendix (Näringsverksamhet) for income tax reporting of enskild firma (sole proprietorship) to Skatteverket. It maps BAS account balances to NE declaration rutor R1-R11 and optionally produces downloadable SRU files.
Only relevant for **enskild firma** (`entity_type = 'enskild_firma'`). The tab is hidden for AB users.
Previously embedded as core code in `lib/reports/ne-declaration.ts` and `app/api/reports/ne-declaration/route.ts`, the logic was extracted into a proper extension following the same pattern as `extensions/sru-export/`.
## Architecture
```
extensions/ne-bilaga/
├── index.ts Extension definition (registered in loader)
├── ne-engine.ts Account-to-ruta mapping and balance calculation
├── types.ts NE-specific type definitions (canonical source)
└── NEDeclarationView.tsx UI component for the NE-bilaga reports tab
app/api/extensions/ne-bilaga/
└── route.ts GET /api/extensions/ne-bilaga (json + sru)
```
### Relationship to SRU Export
The NE-bilaga extension and the SRU Export extension serve different purposes but share SRU file generation utilities:
```
NE-bilaga path (EF only):
ne-engine.ts (hard-coded R1-R11 account mappings) → lib/reports/sru-generator.ts
SRU Export path (EF + AB):
sru-engine.ts (reads sru_code from DB) → sru-generator.ts (any form type)
```
The NE-bilaga engine uses hard-coded account ranges to map balances to NE rutor. The SRU Export engine reads `sru_code` from `chart_of_accounts` for a more generic approach. Both use the shared `sruFileToString()` and `generateSRUFile()` from `lib/reports/sru-generator.ts`.
### Dead code removed
The backward-compatibility shim `lib/reports/ne-declaration.ts` (which re-exported from the extension) has been deleted. It had zero importers after the API route `app/api/reports/ne-declaration/route.ts` was removed earlier. All consumers now import directly from the extension or from `@/types`.
## NE Declaration Rutor
### Revenue (R1-R4)
| Ruta | Account Range | Description |
|---|---|---|
| R1 | 3000-3499 (excl 3100) | Forsaljning med moms (25%) |
| R2 | 3100, 3900, 3970-3980 | Momsfria intakter |
| R3 | 3200-3299 | Bil/bostadsforman |
| R4 | 8310-8330 | Ranteintakter |
### Expenses (R5-R10)
| Ruta | Account Range | Description |
|---|---|---|
| R5 | 4000-4990 | Varuinkop |
| R6 | 5000-6990, 7970 | Ovriga kostnader |
| R7 | 7000-7699 | Lonekostnader |
| R8 | 8400-8499 | Rantekostnader |
| R9 | 7820 | Avskrivningar fastighet |
| R10 | 7700-7899 (excl 7820) | Avskrivningar ovrigt |
### Result
| Ruta | Calculation | Description |
|---|---|---|
| R11 | (R1+R2+R3+R4) - (R5+R6+R7+R8+R9+R10) | Arets resultat |
### Gift handling
- Gifts **with** consideration: R1 (VAT-liable exchange transaction)
- Gifts **without** consideration: R2 via account 3900
- Deductible gifts: R6 via account 5460
## API Reference
### GET /api/extensions/ne-bilaga
Generate NE declaration for a fiscal period.
**Query parameters:**
| Parameter | Required | Description |
|---|---|---|
| `period_id` | Yes | Fiscal period UUID |
| `format` | No | `json` (default) or `sru` |
**Response (format=json):**
```json
{
"data": {
"fiscalYear": {
"id": "uuid",
"name": "2025",
"start": "2025-01-01",
"end": "2025-12-31",
"isClosed": false
},
"rutor": {
"R1": 150000,
"R2": 0,
"R3": 0,
"R4": 500,
"R5": 45000,
"R6": 30000,
"R7": 0,
"R8": 1200,
"R9": 0,
"R10": 5000,
"R11": 69300
},
"breakdown": {
"R1": {
"accounts": [
{ "accountNumber": "3001", "accountName": "Forsaljning tjanster 25%", "amount": 150000 }
],
"total": 150000
}
},
"companyInfo": {
"companyName": "Mitt Foretag",
"orgNumber": "801234-5678"
},
"warnings": ["Rakenskapsaret ar inte stangt. Siffrorna kan andras."]
}
}
```
**Response (format=sru):** Downloads a `.sru` file with `Content-Disposition: attachment`.
**Error responses:**
| Status | Condition |
|---|---|
| 400 | Missing `period_id` parameter |
| 401 | Not authenticated |
| 500 | Entity type is not `enskild_firma`, period not found, or database error |
## Engine Details
### Balance calculation
1. Fetch all **posted** journal entries for the given fiscal period
2. Sum `debit_amount - credit_amount` per account number
3. Match each account to an NE ruta using `NE_ACCOUNT_MAPPINGS`
4. For revenue accounts (credit-normal): negate the balance so positive = income
5. For expense accounts (debit-normal): use as-is
6. Round each ruta to whole kronor
7. Calculate R11 as total revenue minus total expenses
### Entity type guard
The engine throws if `company_settings.entity_type !== 'enskild_firma'`. This prevents accidental NE generation for AB entities.
### Warnings
The engine emits warnings when:
- The fiscal period is not closed (balances may change)
- No revenue or expenses were found (empty period)
## UI Integration
### NEDeclarationView component
The NE-bilaga UI lives in `extensions/ne-bilaga/NEDeclarationView.tsx` and is imported by the reports page:
```typescript
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
```
### Reports Page
The **NE-bilaga** tab in `/reports` is conditionally rendered based on `entity_type`:
- **Visible** when `entity_type === 'enskild_firma'`
- **Hidden** for all other entity types (e.g. `aktiebolag`)
The entity type is fetched from `GET /api/settings` on page load. The tab shows:
1. **Info card** with "Hamta NE-bilaga" and "Ladda ner SRU-fil" buttons
2. **Warnings card** (orange) if the period is open or no data was found
3. **Company info card** with company name, org number, and fiscal year badge
4. **Revenue table** (R1-R4) with expandable account-level detail
5. **Expenses table** (R5-R10) with expandable account-level detail
6. **Result card** (R11) showing net result with green/red color coding
Each ruta row is clickable to expand and show contributing accounts with individual amounts.
## Extension Registration
The extension is registered in `lib/extensions/loader.ts`:
```typescript
import { neBilagaExtension } from '@/extensions/ne-bilaga'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension,
neBilagaExtension, // ← added
]
```
The extension declares a single report type (`ne-bilaga`) for discovery via `extensionRegistry.getByCapability('reportTypes')`. It has no event handlers, no settings panel, and no sidebar items.
## Type Definitions
Canonical source: `extensions/ne-bilaga/types.ts` (re-exported from `types/index.ts` for convenience):
- **`NEDeclaration`** — Top-level response shape with fiscal year, rutor, breakdown, company info, warnings
- **`NEDeclarationRutor`** — Record of R1-R11 number values
- **`NEAccountMapping`** — Mapping config: ruta, account ranges (with exclusions), isExpense flag
- **`NE_RUTA_LABELS`** — Display labels for each ruta
- **`SRURecord`** — Single SRU field code + value pair
- **`SRUFile`** — Collection of SRU records with generation timestamp
## Files Changed
### Created
| File | Purpose |
|---|---|
| `extensions/ne-bilaga/index.ts` | Extension definition with report type registration |
| `extensions/ne-bilaga/ne-engine.ts` | Account mapping + balance calculation (moved from `lib/reports/`) |
| `extensions/ne-bilaga/types.ts` | Canonical type definitions (`NEDeclaration`, `NEDeclarationRutor`, etc.) |
| `extensions/ne-bilaga/NEDeclarationView.tsx` | UI component (extracted from reports page) |
| `app/api/extensions/ne-bilaga/route.ts` | API endpoint with json/sru format support |
### Modified
| File | Change |
|---|---|
| `lib/extensions/loader.ts` | Added `neBilagaExtension` to `FIRST_PARTY_EXTENSIONS` |
| `app/(dashboard)/reports/page.tsx` | Imports `NEDeclarationView` from extension instead of inlining it |
| `types/index.ts` | NE types replaced with re-exports from `extensions/ne-bilaga/types.ts` |
### Deleted
| File | Reason |
|---|---|
| `app/api/reports/ne-declaration/route.ts` | Replaced by `app/api/extensions/ne-bilaga/route.ts` |
| `lib/reports/ne-declaration.ts` | Dead backward-compat shim with zero importers |
### Reused (not modified)
| File | What was reused |
|---|---|
| `lib/reports/sru-generator.ts` | `generateSRUFile()`, `sruFileToString()`, `getSRUFilename()` |
## Verification
- `npx tsc --noEmit` — zero errors
- `GET /api/extensions/ne-bilaga?period_id=X&format=json` returns NE declaration data
- `GET /api/extensions/ne-bilaga?period_id=X&format=sru` downloads `.sru` file
- NE-bilaga tab visible for EF users, hidden for AB users
- Reports page renders identically to before for EF users
- Extension appears in `extensionRegistry.getAll()` and `extensionRegistry.getByCapability('reportTypes')`
- `next build` succeeds with zero errors
@@ -0,0 +1,312 @@
# Push Notifications Extension
## Overview
The `push-notifications` extension converts system events into instant push notifications delivered via the Web Push API. It is the third first-party extension, following `receipt-ocr` and `ai-categorization`.
The extension operates in two modes:
1. **Event-driven** — Instant notifications triggered by the event bus (`period.locked`, `receipt.matched`, etc.)
2. **Cron-based** — Scheduled checks for time-dependent conditions (upcoming tax deadlines, overdue invoices)
Both modes share a single send pipeline that handles settings checks, quiet hours, duplicate prevention, delivery, logging, and subscription cleanup.
---
## Architecture
```
Event Bus
|
+---------+---------+
| |
period.locked receipt.matched
invoice.sent receipt.extracted
period.year_closed
| |
v v
+-----------------------------+
| Event Handlers (index.ts) |
| gate: check setting |
| build: payload-builders |
+-------------+---------------+
|
v
+-----------------------------+ +---------------------------+
| sendNotificationToUser() | <---- | Cron Scheduler |
| (notification-sender.ts) | | (notification-scheduler) |
| | | tax deadlines, invoices |
| 1. push_enabled? | +---------------------------+
| 2. quiet hours? |
| 3. duplicate? |
| 4. get subscriptions |
| 5. web-push send |
| 6. log to notification_log |
| 7. disable 410 subs |
+-----------------------------+
```
### File Structure
```
extensions/push-notifications/
index.ts # Extension object, settings, 5 event handlers
notification-sender.ts # Unified send pipeline, VAPID config, helpers
payload-builders.ts # All notification payload constructors
notification-scheduler.ts # Cron-based tax deadline & invoice scheduling
app/api/extensions/push-notifications/
settings/route.ts # GET/PATCH settings API
cron/route.ts # Daily cron (imports from extension)
subscribe/route.ts # Subscription management (imports VAPID from extension)
```
---
## Event Subscriptions
| Event | Handler | Default Enabled | Notification Content |
|-------|---------|-----------------|---------------------|
| `period.locked` | `handlePeriodLocked` | Yes | "{period.name} har lasts" |
| `period.year_closed` | `handleYearClosed` | Yes | "Arsbokslut klart for {period.name}" |
| `invoice.sent` | `handleInvoiceSent` | No | "Faktura {number} skickad" |
| `receipt.extracted` | `handleReceiptExtracted` | Yes | "Kvitto analyserat: {merchant}" |
| `receipt.matched` | `handleReceiptMatched` | Yes | "Kvitto matchat mot transaktion" |
Each event handler follows the **gate pattern**:
1. Extract `userId` from event payload
2. Load extension settings from `extension_data`
3. Check if the specific notification type is enabled
4. Build the payload via `payload-builders.ts`
5. Call `sendNotificationToUser()` from the sender module
---
## Cron Scheduling
The cron endpoint (`GET /api/extensions/push-notifications/cron`) runs daily at 09:00 via Vercel Cron and handles two time-dependent notification types:
### Tax Deadlines
Queries the `deadlines` table for uncompleted tax deadlines due in 7 days, 1 day, or today. Checks the user-level `tax_deadlines_enabled` setting in `notification_settings` before sending.
### Invoice Reminders
Queries the `invoices` table for sent/overdue invoices due in 3 days, today, or overdue by 3/7 days. Checks the user-level `invoice_reminders_enabled` setting before sending.
Both call `sendNotificationToUser()` which handles the full pipeline (quiet hours, duplicate check, send, log).
---
## Settings
### Extension Settings (event-driven toggles)
Stored in the `extension_data` table under `extension_id = 'push-notifications'`, `key = 'settings'`.
```typescript
interface PushNotificationSettings {
periodLockedEnabled: boolean // default: true
periodYearClosedEnabled: boolean // default: true
invoiceSentEnabled: boolean // default: false
receiptExtractedEnabled: boolean // default: true
receiptMatchedEnabled: boolean // default: true
}
```
### First-Party Settings (existing tables)
The `notification_settings` table controls transport-level and category-level preferences:
| Column | Type | Purpose |
|--------|------|---------|
| `push_enabled` | boolean | Master push toggle |
| `tax_deadlines_enabled` | boolean | Cron: tax deadline notifications |
| `invoice_reminders_enabled` | boolean | Cron: invoice due/overdue |
| `quiet_start` | text | Quiet hours start (e.g., "21:00") |
| `quiet_end` | text | Quiet hours end (e.g., "08:00") |
Both layers are checked before sending. Extension settings gate event-driven notifications; `notification_settings` gates the transport and cron-based categories.
---
## API
### GET /api/extensions/push-notifications/settings
Returns the current user's event-driven notification toggles.
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": false,
"receiptExtractedEnabled": true,
"receiptMatchedEnabled": true
}
}
```
**Response 401:** `{ "error": "Unauthorized" }`
### PATCH /api/extensions/push-notifications/settings
Updates one or more event-driven notification toggles. Only the provided keys are updated; others remain unchanged.
**Request:**
```json
{
"receiptExtractedEnabled": false,
"invoiceSentEnabled": true
}
```
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": true,
"receiptExtractedEnabled": false,
"receiptMatchedEnabled": true
}
}
```
**Response 400:** `{ "error": "No valid settings provided" }`
**Response 401:** `{ "error": "Unauthorized" }`
**Allowed keys:** `periodLockedEnabled`, `periodYearClosedEnabled`, `invoiceSentEnabled`, `receiptExtractedEnabled`, `receiptMatchedEnabled`
---
## Notification Types
The `NotificationType` union is defined in `extensions/push-notifications/types.ts` (re-exported from `types/index.ts` for convenience):
| Type | Source | Description |
|------|--------|-------------|
| `tax_deadline` | Cron | Upcoming tax deadline |
| `invoice_due` | Cron | Invoice approaching due date |
| `invoice_overdue` | Cron | Invoice past due date |
| `period_locked` | Event | Fiscal period was locked |
| `period_year_closed` | Event | Year-end closing completed |
| `receipt_extracted` | Event | Receipt OCR completed |
| `receipt_matched` | Event | Receipt matched to transaction |
| `invoice_sent` | Event | Invoice was sent |
These are stored as text in `notification_log.notification_type` (no migration needed).
---
## Database Tables Used
No new tables or migrations were required. The extension uses existing tables:
| Table | Usage |
|-------|-------|
| `push_subscriptions` | User's active Web Push subscriptions |
| `notification_settings` | Transport-level and category-level preferences |
| `notification_log` | Sent notification history (duplicate prevention) |
| `extension_data` | Event-driven toggle settings |
| `deadlines` | Tax deadline queries (cron) |
| `invoices` | Invoice due/overdue queries (cron) |
---
## Send Pipeline
`sendNotificationToUser(supabase, userId, payload, notificationType, referenceId, daysBefore?)` executes the following steps:
1. **Settings check** — Load `notification_settings` for the user. If `push_enabled` is false, skip.
2. **Quiet hours** — Convert current time to Sweden timezone (`Europe/Stockholm`). If within the user's quiet hours window, skip.
3. **Duplicate check** — Query `notification_log` for a matching `(user_id, notification_type, reference_id, days_before)` tuple. If found, skip.
4. **Get subscriptions** — Fetch all active push subscriptions from `push_subscriptions`. If none, skip.
5. **Send** — Deliver via the `web-push` library to all subscriptions using `Promise.allSettled`.
6. **Log** — Insert a record into `notification_log` with `delivery_status: 'sent'`.
7. **Cleanup** — Any subscription returning HTTP 410 (Gone) is marked `is_active: false`.
Returns `{ sent: boolean, reason?: string }` where reason can be: `push_disabled`, `quiet_hours`, `duplicate`, `no_subscriptions`, `send_failed`, or `error`.
---
## Registration
The extension is registered in `lib/extensions/loader.ts` alongside the other first-party extensions:
```typescript
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
]
```
When `loadExtensions()` is called, the registry wires up all 5 event handlers to the event bus.
---
## Refactoring Notes
### Phase 1: Logic extraction (original)
The following files were deleted. All logic was moved into the extension:
| Deleted File | Logic Moved To |
|-------------|----------------|
| `lib/push/web-push.ts` | `extensions/push-notifications/notification-sender.ts` (VAPID config, send functions, types) + `extensions/push-notifications/payload-builders.ts` (payload constructors) |
| `lib/push/notification-scheduler.ts` | `extensions/push-notifications/notification-scheduler.ts` (cron scheduling) + `extensions/push-notifications/notification-sender.ts` (quiet hours, duplicate check, logging, subscription management) |
### Phase 2: Route relocation
The API routes were moved from `app/api/push/` into the extension namespace to enforce the architecture rule that the core never depends on extension code:
| Old Path | New Path |
|----------|----------|
| `app/api/push/cron/route.ts` | `app/api/extensions/push-notifications/cron/route.ts` |
| `app/api/push/subscribe/route.ts` | `app/api/extensions/push-notifications/subscribe/route.ts` |
Updated consumers:
- `components/push/PushPrompt.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `components/settings/NotificationSettings.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `vercel.json` — cron path updated to `/api/extensions/push-notifications/cron`
### Phase 3: Type extraction
Push notification types (`PushSubscription`, `NotificationSettings`, `NotificationType`, `NotificationLog`) were moved from `types/index.ts` to `extensions/push-notifications/types.ts`. Re-exports in `types/index.ts` preserve backward compatibility.
---
## Environment Variables
The extension requires the following environment variables (unchanged from before):
| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | VAPID public key for client-side subscription |
| `VAPID_PRIVATE_KEY` | VAPID private key for server-side sending |
| `VAPID_SUBJECT` | VAPID subject (defaults to `mailto:support@erp-base.se`) |
| `CRON_SECRET` | Secret for authenticating cron requests |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (used by cron service client) |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (used by cron service client) |
---
## Testing
### Automated
- `npx tsc --noEmit` — Zero TypeScript errors
- `npx vitest run` — All 78 existing tests pass
### Manual Verification
1. **Period locked** — Lock a period via the UI. If the user has an active push subscription and `periodLockedEnabled` is true, they receive a push notification.
2. **Receipt extracted** — Upload a receipt image. After OCR completes, the `receipt.extracted` event fires and triggers a push notification.
3. **Receipt matched** — When a receipt is auto-matched to a transaction, the `receipt.matched` event triggers a push notification.
4. **Invoice sent** — Send an invoice. If `invoiceSentEnabled` is true (default: false), a push notification is sent.
5. **Year closed** — Complete a year-end closing. The `period.year_closed` event triggers a push notification.
6. **Settings API** — `GET /api/extensions/push-notifications/settings` returns default settings. `PATCH` with `{ "receiptExtractedEnabled": false }` disables that specific notification type.
7. **Cron** — `GET /api/extensions/push-notifications/cron` (with Bearer token) runs the tax deadline and invoice reminder checks using the extension's scheduler module.
8. **Disable toggle** — Set `receiptExtractedEnabled: false` via PATCH, then upload a receipt. No push notification should be sent for the extraction event.
@@ -0,0 +1,257 @@
# SRU Export Extension
## Overview
The SRU Export extension generates SRU (Standardiserat Räkenskapsutdrag) files for electronic tax filing with Skatteverket. It reads `sru_code` from `chart_of_accounts`, aggregates balances by SRU code, and produces downloadable `.sru` files.
Supports both:
- **NE** (Enskild firma) — field codes 7310-7350
- **INK2** (Aktiebolag) — field codes 7200-7499
The form type is determined automatically from the user's `entity_type` in `company_settings`.
## Architecture
```
extensions/sru-export/
├── index.ts Extension definition (registered in loader)
├── sru-engine.ts Balance aggregation by SRU code
├── sru-generator.ts Generic SRU file generation
├── types.ts SRU-specific type definitions (canonical source)
└── SRUExportView.tsx UI component for the SRU-export reports tab
app/api/extensions/sru-export/
├── route.ts GET /api/extensions/sru-export (export endpoint)
└── coverage/
└── route.ts GET /api/extensions/sru-export/coverage (stats)
```
### Relationship to NE-bilaga extension
The NE-bilaga extension (`extensions/ne-bilaga/`) handles the NE-specific declaration path with hard-coded R1-R11 account mappings, served via `/api/extensions/ne-bilaga`. See `dev_docs/base_architecture/NE-BILAGA-EXTENSION.md`.
The generic SRU export is an alternative path that works from raw `account → sru_code` mappings stored in `chart_of_accounts`, rather than hard-coded NE ruta mappings. The generic generator reuses `sruFileToString()` and `validateSRUFile()` from the shared module.
```
NE-bilaga path (EF only):
extensions/ne-bilaga/ne-engine.ts → lib/reports/sru-generator.ts (NE field codes)
Generic path (NE + INK2):
extensions/sru-export/sru-engine.ts (reads sru_code from DB) → sru-generator.ts (any form type)
```
## Database Changes
### Migration: `20240101000021_sru_codes.sql`
Applied as three remote migrations:
1. `add_sru_code_column` — `ALTER TABLE chart_of_accounts ADD COLUMN sru_code text`
2. `sru_codes_backfill` — Updates existing accounts with SRU codes based on account number ranges
3. `sru_codes_seed_function` — Updates `seed_chart_of_accounts()` to include `sru_code` for new users
The backfill only updates accounts where `sru_code IS NULL`, preserving any manual assignments.
### SRU Code Mappings
#### NE form (EF) — Revenue & Expense accounts
| Account Range | SRU Code | NE Ruta | Description |
|---|---|---|---|
| 3000-3499 (excl 3100) | 7310 | R1 | Försäljning med moms |
| 3100, 3900, 3970-3980 | 7311 | R2 | Momsfria intäkter |
| 3200-3299 | 7312 | R3 | Bil/bostadsförmån |
| 8310-8330 | 7313 | R4 | Ränteintäkter |
| 4000-4990 | 7320 | R5 | Varuinköp |
| 5000-6990, 7970 | 7321 | R6 | Övriga kostnader |
| 7000-7699 | 7322 | R7 | Lönekostnader |
| 8400-8499 | 7323 | R8 | Räntekostnader |
| 7820 | 7324 | R9 | Avskrivningar fastighet |
| 7700-7899 (excl 7820) | 7325 | R10 | Avskrivningar övrigt |
#### INK2 form (AB) — Balance Sheet accounts
| Account Range | SRU Code | Description |
|---|---|---|
| 1000-1099 | 7201 | Immateriella anläggningstillgångar |
| 1100-1299 | 7202 | Materiella anläggningstillgångar |
| 1300-1399 | 7203 | Finansiella anläggningstillgångar |
| 1400-1499 | 7210 | Varulager |
| 1500-1599 | 7211 | Kundfordringar |
| 1600-1999 | 7212 | Övriga omsättningstillgångar |
| 2081 | 7220 | Aktiekapital |
| 2085-2098 | 7221 | Övrigt eget kapital |
| 2099 | 7222 | Årets resultat |
| 2100-2499 | 7230 | Skulder |
| 2500-2999 | 7231 | Övriga skulder |
#### INK2 form (AB) — Income Statement accounts
| Account Range | SRU Code | Description |
|---|---|---|
| 3000-3999 | 7310 | Nettoomsättning |
| 4000-4999 | 7320 | Varuinköp |
| 5000-6999 | 7330 | Övriga externa kostnader |
| 7000-7699 | 7340 | Personalkostnader |
| 7700-7899 | 7350 | Avskrivningar |
| 7900-7999 | 7360 | Övriga rörelsekostnader |
| 8000-8499 | 7370 | Finansiella poster |
| 8500-8999 | 7380 | Extraordinära poster |
## API Reference
### GET /api/extensions/sru-export
Generate SRU export for a fiscal period.
**Query parameters:**
| Parameter | Required | Description |
|---|---|---|
| `period_id` | Yes | Fiscal period UUID |
| `format` | No | `json` (default) or `sru` |
**Response (format=json):**
```json
{
"data": {
"formType": "NE",
"entityType": "enskild_firma",
"companyName": "Mitt Företag AB",
"orgNumber": "556123-4567",
"fiscalYear": {
"id": "uuid",
"name": "2025",
"start": "2025-01-01",
"end": "2025-12-31"
},
"balances": [
{
"sruCode": "7310",
"description": "Försäljning med moms",
"amount": 150000,
"accounts": [
{ "accountNumber": "3001", "accountName": "Försäljning tjänster 25%", "amount": 150000 }
]
}
],
"warnings": ["Räkenskapsåret är inte stängt. Siffrorna kan ändras."]
}
}
```
**Response (format=sru):** Downloads a `.sru` file with `Content-Disposition: attachment`.
### GET /api/extensions/sru-export/coverage
Returns SRU code coverage statistics for the authenticated user's chart of accounts.
**Response:**
```json
{
"data": {
"totalAccounts": 30,
"accountsWithSRU": 28,
"accountsWithoutSRU": 2,
"coveragePercent": 93,
"missingAccounts": [
{ "accountNumber": "1220", "accountName": "Inventarier" }
]
}
}
```
## SRU File Format
The generated `.sru` file follows the Skatteverket standard:
```
#PRODUKT KONTROLLUPPGIFTER
#SESSION 1
#PROGRAMNAMN ERPBase
#PROGRAMVERSION 1.0
#SKAPAT 20260219
#BLANKETT NE
#IDENTITET 5561234567
#UPPGIFT 7000 20250101-20251231
#UPPGIFT 7310 150000
#UPPGIFT 7320 -45000
#UPPGIFT 7321 -30000
#BLANKETTSLUT
```
Each `#UPPGIFT` line contains an SRU field code and the rounded (whole kronor) amount. Zero-amount entries are omitted.
## UI Integration
### SRUExportView component
The SRU export UI lives in `extensions/sru-export/SRUExportView.tsx` and is imported by the reports page:
```typescript
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
```
### Reports Page
The **SRU-export** tab in `/reports` shows:
1. **Info card** with "Förhandsgranska" and "Ladda ner SRU-fil" buttons
2. **Coverage warning** if accounts lack SRU codes (fetched from `/coverage` endpoint)
3. **Company info card** showing entity type badge (NE/INK2) and fiscal year
4. **SRU balances table** with expandable rows showing per-account detail
### Chart of Accounts
A new **SRU** column is added to the accounts table in the Kontoplan view. The column is inline-editable: click a cell to type a new SRU code, press Enter to save, Escape to cancel. Updates go directly to `chart_of_accounts.sru_code` via the Supabase client.
## Extension Registration
The extension is registered in `lib/extensions/loader.ts`:
```typescript
import { sruExportExtension } from '@/extensions/sru-export'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension, // ← added
]
```
The extension has no event handlers, no settings panel, and no sidebar items. It only declares a report type for discovery purposes.
## Type Definitions
Canonical source: `extensions/sru-export/types.ts` (re-exported from `types/index.ts` for convenience):
- **`SRUExportResult`** — Response shape for the JSON format export endpoint
- **`SRUCoverageStats`** — Response shape for the coverage endpoint
## Files Changed
### Created
| File | Purpose |
|---|---|
| `supabase/migrations/20240101000021_sru_codes.sql` | Add column, backfill SRU codes, update seed function |
| `extensions/sru-export/index.ts` | Extension definition |
| `extensions/sru-export/sru-engine.ts` | Balance aggregation by SRU code |
| `extensions/sru-export/sru-generator.ts` | Generic SRU file generation |
| `extensions/sru-export/types.ts` | Canonical type definitions (`SRUExportResult`, `SRUCoverageStats`) |
| `extensions/sru-export/SRUExportView.tsx` | UI component (extracted from reports page) |
| `app/api/extensions/sru-export/route.ts` | Export API endpoint |
| `app/api/extensions/sru-export/coverage/route.ts` | Coverage stats endpoint |
### Modified
| File | Change |
|---|---|
| `lib/extensions/loader.ts` | Added `sruExportExtension` to `FIRST_PARTY_EXTENSIONS` |
| `app/(dashboard)/reports/page.tsx` | Imports `SRUExportView` from extension instead of inlining it |
| `types/index.ts` | SRU types replaced with re-exports from `extensions/sru-export/types.ts` |
| `components/bookkeeping/ChartOfAccounts.tsx` | Added inline-editable SRU code column |
### Reused (not modified)
| File | What was reused |
|---|---|
| `lib/reports/sru-generator.ts` | `sruFileToString()`, `validateSRUFile()` |
| `extensions/ne-bilaga/ne-engine.ts` | Balance calculation pattern |
| `lib/reports/sie-export.ts` | `calculateBalances()` pattern |
+22
View File
@@ -0,0 +1,22 @@
import type { Extension } from '@/lib/extensions/types'
/**
* AI Chat Extension
*
* Provides an AI-powered chatbot assistant for Swedish tax and bookkeeping
* questions. Uses RAG (Retrieval Augmented Generation) with a knowledge base
* of Swedish tax laws, regulations, and best practices.
*
* Components:
* - chatbot/: Chain, config, prompts, embeddings, retriever
* - ingestion/: CLI tool for ingesting knowledge base documents
*/
export const aiChatExtension: Extension = {
id: 'ai-chat',
name: 'AI-assistent',
version: '1.0.0',
settingsPanel: {
label: 'AI-assistent',
path: '/settings/extensions/ai-chat',
},
}
+296
View File
@@ -0,0 +1,296 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import type { NEDeclaration } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/extensions/ne-bilaga?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta NE-bilaga')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/extensions/ne-bilaga?period_id=${periodId}&format=sru`, '_blank')
}
// NE ruta labels
const rutaLabels: Record<string, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat',
}
// Categorize rutor
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
</p>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
{data.fiscalYear.name}
</Badge>
</div>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{revenueRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right">
{formatAmount(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{expenseRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
isExpense
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right">
-{formatAmount(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-bold text-xl">Årets resultat</span>
</div>
<span
className={`text-2xl font-bold ${
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatAmount(data.rutor.R11)} kr
</span>
</div>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta NE-bilaga&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
</div>
)
}
function NEDeclarationRow({
ruta,
label,
amount,
accounts,
isExpense,
}: {
ruta: string
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
// Don't show rows with zero values
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 font-mono">{acc.accountNumber}</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { Extension } from '@/lib/extensions/types'
/**
* NE-bilaga Extension
*
* Generates NE-bilaga (Näringsverksamhet) for enskild firma.
* Maps BAS account balances to NE declaration rutor (R1-R11)
* for tax reporting to Skatteverket.
*
* Only relevant for EF entity type. Hidden for AB.
*/
export const neBilagaExtension: Extension = {
id: 'ne-bilaga',
name: 'NE-bilaga',
version: '1.0.0',
reportTypes: [
{
id: 'ne-bilaga',
name: 'NE-bilaga',
description: 'NE-bilaga (Näringsverksamhet) för enskild firma',
},
],
}
+80
View File
@@ -0,0 +1,80 @@
// NE-bilaga rutor (NE appendix boxes)
export interface NEDeclarationRutor {
R1: number // Försäljning med 25% moms (3000-3499 excl 3100)
R2: number // Momsfria intäkter (3100, 3970, 3980)
R3: number // Bil/bostadsförmån (3200)
R4: number // Ränteintäkter (8310-8330)
R5: number // Varuinköp (4000-4990)
R6: number // Övriga kostnader (5000-6990, 7970)
R7: number // Lönekostnader (7000-7699)
R8: number // Räntekostnader (8400-8499)
R9: number // Avskrivningar fastighet (7820)
R10: number // Avskrivningar övrigt (7700-7899 excl 7820)
R11: number // Årets resultat (beräknat)
}
// NE account mapping configuration
export interface NEAccountMapping {
ruta: keyof NEDeclarationRutor
description: string
accountRanges: Array<{
start: string
end: string
exclude?: string[]
}>
isExpense: boolean // true = debit normal, false = credit normal
}
// NE declaration response
export interface NEDeclaration {
fiscalYear: {
id: string
name: string
start: string
end: string
isClosed: boolean
}
rutor: NEDeclarationRutor
// Detailed breakdown per ruta
breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
total: number
}>
// Company info for SRU
companyInfo: {
companyName: string
orgNumber: string | null
}
// Warnings
warnings: string[]
}
// SRU file format types
export interface SRURecord {
fieldCode: string
value: string | number
}
export interface SRUFile {
records: SRURecord[]
generatedAt: string
}
// Labels for NE rutor
export const NE_RUTA_LABELS: Record<keyof NEDeclarationRutor, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat'
}
@@ -117,7 +117,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
return
}
const vapidResponse = await fetch('/api/push/subscribe')
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
@@ -130,7 +130,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
const response = await fetch('/api/push/subscribe', {
const response = await fetch('/api/extensions/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
@@ -162,7 +162,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
if (subscription) {
await subscription.unsubscribe()
await fetch('/api/push/subscribe', {
await fetch('/api/extensions/push-notifications/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
@@ -63,7 +63,7 @@ export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
}
// Get VAPID public key
const vapidResponse = await fetch('/api/push/subscribe')
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
@@ -78,7 +78,7 @@ export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
})
// Send subscription to server
const response = await fetch('/api/push/subscribe', {
const response = await fetch('/api/extensions/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
+236
View File
@@ -0,0 +1,236 @@
/**
* Push Notifications Extension
*
* Converts system events into instant push notifications.
* Also provides cron-based scheduling for time-dependent checks
* (tax deadlines, invoice due/overdue).
*
* Event handlers follow the gate pattern:
* check extension setting -> build payload -> send via unified pipeline
*/
import { createClient } from '@/lib/supabase/server'
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import { sendNotificationToUser } from './notification-sender'
import {
createPeriodLockedPayload,
createYearClosedPayload,
createInvoiceSentPayload,
createReceiptExtractedPayload,
createReceiptMatchedPayload,
} from './payload-builders'
// ============================================================
// Settings
// ============================================================
export interface PushNotificationSettings {
periodLockedEnabled: boolean
periodYearClosedEnabled: boolean
invoiceSentEnabled: boolean
receiptExtractedEnabled: boolean
receiptMatchedEnabled: boolean
}
const DEFAULT_SETTINGS: PushNotificationSettings = {
periodLockedEnabled: true,
periodYearClosedEnabled: true,
invoiceSentEnabled: false,
receiptExtractedEnabled: true,
receiptMatchedEnabled: true,
}
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', 'push-notifications')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<PushNotificationSettings>
): Promise<PushNotificationSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'push-notifications',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handlers
// ============================================================
async function handlePeriodLocked(
payload: EventPayload<'period.locked'>
): Promise<void> {
const { period, userId } = payload
const settings = await getSettings(userId)
if (!settings.periodLockedEnabled) return
const supabase = await createClient()
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
const result = await sendNotificationToUser(
supabase,
userId,
notificationPayload,
'period_locked',
period.id
)
if (result.sent) {
console.log(`[push-notifications] Period locked notification sent for ${period.name}`)
}
}
async function handleYearClosed(
payload: EventPayload<'period.year_closed'>
): Promise<void> {
const { period, userId } = payload
const settings = await getSettings(userId)
if (!settings.periodYearClosedEnabled) return
const supabase = await createClient()
const notificationPayload = createYearClosedPayload(period.name, period.id)
const result = await sendNotificationToUser(
supabase,
userId,
notificationPayload,
'period_year_closed',
period.id
)
if (result.sent) {
console.log(`[push-notifications] Year closed notification sent for ${period.name}`)
}
}
async function handleInvoiceSent(
payload: EventPayload<'invoice.sent'>
): Promise<void> {
const { invoice, userId } = payload
const settings = await getSettings(userId)
if (!settings.invoiceSentEnabled) return
const supabase = await createClient()
const notificationPayload = createInvoiceSentPayload(
invoice.invoice_number,
invoice.id
)
const result = await sendNotificationToUser(
supabase,
userId,
notificationPayload,
'invoice_sent',
invoice.id
)
if (result.sent) {
console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`)
}
}
async function handleReceiptExtracted(
payload: EventPayload<'receipt.extracted'>
): Promise<void> {
const { receipt, userId } = payload
const settings = await getSettings(userId)
if (!settings.receiptExtractedEnabled) return
const supabase = await createClient()
const notificationPayload = createReceiptExtractedPayload(
receipt.merchant_name,
receipt.id
)
const result = await sendNotificationToUser(
supabase,
userId,
notificationPayload,
'receipt_extracted',
receipt.id
)
if (result.sent) {
console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`)
}
}
async function handleReceiptMatched(
payload: EventPayload<'receipt.matched'>
): Promise<void> {
const { receipt, transaction, userId } = payload
const settings = await getSettings(userId)
if (!settings.receiptMatchedEnabled) return
const supabase = await createClient()
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
const result = await sendNotificationToUser(
supabase,
userId,
notificationPayload,
'receipt_matched',
receipt.id
)
if (result.sent) {
console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`)
}
}
// ============================================================
// Extension Object
// ============================================================
export const pushNotificationsExtension: Extension = {
id: 'push-notifications',
name: 'Push-notiser',
version: '1.0.0',
eventHandlers: [
{ eventType: 'period.locked', handler: handlePeriodLocked },
{ eventType: 'period.year_closed', handler: handleYearClosed },
{ eventType: 'invoice.sent', handler: handleInvoiceSent },
{ eventType: 'receipt.extracted', handler: handleReceiptExtracted },
{ eventType: 'receipt.matched', handler: handleReceiptMatched },
],
settingsPanel: {
label: 'Push-notiser',
path: '/settings/extensions/push-notifications',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
},
}
@@ -0,0 +1,210 @@
/**
* Cron-based notification scheduling.
*
* Handles time-dependent checks that cannot be event-driven:
* - Tax deadlines approaching (7 days, 1 day, today)
* - Invoice due/overdue reminders (3 days before, on due date, 3/7 days overdue)
*
* Both functions call `sendNotificationToUser()` from the sender module
* instead of duplicating the send pipeline.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { NotificationType } from '@/types'
import { sendNotificationToUser } from './notification-sender'
import {
createTaxDeadlinePayload,
createInvoiceOverduePayload,
createInvoiceDuePayload,
} from './payload-builders'
/**
* Send tax deadline notifications.
* Checks for deadlines due in 7 days, 1 day, or today.
*/
export async function sendTaxDeadlineNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
const in7Days = new Date(today)
in7Days.setDate(in7Days.getDate() + 7)
const in7DaysStr = in7Days.toISOString().split('T')[0]
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
const { data: deadlines } = await supabase
.from('deadlines')
.select('id, user_id, title, due_date')
.eq('deadline_type', 'tax')
.eq('is_completed', false)
.in('status', ['upcoming', 'action_needed'])
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
if (!deadlines || deadlines.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userDeadlines = new Map<string, typeof deadlines>()
for (const deadline of deadlines) {
const list = userDeadlines.get(deadline.user_id) || []
list.push(deadline)
userDeadlines.set(deadline.user_id, list)
}
for (const [userId, userDls] of userDeadlines) {
// Check user-level tax_deadlines_enabled
const { data: settings } = await supabase
.from('notification_settings')
.select('tax_deadlines_enabled')
.eq('user_id', userId)
.single()
if (settings && !settings.tax_deadlines_enabled) {
skipped += userDls.length
continue
}
for (const deadline of userDls) {
const daysUntil = Math.ceil(
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
const payload = createTaxDeadlinePayload(
deadline.title,
deadline.due_date,
daysUntil,
deadline.id
)
const result = await sendNotificationToUser(
supabase,
userId,
payload,
'tax_deadline',
deadline.id,
daysUntil
)
if (result.sent) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
/**
* Send invoice reminder notifications.
* Checks for invoices due in 3 days, today, or overdue by 3/7 days.
*/
export async function sendInvoiceNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
const in3Days = new Date(today)
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().split('T')[0]
const daysAgo3 = new Date(today)
daysAgo3.setDate(daysAgo3.getDate() - 3)
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
const daysAgo7 = new Date(today)
daysAgo7.setDate(daysAgo7.getDate() - 7)
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
const { data: invoices } = await supabase
.from('invoices')
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
.in('status', ['sent', 'overdue'])
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
if (!invoices || invoices.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userInvoices = new Map<string, typeof invoices>()
for (const invoice of invoices) {
const list = userInvoices.get(invoice.user_id) || []
list.push(invoice)
userInvoices.set(invoice.user_id, list)
}
for (const [userId, userInvs] of userInvoices) {
// Check user-level invoice_reminders_enabled
const { data: settings } = await supabase
.from('notification_settings')
.select('invoice_reminders_enabled')
.eq('user_id', userId)
.single()
if (settings && !settings.invoice_reminders_enabled) {
skipped += userInvs.length
continue
}
for (const invoice of userInvs) {
const dueDate = new Date(invoice.due_date)
const daysUntil = Math.ceil(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
const isOverdue = daysUntil < 0
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
const customer = invoice.customer as unknown as { name: string } | null
const customerName = customer?.name || 'Okänd kund'
const payload = isOverdue
? createInvoiceOverduePayload(
invoice.invoice_number,
customerName,
invoice.total,
invoice.due_date,
invoice.id
)
: createInvoiceDuePayload(
invoice.invoice_number,
customerName,
invoice.total,
invoice.due_date,
invoice.id
)
const result = await sendNotificationToUser(
supabase,
userId,
payload,
notificationType,
invoice.id,
Math.abs(daysUntil)
)
if (result.sent) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
@@ -0,0 +1,298 @@
/**
* Unified push notification send pipeline.
*
* Both event handlers and the cron scheduler call `sendNotificationToUser()`.
* This module owns the full chain:
* settings check -> quiet hours -> duplicate check -> send -> log -> cleanup
*/
import webpush from 'web-push'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { NotificationSettings, NotificationType } from '@/types'
// ============================================================
// VAPID configuration
// ============================================================
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
if (vapidPublicKey && vapidPrivateKey) {
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
}
// ============================================================
// Types
// ============================================================
export interface PushSubscriptionKeys {
endpoint: string
p256dh: string
auth: string
}
export interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
tag?: string
data?: {
url?: string
type?: string
id?: string
}
actions?: Array<{
action: string
title: string
icon?: string
}>
}
// ============================================================
// Public API
// ============================================================
/**
* Get the public VAPID key for client-side subscription.
*/
export function getVapidPublicKey(): string | null {
return vapidPublicKey || null
}
/**
* Send a notification to a specific user.
*
* Performs the full pipeline:
* 1. Check push_enabled in notification_settings
* 2. Check quiet hours
* 3. Check duplicate via notification_log
* 4. Get user's push subscriptions
* 5. Send via web-push
* 6. Log to notification_log
* 7. Disable gone subscriptions (410)
*
* Returns { sent: boolean, reason?: string }
*/
export async function sendNotificationToUser(
supabase: SupabaseClient,
userId: string,
payload: NotificationPayload,
notificationType: NotificationType,
referenceId: string,
daysBefore: number = 0
): Promise<{ sent: boolean; reason?: string }> {
try {
// 1. Check notification settings
const settings = await getUserNotificationSettings(supabase, userId)
if (settings && !settings.push_enabled) {
return { sent: false, reason: 'push_disabled' }
}
// 2. Check quiet hours
if (settings && isWithinQuietHours(settings)) {
return { sent: false, reason: 'quiet_hours' }
}
// 3. Check duplicate
const alreadySent = await wasNotificationSent(
supabase,
userId,
notificationType,
referenceId,
daysBefore
)
if (alreadySent) {
return { sent: false, reason: 'duplicate' }
}
// 4. Get subscriptions
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
return { sent: false, reason: 'no_subscriptions' }
}
// 5. Send
const result = await sendPushToMultiple(subscriptions, payload)
// 6. Log
await logNotification(supabase, userId, notificationType, referenceId, daysBefore)
// 7. Cleanup gone subscriptions
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
return result.successful > 0
? { sent: true }
: { sent: false, reason: 'send_failed' }
} catch (error) {
console.error(`[push-notifications] sendNotificationToUser failed for ${userId}:`, error)
return { sent: false, reason: 'error' }
}
}
// ============================================================
// Internal helpers
// ============================================================
async function sendPushNotification(
subscription: PushSubscriptionKeys,
payload: NotificationPayload
): Promise<{ success: boolean; error?: string }> {
if (!vapidPublicKey || !vapidPrivateKey) {
console.error('VAPID keys not configured')
return { success: false, error: 'VAPID keys not configured' }
}
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}
try {
await webpush.sendNotification(pushSubscription, JSON.stringify(payload), {
TTL: 86400,
urgency: 'normal',
})
return { success: true }
} catch (error: unknown) {
const err = error as { statusCode?: number; message?: string }
if (err.statusCode === 410) {
return { success: false, error: 'subscription_gone' }
}
console.error('Push notification error:', err)
return { success: false, error: err.message || 'Unknown error' }
}
}
async function sendPushToMultiple(
subscriptions: PushSubscriptionKeys[],
payload: NotificationPayload
): Promise<{ successful: number; failed: number; goneSubscriptions: string[] }> {
let successful = 0
let failed = 0
const goneSubscriptions: string[] = []
const results = await Promise.allSettled(
subscriptions.map((sub) => sendPushNotification(sub, payload))
)
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
successful++
} else {
failed++
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
goneSubscriptions.push(subscriptions[index].endpoint)
}
}
})
return { successful, failed, goneSubscriptions }
}
export function isWithinQuietHours(
settings: NotificationSettings,
now: Date = new Date()
): boolean {
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now)
const [hours, minutes] = swedenTime.split(':').map(Number)
const currentMinutes = hours * 60 + minutes
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
const quietStartMinutes = startHours * 60 + startMinutes
const quietEndMinutes = endHours * 60 + endMinutes
if (quietStartMinutes > quietEndMinutes) {
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
}
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
}
async function wasNotificationSent(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<boolean> {
const { data } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', userId)
.eq('notification_type', notificationType)
.eq('reference_id', referenceId)
.eq('days_before', daysBefore)
.single()
return !!data
}
async function logNotification(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<void> {
await supabase.from('notification_log').insert({
user_id: userId,
notification_type: notificationType,
reference_id: referenceId,
days_before: daysBefore,
delivery_status: 'sent',
})
}
export async function getUserSubscriptions(
supabase: SupabaseClient,
userId: string
): Promise<PushSubscriptionKeys[]> {
const { data } = await supabase
.from('push_subscriptions')
.select('endpoint, p256dh, auth')
.eq('user_id', userId)
.eq('is_active', true)
return data || []
}
export async function getUserNotificationSettings(
supabase: SupabaseClient,
userId: string
): Promise<NotificationSettings | null> {
const { data } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', userId)
.single()
return data
}
async function disableGoneSubscriptions(
supabase: SupabaseClient,
endpoints: string[]
): Promise<void> {
if (endpoints.length === 0) return
await supabase
.from('push_subscriptions')
.update({ is_active: false })
.in('endpoint', endpoints)
}
@@ -0,0 +1,190 @@
/**
* Notification payload constructors for all push notification types.
*
* Centralises every notification's title/body/icon/action in one place.
* Both event handlers and the cron scheduler import from here.
*/
import type { NotificationPayload } from './notification-sender'
// ============================================================
// Helpers
// ============================================================
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// ============================================================
// Event-driven payloads
// ============================================================
export function createPeriodLockedPayload(
periodName: string,
periodId: string
): NotificationPayload {
return {
title: 'Period låst',
body: `${periodName} har låsts`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `period-locked-${periodId}`,
data: {
url: '/periods',
type: 'period_locked',
id: periodId,
},
}
}
export function createYearClosedPayload(
periodName: string,
periodId: string
): NotificationPayload {
return {
title: 'Årsbokslut klart',
body: `Årsbokslut klart för ${periodName}`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `year-closed-${periodId}`,
data: {
url: '/periods',
type: 'period_year_closed',
id: periodId,
},
}
}
export function createInvoiceSentPayload(
invoiceNumber: string,
invoiceId: string
): NotificationPayload {
return {
title: 'Faktura skickad',
body: `Faktura ${invoiceNumber} skickad`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-sent-${invoiceId}`,
data: {
url: `/invoices/${invoiceId}`,
type: 'invoice_sent',
id: invoiceId,
},
}
}
export function createReceiptExtractedPayload(
merchantName: string | null,
receiptId: string
): NotificationPayload {
const merchant = merchantName || 'Okänd butik'
return {
title: 'Kvitto analyserat',
body: `Kvitto analyserat: ${merchant}`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `receipt-extracted-${receiptId}`,
data: {
url: '/receipts',
type: 'receipt_extracted',
id: receiptId,
},
}
}
export function createReceiptMatchedPayload(
receiptId: string,
transactionId: string
): NotificationPayload {
return {
title: 'Kvitto matchat',
body: 'Kvitto matchat mot transaktion',
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `receipt-matched-${receiptId}`,
data: {
url: '/receipts',
type: 'receipt_matched',
id: receiptId,
},
}
}
// ============================================================
// Cron-based payloads (moved from lib/push/web-push.ts)
// ============================================================
export function createTaxDeadlinePayload(
title: string,
dueDate: string,
daysUntil: number,
deadlineId: string
): NotificationPayload {
const urgencyText =
daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
return {
title: `${title}`,
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `tax-deadline-${deadlineId}`,
data: {
url: '/calendar',
type: 'tax_deadline',
id: deadlineId,
},
actions: [
{ action: 'view', title: 'Visa' },
{ action: 'dismiss', title: 'Avfärda' },
],
}
}
export function createInvoiceOverduePayload(
invoiceNumber: string,
customerName: string,
amount: number,
dueDate: string,
invoiceId: string
): NotificationPayload {
return {
title: `Obetald faktura #${invoiceNumber}`,
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,
data: {
url: `/invoices/${invoiceId}`,
type: 'invoice_overdue',
id: invoiceId,
},
actions: [{ action: 'view', title: 'Visa faktura' }],
}
}
export function createInvoiceDuePayload(
invoiceNumber: string,
customerName: string,
amount: number,
dueDate: string,
invoiceId: string
): NotificationPayload {
return {
title: `Faktura #${invoiceNumber} förfaller`,
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,
data: {
url: `/invoices/${invoiceId}`,
type: 'invoice_due',
id: invoiceId,
},
actions: [{ action: 'view', title: 'Visa faktura' }],
}
}
+48
View File
@@ -0,0 +1,48 @@
// Push subscription for Web Push API
export interface PushSubscription {
id: string
user_id: string
endpoint: string
p256dh: string
auth: string
user_agent: string | null
is_active: boolean
last_used_at: string | null
created_at: string
}
// Notification settings per user
export interface NotificationSettings {
id: string
user_id: string
tax_deadlines_enabled: boolean
invoice_reminders_enabled: boolean
quiet_start: string // time format "HH:MM"
quiet_end: string // time format "HH:MM"
email_enabled: boolean
push_enabled: boolean
created_at: string
updated_at: string
}
// Notification type for logging
export type NotificationType =
| 'tax_deadline'
| 'invoice_due'
| 'invoice_overdue'
| 'period_locked'
| 'period_year_closed'
| 'receipt_extracted'
| 'receipt_matched'
| 'invoice_sent'
// Notification log entry
export interface NotificationLog {
id: string
user_id: string
notification_type: NotificationType
reference_id: string
days_before: number
sent_at: string
delivery_status: 'sent' | 'delivered' | 'failed'
}
@@ -42,7 +42,7 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
vi.mock('@/lib/receipts/receipt-analyzer', () => ({
vi.mock('../lib/receipt-analyzer', () => ({
analyzeReceipt: vi.fn().mockResolvedValue({
merchant: { name: 'ICA', orgNumber: null, vatNumber: null, isForeign: false },
receipt: { date: '2024-06-15', time: '14:30', currency: 'SEK' },
@@ -55,13 +55,13 @@ vi.mock('@/lib/receipts/receipt-analyzer', () => ({
}),
}))
vi.mock('@/lib/receipts/receipt-matcher', () => ({
vi.mock('../lib/receipt-matcher', () => ({
autoMatchReceipts: vi.fn().mockReturnValue([]),
}))
import { createClient } from '@/lib/supabase/server'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
import { analyzeReceipt } from '../lib/receipt-analyzer'
import { autoMatchReceipts } from '../lib/receipt-matcher'
import { getSettings, saveSettings, receiptOcrExtension } from '../index'
import { extensionRegistry } from '@/lib/extensions/registry'
@@ -287,7 +287,7 @@ export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-yellow-500/90 text-black px-4 py-2 rounded-full text-sm font-medium">
<div className="bg-warning text-warning-foreground px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
@@ -40,12 +40,12 @@ export default function ReceiptDashboard({
{/* Streak counter */}
{summary.streak_count > 0 && (
<Card className="bg-gradient-to-r from-orange-500/10 to-yellow-500/10 border-orange-500/20">
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center">
<Flame className="h-6 w-6 text-orange-500" />
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Flame className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{summary.streak_count}</p>
@@ -71,8 +71,8 @@ export default function ReceiptDashboard({
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-yellow-500/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-yellow-500" />
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Att granska</p>
@@ -96,8 +96,8 @@ export default function ReceiptDashboard({
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-blue-500" />
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">Kvitton utan transaktion</p>
@@ -121,8 +121,8 @@ export default function ReceiptDashboard({
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-purple-500" />
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<CreditCard className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Transaktioner utan kvitto</p>
@@ -140,11 +140,11 @@ export default function ReceiptDashboard({
{/* All done state */}
{!hasWork && (
<Card className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/20">
<Card className="bg-success/5 border-success/20">
<CardContent className="pt-4 text-center py-8">
<div className="h-16 w-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<svg
className="h-8 w-8 text-green-500"
className="h-8 w-8 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -4,7 +4,7 @@ import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { getExpenseCategories, CATEGORY_LABELS } from '@/lib/receipts/receipt-categorizer'
import { getExpenseCategories, CATEGORY_LABELS } from '../lib/receipt-categorizer'
import type { ReceiptLineItem, TransactionCategory } from '@/types'
interface ReceiptLineItemRowProps {
@@ -21,8 +21,8 @@ import {
Loader2,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { calculateReceiptSplit, getDefaultClassification } from '@/lib/receipts/receipt-categorizer'
import { calculateRepresentationLimits } from '@/lib/receipts/receipt-utils'
import { calculateReceiptSplit, getDefaultClassification } from '../lib/receipt-categorizer'
import { calculateRepresentationLimits } from '../lib/receipt-utils'
import ReceiptLineItemRow from './ReceiptLineItemRow'
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
@@ -235,9 +235,9 @@ export default function ReceiptReviewView({
{/* Warning message */}
{defaultClassification.warningMessage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/5 border border-warning/30">
<AlertTriangle className="h-5 w-5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning-foreground">
{defaultClassification.warningMessage}
</p>
</div>
@@ -34,7 +34,7 @@ export default function TransactionMatcher({
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/receipts/${receipt.id}/match`, {
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
+3 -3
View File
@@ -1,8 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt, Transaction } from '@/types'
@@ -0,0 +1,344 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '../components/ReceiptDashboard'
import ReceiptReviewView from '../components/ReceiptReviewView'
import TransactionMatcher from '../components/TransactionMatcher'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Selected receipt for review/match
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
// Fetch receipts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/extensions/receipt-ocr'),
fetch('/api/extensions/receipt-ocr/queue'),
])
const [receiptsData, queueData] = await Promise.all([
receiptsRes.json(),
queueRes.json(),
])
if (receiptsData.data) {
setReceipts(receiptsData.data)
}
if (queueData.data?.summary) {
setSummary(queueData.data.summary)
}
} catch (error) {
console.error('Fetch error:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Filter receipts based on tab
const filteredReceipts = receipts.filter((r) => {
if (listFilter === 'pending') return r.status === 'extracted'
if (listFilter === 'confirmed') return r.status === 'confirmed'
return true
})
// Handle scan receipt
const handleScanReceipt = () => {
router.push('/receipts/scan')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
setViewMode('list')
}
// Handle view transaction queue
const handleViewTransactionQueue = () => {
router.push('/transactions?filter=unmatched')
}
// Handle receipt selection for review
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
setSelectedReceipt(receipt)
if (receipt.status === 'extracted') {
setViewMode('review')
} else {
setViewMode('match')
}
}
// Handle confirm receipt
const handleConfirmReceipt = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match receipt to transaction
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
}
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
<ReceiptReviewView
receipt={selectedReceipt}
onConfirm={handleConfirmReceipt}
onCancel={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onFindMatches={() => setViewMode('match')}
/>
)
}
// Render transaction matcher
if (viewMode === 'match' && selectedReceipt) {
return (
<TransactionMatcher
receipt={selectedReceipt}
onMatch={handleMatchReceipt}
onSkip={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onClose={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kvittohantering</h1>
<Button onClick={handleScanReceipt}>
<Camera className="mr-2 h-4 w-4" />
Skanna kvitto
</Button>
</div>
{/* Dashboard or List view toggle */}
<Tabs
value={viewMode === 'list' ? 'list' : 'dashboard'}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList>
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === 'dashboard' && (
<>
{isLoading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
) : summary ? (
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
) : (
<Card>
<CardContent className="pt-4 text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p>Kunde inte ladda data</p>
<Button variant="outline" className="mt-4" onClick={fetchData}>
Försök igen
</Button>
</CardContent>
</Card>
)}
</>
)}
{viewMode === 'list' && (
<>
{/* List filters */}
<div className="flex gap-2">
<Button
variant={listFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('all')}
>
Alla
</Button>
<Button
variant={listFilter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('pending')}
>
<Clock className="mr-1 h-3 w-3" />
Att granska
</Button>
<Button
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('confirmed')}
>
<Check className="mr-1 h-3 w-3" />
Bekräftade
</Button>
</div>
{/* Receipt list */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : filteredReceipts.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Inga kvitton hittades</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredReceipts.map((receipt) => (
<Card
key={receipt.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleSelectReceipt(receipt)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{receipt.image_url ? (
<img
src={receipt.image_url}
alt="Receipt"
className="h-16 w-12 object-cover rounded"
/>
) : (
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">
{formatDate(receipt.receipt_date)}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant={
receipt.status === 'confirmed'
? 'default'
: receipt.status === 'extracted'
? 'secondary'
: receipt.status === 'error'
? 'destructive'
: 'outline'
}
>
{receipt.status === 'confirmed' && 'Bekräftat'}
{receipt.status === 'extracted' && 'Att granska'}
{receipt.status === 'processing' && 'Analyserar...'}
{receipt.status === 'pending' && 'Väntar'}
{receipt.status === 'error' && 'Fel'}
</Badge>
{receipt.matched_transaction_id && (
<Badge variant="outline">Kopplat</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,157 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import ReceiptCamera from '../../components/ReceiptCamera'
import ReceiptReviewView from '../../components/ReceiptReviewView'
import TransactionMatcher from '../../components/TransactionMatcher'
import { Loader2 } from 'lucide-react'
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
export default function ScanReceiptPage() {
const router = useRouter()
const [pageState, setPageState] = useState<PageState>('camera')
const [uploadError, setUploadError] = useState<string | null>(null)
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
// Handle image capture
const handleCapture = async (imageData: string, mimeType: string) => {
setPageState('uploading')
setUploadError(null)
try {
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
// Create form data
const formData = new FormData()
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/extensions/receipt-ocr/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setReceipt(data.data)
setPageState('review')
} else {
setUploadError(data.error || 'Kunde inte analysera kvittot')
setPageState('camera')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError('Nätverksfel. Försök igen.')
setPageState('camera')
}
}
// Handle close/cancel
const handleClose = () => {
router.push('/receipts')
}
// Handle confirm receipt
const handleConfirm = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
const updatedData = await response.json()
setReceipt(updatedData.data)
setPageState('match')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match to transaction
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
router.push('/receipts')
}
}
// Handle skip matching
const handleSkipMatch = () => {
router.push('/receipts')
}
// Render uploading state
if (pageState === 'uploading') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Analyserar kvitto...</p>
<p className="text-sm text-muted-foreground mt-1">
AI läser av artiklar och belopp
</p>
</div>
)
}
// Render review view
if (pageState === 'review' && receipt) {
return (
<ReceiptReviewView
receipt={receipt}
onConfirm={handleConfirm}
onCancel={handleClose}
onFindMatches={() => setPageState('match')}
/>
)
}
// Render match view
if (pageState === 'match' && receipt) {
return (
<TransactionMatcher
receipt={receipt}
onMatch={handleMatch}
onSkip={handleSkipMatch}
onClose={handleClose}
/>
)
}
// Render camera (default)
return (
<>
{uploadError && (
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
{uploadError}
</div>
)}
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
</>
)
}
+171
View File
@@ -0,0 +1,171 @@
import type { TransactionCategory, Transaction } from '@/types'
// Receipt extraction status
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
// Receipt record
export interface Receipt {
id: string
user_id: string
// Image storage
image_url: string
image_thumbnail_url: string | null
// Extraction status
status: ReceiptStatus
extraction_confidence: number | null
// Extracted header data
merchant_name: string | null
merchant_org_number: string | null
merchant_vat_number: string | null
receipt_date: string | null
receipt_time: string | null
total_amount: number | null
currency: string
vat_amount: number | null
// Special flags
is_restaurant: boolean
is_systembolaget: boolean
is_foreign_merchant: boolean
// Restaurant representation data
representation_persons: number | null
representation_purpose: string | null
// Transaction matching
matched_transaction_id: string | null
match_confidence: number | null
// Raw extraction data
raw_extraction: ReceiptExtractionResult | null
created_at: string
updated_at: string
// Relations (populated when fetched)
line_items?: ReceiptLineItem[]
matched_transaction?: Transaction
}
// Receipt line item record
export interface ReceiptLineItem {
id: string
receipt_id: string
// Extracted data
description: string
quantity: number
unit_price: number | null
line_total: number
vat_rate: number | null
vat_amount: number | null
// Classification
is_business: boolean | null
category: TransactionCategory | null
bas_account: string | null
// Confidence
extraction_confidence: number | null
suggested_category: string | null
sort_order: number
created_at: string
}
// AI extraction result from Claude Vision
export interface ReceiptExtractionResult {
merchant: {
name: string | null
orgNumber: string | null
vatNumber: string | null
isForeign: boolean
}
receipt: {
date: string | null
time: string | null
currency: string
}
lineItems: ExtractedLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
flags: {
isRestaurant: boolean
isSystembolaget: boolean
isForeignMerchant: boolean
}
confidence: number
}
// Extracted line item from AI
export interface ExtractedLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
suggestedCategory: string | null
confidence?: number
}
// Match candidate for receipt-to-transaction matching
export interface ReceiptMatchCandidate {
transaction: Transaction
confidence: number
matchReasons: string[]
dateVariance: number
amountVariance: number
}
// Input for creating a receipt
export interface CreateReceiptInput {
image_url: string
image_thumbnail_url?: string
}
// Input for confirming receipt line items
export interface ConfirmReceiptInput {
line_items: ConfirmLineItemInput[]
matched_transaction_id?: string
representation_persons?: number
representation_purpose?: string
}
export interface ConfirmLineItemInput {
id: string
is_business: boolean
category?: TransactionCategory
bas_account?: string
}
// Receipt queue summary
export interface ReceiptQueueSummary {
unmatched_receipts_count: number
unmatched_transactions_count: number
pending_review_count: number
streak_count: number
}
// Camera quality feedback
export interface CameraQualityFeedback {
lightingOk: boolean
distanceOk: boolean
focusOk: boolean
readyToCapture: boolean
message?: string
}
// Swedish labels for receipt status
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
pending: 'Väntar',
processing: 'Analyserar',
extracted: 'Extraherat',
confirmed: 'Bekräftat',
error: 'Fel'
}
+243
View File
@@ -0,0 +1,243 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import type { SRUExportResult, SRUCoverageStats } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function SRUExportView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SRUExportResult | null>(null)
const [coverage, setCoverage] = useState<SRUCoverageStats | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchCoverage()
}, [])
async function fetchCoverage() {
try {
const res = await fetch('/api/extensions/sru-export/coverage')
const result = await res.json()
if (result.data) {
setCoverage(result.data)
}
} catch {
// Coverage is optional, ignore errors
}
}
const fetchExport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/extensions/sru-export?period_id=${periodId}&format=json`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta SRU-export')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/extensions/sru-export?period_id=${periodId}&format=sru`, '_blank')
}
const formLabel = data?.formType === 'INK2' ? 'INK2 (Aktiebolag)' : 'NE (Enskild firma)'
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-export</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
</p>
<div className="flex gap-2">
<Button onClick={fetchExport} disabled={loading}>
{loading ? 'Laddar...' : 'Förhandsgranska'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{/* Coverage warning */}
{coverage && coverage.accountsWithoutSRU > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
<p className="text-sm text-orange-800">
{coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
({coverage.coveragePercent}% täckning).
Konton utan SRU-kod inkluderas inte i exporten.
</p>
{coverage.missingAccounts.length <= 5 && (
<ul className="text-xs text-orange-700 mt-1 space-y-0.5">
{coverage.missingAccounts.map((a) => (
<li key={a.accountNumber}>
{a.accountNumber} — {a.accountName}
</li>
))}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company + form info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{data.companyName || 'Okänt företag'}</CardTitle>
<div className="flex gap-2">
<Badge className="bg-blue-100 text-blue-800">{formLabel}</Badge>
<Badge className="bg-blue-100 text-blue-800">{data.fiscalYear.name}</Badge>
</div>
</div>
{data.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* SRU balances table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-poster</CardTitle>
</CardHeader>
<CardContent>
{data.balances.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga poster med belopp att exportera.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">SRU-kod</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{data.balances.map((b) => (
<SRUBalanceRow key={b.sruCode} balance={b} />
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Förhandsgranska&quot; för att se SRU-uppgifter för valt räkenskapsår.
</CardContent>
</Card>
)}
</div>
)
}
function SRUBalanceRow({
balance,
}: {
balance: SRUExportResult['balances'][number]
}) {
const [expanded, setExpanded] = useState(false)
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2 font-mono">{balance.sruCode}</td>
<td className="py-2">
{balance.description}
{balance.accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({balance.accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
</tr>
{expanded && balance.accounts.length > 0 && (
<tr>
<td colSpan={3} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{balance.accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 font-mono w-16">{acc.accountNumber}</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { Extension } from '@/lib/extensions/types'
/**
* SRU Export Extension
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files from chart_of_accounts
* sru_code mappings. Supports both NE (EF) and INK2 (AB) form types.
*
* No event handlers or settings panel needed — SRU codes are managed
* directly on chart_of_accounts.
*/
export const sruExportExtension: Extension = {
id: 'sru-export',
name: 'SRU-export',
version: '1.0.0',
reportTypes: [
{
id: 'sru-generic',
name: 'SRU-export',
description: 'Generera SRU-fil för Skatteverket',
},
],
}
+151
View File
@@ -0,0 +1,151 @@
import { createClient } from '@/lib/supabase/server'
import type { JournalEntry, JournalEntryLine } from '@/types'
/**
* SRU aggregation engine
*
* Fetches posted journal entries for a fiscal period, computes net balance
* per account, and groups by sru_code from chart_of_accounts.
*/
export interface SRUBalance {
sruCode: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
/**
* Aggregate account balances by SRU code for a given fiscal period.
* Returns a Map of sru_code → summed amount, plus per-account detail.
*/
export async function aggregateBalancesBySRU(
userId: string,
fiscalPeriodId: string
): Promise<Map<string, SRUBalance>> {
const supabase = await createClient()
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts with SRU codes
const { data: accounts, error: accountsError } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
if (accountsError) {
throw new Error(`Failed to fetch accounts: ${accountsError.message}`)
}
// Build lookup maps
const accountSRUMap = new Map<string, string>()
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
if (acc.sru_code) {
accountSRUMap.set(acc.account_number, acc.sru_code)
}
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate net balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Group balances by SRU code
const sruBalances = new Map<string, SRUBalance>()
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
const sruCode = accountSRUMap.get(accountNumber)
if (!sruCode) continue
let entry = sruBalances.get(sruCode)
if (!entry) {
entry = { sruCode, amount: 0, accounts: [] }
sruBalances.set(sruCode, entry)
}
entry.amount += balance
entry.accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: Math.round(balance),
})
}
// Round totals
for (const entry of sruBalances.values()) {
entry.amount = Math.round(entry.amount)
}
return sruBalances
}
/**
* Get SRU code coverage stats for a user's chart of accounts.
* Returns how many accounts have vs lack SRU codes.
*/
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
const supabase = await createClient()
const { data: accounts, error } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
if (error) {
throw new Error(`Failed to fetch accounts: ${error.message}`)
}
const allAccounts = accounts || []
const withSRU = allAccounts.filter((a) => a.sru_code)
const withoutSRU = allAccounts.filter((a) => !a.sru_code)
return {
totalAccounts: allAccounts.length,
accountsWithSRU: withSRU.length,
accountsWithoutSRU: withoutSRU.length,
coveragePercent: allAccounts.length > 0
? Math.round((withSRU.length / allAccounts.length) * 100)
: 0,
missingAccounts: withoutSRU.map((a) => ({
accountNumber: a.account_number,
accountName: a.account_name,
})),
}
}
+140
View File
@@ -0,0 +1,140 @@
import type { SRUFile, SRURecord } from '@/types'
import { sruFileToString, validateSRUFile } from './lib/sru-generator'
import type { SRUBalance } from './sru-engine'
/**
* Generic SRU file generator
*
* Generates SRU files from aggregated SRU balances for any form type
* (NE for enskild firma, INK2 for aktiebolag).
*
* Reuses sruFileToString() and validateSRUFile() from the existing
* NE-specific generator.
*/
export type SRUFormType = 'NE' | 'INK2'
export interface GenericSRUParams {
formType: SRUFormType
orgNumber: string | null
companyName: string
fiscalYearStart: string // YYYY-MM-DD
fiscalYearEnd: string // YYYY-MM-DD
sruBalances: Map<string, SRUBalance>
}
/**
* SRU code descriptions for display
*/
export const SRU_CODE_DESCRIPTIONS: Record<string, string> = {
// NE form (EF)
'7310': 'Försäljning med moms',
'7311': 'Momsfria intäkter',
'7312': 'Bil/bostadsförmån',
'7313': 'Ränteintäkter',
'7320': 'Varuinköp',
'7321': 'Övriga kostnader',
'7322': 'Lönekostnader',
'7323': 'Räntekostnader',
'7324': 'Avskrivningar fastighet',
'7325': 'Avskrivningar övrigt',
'7350': 'Årets resultat',
// INK2 form (AB) — balance sheet
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Skulder',
'7231': 'Övriga skulder',
// INK2 form (AB) — income statement
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster',
'7380': 'Extraordinära poster',
}
/**
* Generate a generic SRU file from aggregated SRU balances.
*/
export function generateGenericSRU(params: GenericSRUParams): SRUFile {
const { formType, orgNumber, fiscalYearStart, fiscalYearEnd, sruBalances } = params
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({ fieldCode: 'SKAPAT', value: formatSRUDate(now) })
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: formType })
// Company identification
if (orgNumber) {
const cleanOrgNumber = orgNumber.replace(/-/g, '')
records.push({ fieldCode: 'IDENTITET', value: cleanOrgNumber })
}
// Fiscal year
const startSRU = fiscalYearStart.replace(/-/g, '')
const endSRU = fiscalYearEnd.replace(/-/g, '')
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${startSRU}-${endSRU}`,
})
// SRU balance entries — one #UPPGIFT per non-zero SRU code
const sortedEntries = Array.from(sruBalances.entries())
.sort(([a], [b]) => a.localeCompare(b))
for (const [sruCode, balance] of sortedEntries) {
if (balance.amount !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${sruCode} ${Math.round(balance.amount)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Get filename for generic SRU file download
*/
export function getGenericSRUFilename(
formType: SRUFormType,
orgNumber: string | null,
fiscalYearStart: string
): string {
const year = fiscalYearStart.substring(0, 4)
const cleanOrg = orgNumber?.replace(/-/g, '') || 'unknown'
return `${formType}_${cleanOrg}_${year}.sru`
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
// Re-export helpers from the existing SRU generator
export { sruFileToString, validateSRUFile }
+37
View File
@@ -0,0 +1,37 @@
import type { EntityType } from '@/types'
// Generic SRU export types
export interface SRUExportResult {
formType: 'NE' | 'INK2'
entityType: EntityType
companyName: string | null
orgNumber: string | null
fiscalYear: {
id: string
name: string
start: string
end: string
}
balances: Array<{
sruCode: string
description: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}>
warnings: string[]
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
+8
View File
@@ -1,6 +1,10 @@
import { extensionRegistry } from './registry'
import { receiptOcrExtension } from '@/extensions/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/ai-categorization'
import { pushNotificationsExtension } from '@/extensions/push-notifications'
import { sruExportExtension } from '@/extensions/sru-export'
import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/ai-chat'
import type { Extension } from './types'
/**
@@ -12,6 +16,10 @@ import type { Extension } from './types'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension,
neBilagaExtension,
aiChatExtension,
]
let loaded = false
-482
View File
@@ -1,482 +0,0 @@
import {
UtensilsCrossed,
BedDouble,
Monitor,
HardHat,
HeartPulse,
Store,
ShoppingCart,
Scissors,
Truck,
Scale,
GraduationCap,
Wheat,
Film,
Dumbbell,
Wrench,
UserPlus,
Factory,
Briefcase,
PartyPopper,
Building,
type LucideIcon,
} from 'lucide-react'
export type ModuleCategory = 'bokforing' | 'rapport' | 'import' | 'operativ'
export interface ModuleItem {
slug: string
name: string
cat: ModuleCategory
desc: string
longDesc: string
}
export interface Sector {
slug: string
name: string
icon: LucideIcon
description: string
modules: ModuleItem[]
}
export const CATEGORY_LABELS: Record<ModuleCategory, string> = {
bokforing: 'Bokföring & Skatt',
rapport: 'Branschrapporter',
import: 'Smart import',
operativ: 'Operativa moduler',
}
export const sectors: Sector[] = [
{
slug: 'restaurang', name: 'Restaurang & Café', icon: UtensilsCrossed,
description: 'Menyhantering, bordsbokning & kassa',
modules: [
{ slug: 'restaurangkontoplan', name: 'Restaurangkontoplan', cat: 'bokforing', desc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp', longDesc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp. Nyckelfunktioner: Separata konton för mat (3001), dryck (3002), alkohol (3003), take-away; auto-kontosättning från kassaimport' },
{ slug: 'momssplit-mat-dryck', name: 'Momssplit mat/dryck', cat: 'bokforing', desc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck)', longDesc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck). Nyckelfunktioner: Regelmotor för momsfördelning, hantering av blandade kvitton, månadsavstämning per momssats' },
{ slug: 'dagskassaavstamning', name: 'Dagskassaavstämning', cat: 'bokforing', desc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank', longDesc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank. Nyckelfunktioner: Z-rapport-import, avvikelseanalys (svinn, differens), verifikation per dag' },
{ slug: 'tipsbokforing', name: 'Tipsbokföring', cat: 'bokforing', desc: 'Bokför dricks — arbetsgivaravgift på kartdricks, skattefri kontantdricks', longDesc: 'Bokför dricks korrekt — arbetsgivaravgift på kartdricks, skattefri kontantdricks. Nyckelfunktioner: Fördelning per anställd, arbetsgivaravgiftsberäkning, konto 7699/7010-koppling' },
{ slug: 'personalliggare', name: 'Personalliggare', cat: 'bokforing', desc: 'Elektronisk personalliggare enligt krav från Skatteverket', longDesc: 'Elektronisk personalliggare enligt krav från Skatteverket. Nyckelfunktioner: Daglig registrering in/ut, exportformat för kontroll, koppla till lönekostnad' },
{ slug: 'alkoholpunktskatt', name: 'Alkohol & Accis', cat: 'bokforing', desc: 'Punktskattehantering för alkoholservering', longDesc: 'Hantera punktskatt vid egenbrygd/import av alkohol. Nyckelfunktioner: Accisberäkning, lagerrapportering, Tullverket-format' },
{ slug: 'representationsbokforing', name: 'Representationsbokföring', cat: 'bokforing', desc: 'Bokför representation med rätt avdragsgränser', longDesc: 'Bokför representation med rätt avdragsgränser. Nyckelfunktioner: Automatisk split avdragsgill/ej avdragsgill del, 90 SEK-gräns internt, momsavdrag' },
{ slug: 'matkostnad', name: 'Matkostnad (Food Cost %)', cat: 'rapport', desc: 'Livsmedelskostnad som andel av matintäkt', longDesc: 'Livsmedelskostnad som andel av matintäkt. Nyckelfunktioner: Daglig/veckovis/månadsvis %, mål vs utfall, trendkurva, larmgräns' },
{ slug: 'personalkostnad-vs-omsattning', name: 'Personalkostnad vs omsättning', cat: 'rapport', desc: 'Personalkostnad som andel av total omsättning', longDesc: 'Spåra personalkostnad som andel av total omsättning. Nyckelfunktioner: Målvärde (typiskt 30-35%), trend, jämförelse budget' },
{ slug: 'svinnrapport', name: 'Svinnrapport', cat: 'rapport', desc: 'Analysera matsvinn i kronor och procent', longDesc: 'Analysera matsvinn i kronor och procent. Nyckelfunktioner: Svinn per kategori, kostnad, svinn-till-omsättning' },
{ slug: 'revpash', name: 'RevPASH', cat: 'rapport', desc: 'Revenue per Available Seat Hour', longDesc: 'Revenue per available seat hour. Nyckelfunktioner: Intäkt per stolstimme, jämförelse per pass (lunch/middag), kapacitetsoptimering' },
{ slug: 'z-rapport-import', name: 'Z-rapport-import', cat: 'import', desc: 'Importera dagskassarapport från kassasystem', longDesc: 'Importera dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Trivec, Square, Orderbird; auto-bokför med momssplit och kontosättning' },
{ slug: 'leverantorsfaktura-import', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor för matinköp', longDesc: 'Importera leverantörsfakturor för matinköp. Nyckelfunktioner: Parsning av leverantörsformat (Martin & Servera, Menigo), kostnadsfördelning per kategori' },
{ slug: 'menyhantering', name: 'Menyhantering', cat: 'operativ', desc: 'Bygg och publicera menyer digitalt', longDesc: 'Bygg och publicera menyer digitalt. Nyckelfunktioner: Menybyggare, säsongsbyte, allergenmarkering, prissättning per kanal' },
{ slug: 'bordsbokning', name: 'Bordsbokning', cat: 'operativ', desc: 'Online och manuell bordsreservation', longDesc: 'Online och manuell bordsreservation. Nyckelfunktioner: Bokningswidget, no-show-hantering, sittningsplanering, kapacitetsvy' },
{ slug: 'receptkalkyl', name: 'Receptkalkyl', cat: 'operativ', desc: 'Beräkna kostnad per rätt utifrån inköpspriser', longDesc: 'Beräkna kostnad per rätt utifrån inköpspriser. Nyckelfunktioner: Receptbyggare, portionskostnad, marginalkalkyl, prisändringsanalys' },
{ slug: 'personalschema', name: 'Personalschema', cat: 'operativ', desc: 'Schemalägg personal i skift', longDesc: 'Schemalägg personal i skift. Nyckelfunktioner: Skiftplanering, bytesförfrågningar, övertidsvarning, tillgänglighetsvy' },
{ slug: 'leverantorsbestallning', name: 'Leverantörsbeställning', cat: 'operativ', desc: 'Beställ varor direkt till leverantörer', longDesc: 'Beställ varor direkt till leverantörer. Nyckelfunktioner: Beställningsmallar, leveransbevakning, prishistorik' },
],
},
{
slug: 'hotell', name: 'Hotell & Boende', icon: BedDouble,
description: 'Rumsbokning, gäster & städschema',
modules: [
{ slug: 'hotellkontoplan', name: 'Hotellkontoplan', cat: 'bokforing', desc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B', longDesc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B, tilläggstjänster. Nyckelfunktioner: Konton 3010 (rumsintäkter), 3020 (konferens), 3030 (F&B); auto-kontosättning' },
{ slug: 'momssplit-boende-tjanst', name: 'Momssplit boende/tjänst', cat: 'bokforing', desc: 'Moms 12% på boende, 25% på frukost, minibar, spa', longDesc: 'Moms 12% på boende, 25% på frukost, minibar, spa, parkering. Nyckelfunktioner: Automatisk split vid kombinationspaket (halvpension, konferenspaket), momsavstämning' },
{ slug: 'forskottsbetalning', name: 'Förskottsbetalning', cat: 'bokforing', desc: 'Bokför depositioner som förutbetald intäkt', longDesc: 'Bokför förskott/depositioner korrekt som förutbetald intäkt. Nyckelfunktioner: Konto 2420, automatisk intäktsföring vid incheckning, avbokningsbokföring' },
{ slug: 'provisionshantering', name: 'Provisionshantering', cat: 'bokforing', desc: 'Bokför provision till Booking.com, Expedia, Airbnb', longDesc: 'Bokför provision till Booking.com, Expedia, Airbnb som kostnad. Nyckelfunktioner: Provisionsberäkning per kanal, nettointäkt per bokning, konto 6090' },
{ slug: 'nattrevision', name: 'Nattrevision', cat: 'bokforing', desc: 'Automatiserad nattlig avstämning av dagens intäkter', longDesc: 'Automatiserad nattlig avstämning av dagens intäkter. Nyckelfunktioner: Dagsbokslut, kassaavstämning, no-show-debitering, verifikation per dygn' },
{ slug: 'revpar', name: 'RevPAR', cat: 'rapport', desc: 'Revenue Per Available Room', longDesc: 'Revenue per available room — branschens viktigaste KPI. Nyckelfunktioner: Beläggningsgrad × ADR, jämförelse per månad/säsong, budget vs utfall' },
{ slug: 'adr', name: 'ADR (Average Daily Rate)', cat: 'rapport', desc: 'Genomsnittlig rumspris', longDesc: 'Genomsnittlig rumspris. Nyckelfunktioner: Pris per rumstyp, kanalfördelning, säsongsanalys' },
{ slug: 'belaggningsgrad', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Procent uthyrda rum', longDesc: 'Procent uthyrda rum. Nyckelfunktioner: Daglig/veckovis/månadsvis, prognos baserad på bokningsläge' },
{ slug: 'kanallonsamhet', name: 'Kanallönsamhet', cat: 'rapport', desc: 'Jämför nettointäkt per kanal efter provision', longDesc: 'Jämför nettointäkt per kanal efter provision. Nyckelfunktioner: Direktbokning vs OTA, provision %, rekommendation' },
{ slug: 'pms-import', name: 'PMS-import', cat: 'import', desc: 'Importera data från PMS (Mews, Protel, Hotellinx)', longDesc: 'Importera boknings- och intäktsdata från PMS (Mews, Protel, Hotellinx). Nyckelfunktioner: CSV/Excel, nattrevisionsrapport, auto-bokföring med momssplit' },
{ slug: 'channel-manager-rapport', name: 'Channel Manager-rapport', cat: 'import', desc: 'Importera provisionssammanställning per kanal', longDesc: 'Importera provisionssammanställning per kanal. Nyckelfunktioner: Booking/Expedia/Airbnb-rapporter, provisionsbokföring' },
{ slug: 'rumsbokning', name: 'Rumsbokning', cat: 'operativ', desc: 'Central bokningsmotor för rum och stugor', longDesc: 'Central bokningsmotor. Nyckelfunktioner: Kalendervy, tillgänglighet, säsongspriser, min/max-nätter' },
{ slug: 'gastregister', name: 'Gästregister', cat: 'operativ', desc: 'Gästprofiler och historik', longDesc: 'Gästprofiler och historik. Nyckelfunktioner: Kontaktuppgifter, besökshistorik, preferenser, GDPR' },
{ slug: 'stadschema', name: 'Städschema', cat: 'operativ', desc: 'Planera och följ upp städning per rum', longDesc: 'Planera städning per rum. Nyckelfunktioner: Status per rum, checklista, personalfördelning' },
{ slug: 'sasongsplanering', name: 'Säsongsplanering', cat: 'operativ', desc: 'Planera priser och kapacitet per säsong', longDesc: 'Priser och kapacitet per säsong. Nyckelfunktioner: Säsongsdefinition, dynamisk prissättning, beläggningsprognos' },
{ slug: 'gastkommunikation', name: 'Gästkommunikation', cat: 'operativ', desc: 'Automatiserad kommunikation med gäster', longDesc: 'Automatiska meddelanden. Nyckelfunktioner: Bekräftelse, incheckning, utcheckning, recensionsförfrågan' },
],
},
{
slug: 'tech', name: 'Tech & IT', icon: Monitor,
description: 'Projekthantering, tid & SLA',
modules: [
{ slug: 'it-kontoplan', name: 'IT-kontoplan', cat: 'bokforing', desc: 'BAS-konton för konsult/SaaS-bolag', longDesc: 'BAS-konton anpassade för konsult/SaaS-bolag. Nyckelfunktioner: Konton för konsultintäkter, licensintäkter, SaaS MRR; auto-kontosättning' },
{ slug: 'projektredovisning', name: 'Projektredovisning', cat: 'bokforing', desc: 'Successiv vinstavräkning per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Successiv vinstavräkning, WIP-beräkning (konto 1470), projektbokslut' },
{ slug: 'fou-avdrag', name: 'FoU-avdrag', cat: 'bokforing', desc: 'Avdrag för forskning och utveckling', longDesc: 'Beräkna avdrag för forskning och utveckling. Nyckelfunktioner: Nedsättning av arbetsgivaravgifter, underlag per anställd, Skatteverket-krav' },
{ slug: 'licensavskrivning', name: 'Licensavskrivning', cat: 'bokforing', desc: 'Avskrivning av mjukvarulicenser och IP', longDesc: 'Hantera avskrivning av mjukvarulicenser och IP. Nyckelfunktioner: Avskrivningsplan, konto 1010/1020, linjär vs degressiv avskrivning' },
{ slug: 'eu-tjanstmoms', name: 'EU-tjänstemoms', cat: 'bokforing', desc: 'Omvänd skattskyldighet vid EU-försäljning', longDesc: 'Omvänd skattskyldighet vid försäljning till EU-kunder. Nyckelfunktioner: Automatisk B2B-kontroll (VAT-nummer), konto 2614, EU-försäljningsrapport' },
{ slug: 'debiteringsgrad', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Andel debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per konsult/team, mål vs utfall, trend' },
{ slug: 'projektlonsamhet', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Timkostnad vs debiterat, överdrag-varning, jämförelse budget' },
{ slug: 'mrr-arr', name: 'MRR/ARR', cat: 'rapport', desc: 'Monthly/Annual Recurring Revenue', longDesc: 'Monthly/Annual Recurring Revenue för SaaS. Nyckelfunktioner: Tillväxttakt, churn, expansion revenue' },
{ slug: 'tidrapport-import', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl, Clockify', longDesc: 'Importera tidrapporter från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV/Excel, auto-koppling till projekt, fakturaunderlag' },
{ slug: 'projekthantering', name: 'Projekthantering', cat: 'operativ', desc: 'Överblick och styrning av kundprojekt', longDesc: 'Överblick och styrning av kundprojekt. Nyckelfunktioner: Projekttavla, milstolpar, tidslinje, budgetuppföljning' },
{ slug: 'tidrapportering', name: 'Tidrapportering', cat: 'operativ', desc: 'Logga tid per projekt och kund', longDesc: 'Logga tid per projekt och kund. Nyckelfunktioner: Timer, manuell inmatning, godkännandeflöde, debiterbara timmar' },
{ slug: 'arendehantering', name: 'Ärendehantering', cat: 'operativ', desc: 'Supportärenden och buggspårning', longDesc: 'Supportärenden och buggspårning. Nyckelfunktioner: Ärendekö, SLA-prioritet, statusflöde, kundportal' },
{ slug: 'resursplanering', name: 'Resursplanering', cat: 'operativ', desc: 'Allokera personal till projekt', longDesc: 'Allokera personal till projekt. Nyckelfunktioner: Beläggningsvy, kompetensfilter, överlappningsvarning' },
],
},
{
slug: 'bygg', name: 'Bygg & Entreprenad', icon: HardHat,
description: 'Projektkalkyler, underentreprenörer',
modules: [
{ slug: 'byggkontoplan', name: 'Byggkontoplan', cat: 'bokforing', desc: 'BAS-konton med projektkontosättning och UE-konton', longDesc: 'BAS-konton med projektkontosättning och UE-konton. Nyckelfunktioner: Separata konton per projekt, UE-kostnader (konto 4400), materialkostnader' },
{ slug: 'omvand-skattskyldighet-bygg', name: 'Omvänd skattskyldighet bygg', cat: 'bokforing', desc: 'Omvänd moms vid UE-tjänster (6 kap. ML)', longDesc: 'Automatisk hantering av omvänd moms (6 kap. ML) vid UE-tjänster. Nyckelfunktioner: Automatisk kontroll köpare/säljare, fakturamärkning, momsdeklaration ruta 24' },
{ slug: 'rot-avdrag', name: 'ROT-avdrag', cat: 'bokforing', desc: 'ROT-avdrag per kund — 30% max 50 000 SEK', longDesc: 'Beräkna och rapportera ROT-avdrag per kund. Nyckelfunktioner: Automatisk 30%-beräkning, maxbelopp 50 000 SEK, personnummervalidering, Skatteverket-format' },
{ slug: 'successiv-vinstavrakning', name: 'Successiv vinstavräkning', cat: 'bokforing', desc: 'Periodisera intäkter efter färdigställandegrad', longDesc: 'Periodisera projektintäkter efter färdigställandegrad. Nyckelfunktioner: Beräkning av färdigställandegrad, intäktsperiodisering, konto 1470 WIP' },
{ slug: 'ue-attestering', name: 'UE-attestering', cat: 'bokforing', desc: 'Attestflöde för UE-fakturor med F-skatt-kontroll', longDesc: 'Attestflöde för underentreprenörsfakturor. Nyckelfunktioner: Godkännandeflöde, delavstämning, F-skatt-kontroll, försäkringsverifiering' },
{ slug: 'personalliggare-bygg', name: 'Personalliggare bygg', cat: 'bokforing', desc: 'Elektronisk personalliggare inkl. UE-personal', longDesc: 'Elektronisk personalliggare enligt Skatteverkets krav. Nyckelfunktioner: In/ut-registrering, export vid kontroll, UE-personal inkluderad' },
{ slug: 'ata-bokforing', name: 'ÄTA-bokföring', cat: 'bokforing', desc: 'Koppla ÄTA-arbeten till projekt och faktura', longDesc: 'Koppla ÄTA-arbeten till rätt projekt och faktura. Nyckelfunktioner: ÄTA-registrering, kostnadseffekt på projektkalkyl, intäktsökning vid godkänd ÄTA' },
{ slug: 'projektmarginal', name: 'Projektmarginal', cat: 'rapport', desc: 'Kalkylerad vs verklig marginal per projekt', longDesc: 'Kalkylerad vs verklig marginal per projekt. Nyckelfunktioner: Budget vs utfall, ÄTA-påverkan, UE-kostnad vs plan' },
{ slug: 'ata-analys', name: 'ÄTA-analys', cat: 'rapport', desc: 'ÄTA som andel av ursprunglig kalkyl', longDesc: 'ÄTA som andel av ursprunglig kalkyl. Nyckelfunktioner: ÄTA-andel per projekt, typ av ÄTA, kundgodkännandestatus' },
{ slug: 'likviditet-per-projekt', name: 'Likviditet per projekt', cat: 'rapport', desc: 'Kassaflöde per projekt', longDesc: 'Kassaflöde per projekt — fakturerat vs betalt vs kostnad. Nyckelfunktioner: Faktureringsrytm, kundfordringsålder, UE-skulder' },
{ slug: 'ue-fakturaimport', name: 'UE-fakturaimport', cat: 'import', desc: 'Importera underentreprenörsfakturor', longDesc: 'Importera underentreprenörsfakturor. Nyckelfunktioner: Parsning, projektkoppling, F-skatt-kontroll, auto-kontosättning' },
{ slug: 'materialkostnadsimport', name: 'Materialkostnadsimport', cat: 'import', desc: 'Importera materialinköp per projekt', longDesc: 'Importera materialinköp per projekt. Nyckelfunktioner: Leverantörsformat (Beijer, Ahlsell), projektfördelning' },
{ slug: 'projektkalkyl', name: 'Projektkalkyl', cat: 'operativ', desc: 'Detaljerade kalkyler inför anbud', longDesc: 'Detaljerade kalkyler inför anbud. Nyckelfunktioner: Arbete, material, maskiner, UE-kostnader, marginalpåslag' },
{ slug: 'ata-hantering', name: 'ÄTA-hantering', cat: 'operativ', desc: 'Spåra ändringar, tillägg och avgående', longDesc: 'Spåra ändringar, tillägg och avgående. Nyckelfunktioner: Registrering, kundgodkännande, koppling till faktura' },
{ slug: 'byggdagbok', name: 'Byggdagbok', cat: 'operativ', desc: 'Digital byggdagbok per projekt', longDesc: 'Digital dagbok per projekt. Nyckelfunktioner: Dagliga noteringar, foto, väderpåverkan, personallog' },
{ slug: 'ritningshantering', name: 'Ritningshantering', cat: 'operativ', desc: 'Versionshantering av projektritningar', longDesc: 'Versionshantering av projektritningar. Nyckelfunktioner: Uppladdning, versionsjämförelse, distribution till UE' },
{ slug: 'materialbestallning', name: 'Materialbeställning', cat: 'operativ', desc: 'Beställ och spåra material till projekt', longDesc: 'Beställ och spåra material. Nyckelfunktioner: Materiallistor, leveransbevakning, kostnadsuppföljning' },
],
},
{
slug: 'halsa', name: 'Hälsa & Sjukvård', icon: HeartPulse,
description: 'Patientbokning, journal & recept',
modules: [
{ slug: 'vardkontoplan', name: 'Vårdkontoplan', cat: 'bokforing', desc: 'BAS-konton: region, privat, försäkring', longDesc: 'BAS-konton uppdelade per intäktstyp: regionavtal, privatpatienter, försäkring. Nyckelfunktioner: Konton 3010 (regionersättning), 3020 (patientavgift), 3030 (försäkring)' },
{ slug: 'momsfrihet-sjukvard', name: 'Momsfrihet sjukvård', cat: 'bokforing', desc: 'Momsfria vårdtjänster vs momspliktiga sidotjänster', longDesc: 'Hantera momsfria vårdtjänster vs momspliktiga sidotjänster. Nyckelfunktioner: Automatisk momsbedömning per tjänsttyp, korrekt moms vid blandad verksamhet' },
{ slug: 'forsakringsersattning', name: 'Försäkringsersättning', cat: 'bokforing', desc: 'Bokför försäkringsersättningar vid godkännande', longDesc: 'Bokför ersättningar från försäkringsbolag korrekt. Nyckelfunktioner: Intäktsföring vid godkännande, kundfordran per försäkringsbolag, avskrivning av nekade ärenden' },
{ slug: 'frikort-hogkostnadsskydd', name: 'Frikort & Högkostnadsskydd', cat: 'bokforing', desc: 'Patienters frikort vid avgiftsberäkning', longDesc: 'Hantera patienters frikort vid avgiftsberäkning. Nyckelfunktioner: Automatisk kontroll mot högkostnadsgräns, rapportering till region' },
{ slug: 'intakt-per-behandlare', name: 'Intäkt per behandlare', cat: 'rapport', desc: 'Omsättning fördelat per vårdgivare', longDesc: 'Omsättning fördelat per vårdgivare. Nyckelfunktioner: Per dag/vecka/månad, privatpatienter vs region, besökssnitt' },
{ slug: 'patientmix', name: 'Patientmix', cat: 'rapport', desc: 'Fördelning region/privat/försäkring', longDesc: 'Fördelning region/privat/försäkring. Nyckelfunktioner: Intäktsfördelning per betalarkategori, trend, lönsamhet per typ' },
{ slug: 'regionersattningsimport', name: 'Regionersättningsimport', cat: 'import', desc: 'Importera utbetalningsbesked från region', longDesc: 'Importera utbetalningsbesked från region. Nyckelfunktioner: Periodisering, kontosättning, avstämning mot avtal' },
{ slug: 'forsakringsrapport-import', name: 'Försäkringsrapport-import', cat: 'import', desc: 'Importera beslut från försäkringsbolag', longDesc: 'Importera beslut och utbetalningar från försäkringsbolag. Nyckelfunktioner: Auto-bokföring godkända/nekade ärenden' },
{ slug: 'patientbokning', name: 'Patientbokning', cat: 'operativ', desc: 'Bokning av besök och behandlingar', longDesc: 'Bokning av besök och behandlingar. Nyckelfunktioner: Onlinebokning, kalendervy, SMS-påminnelse, väntelista' },
{ slug: 'journalhantering', name: 'Journalhantering', cat: 'operativ', desc: 'Strukturerade patientjournaler', longDesc: 'Strukturerade patientjournaler. Nyckelfunktioner: Anteckningar, diagnoser, vårdplan, GDPR-efterlevnad' },
{ slug: 'remisshantering', name: 'Remisshantering', cat: 'operativ', desc: 'Skicka och ta emot remisser', longDesc: 'Skicka och ta emot remisser. Nyckelfunktioner: Mallar, statusspårning, svarsbevakning' },
{ slug: 'kassasystem-patientavgifter', name: 'Kassasystem & Patientavgifter', cat: 'operativ', desc: 'Debitera patientbetalningar', longDesc: 'Debitera patientbetalningar. Nyckelfunktioner: Avgiftskategorier, frikort-koll, kortbetalning' },
],
},
{
slug: 'detaljhandel', name: 'Detaljhandel', icon: Store,
description: 'Lager, kassa & leverantörer',
modules: [
{ slug: 'detaljhandelskontoplan', name: 'Detaljhandelskontoplan', cat: 'bokforing', desc: 'BAS-konton per produktkategori och momssats', longDesc: 'BAS-konton per produktkategori och momssats. Nyckelfunktioner: Intäktskonton per varugrupp, separata konton 25%/12%/6% moms' },
{ slug: 'lagervardering', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO och vägt genomsnitt för bokslut', longDesc: 'Löpande beräkning av lagervärde för bokslut. Nyckelfunktioner: FIFO och vägt genomsnitt, månadsbokslut, inkuransbedömning, konto 1400' },
{ slug: 'kassaavstamning-butik', name: 'Kassaavstämning butik', cat: 'bokforing', desc: 'Daglig avstämning kassa vs kassarapport', longDesc: 'Daglig avstämning kassa vs kassarapport. Nyckelfunktioner: Z-rapport-import, avvikelse, kontant/kort-split, verifikation per dag' },
{ slug: 'svinnbokforing', name: 'Svinnbokföring', cat: 'bokforing', desc: 'Bokför varusvinn som kostnad (konto 4730)', longDesc: 'Bokför varusvinn korrekt som kostnad. Nyckelfunktioner: Svinnregistrering, konto 4730, period-uppföljning' },
{ slug: 'personalliggare-butik', name: 'Personalliggare butik', cat: 'bokforing', desc: 'Personalliggare för livsmedelsbutik', longDesc: 'Elektronisk personalliggare (obligatorisk livsmedelsbutik). Nyckelfunktioner: In/ut-registrering, Skatteverket-export' },
{ slug: 'bruttomarginal-per-varugrupp', name: 'Bruttomarginal per varugrupp', cat: 'rapport', desc: 'Inpris vs utpris per kategori', longDesc: 'Inpris vs utpris per kategori. Nyckelfunktioner: Marginal %, jämförelse kampanjperiod vs normal, trendvy' },
{ slug: 'lageromsattningshastighet', name: 'Lageromsättningshastighet', cat: 'rapport', desc: 'Hur snabbt lagret omsätts', longDesc: 'Hur snabbt lagret omsätts. Nyckelfunktioner: Per varugrupp, kapitalbindning, beställningspunkt-förslag' },
{ slug: 'svinnprocent', name: 'Svinnprocent', cat: 'rapport', desc: 'Svinn i kronor och procent', longDesc: 'Svinn i kronor och procent av omsättning. Nyckelfunktioner: Per kategori, trend, benchmark mot branschsnitt' },
{ slug: 'forsaljning-per-m2', name: 'Försäljning per m²', cat: 'rapport', desc: 'Butikseffektivitet', longDesc: 'Butikseffektivitet. Nyckelfunktioner: Intäkt per kvadratmeter, jämförelse butiker' },
{ slug: 'pos-z-rapport-import', name: 'POS Z-rapport-import', cat: 'import', desc: 'Dagskassarapport från kassasystem', longDesc: 'Dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Sitoo, Caspeco; auto-kontosättning med momssplit' },
{ slug: 'inventeringsimport', name: 'Inventeringsimport', cat: 'import', desc: 'Importera inventeringsresultat', longDesc: 'Importera inventeringsresultat. Nyckelfunktioner: Avvikelseberäkning, lageruppdatering, svinnbokföring' },
{ slug: 'leverantorsfaktura-import-butik', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor', longDesc: 'Importera leverantörsfakturor. Nyckelfunktioner: Parsning, inprisuppdatering, auto-kontosättning' },
{ slug: 'lagerhantering', name: 'Lagerhantering', cat: 'operativ', desc: 'Lagernivåer och rörelser', longDesc: 'Lagernivåer och rörelser. Nyckelfunktioner: Inleverans, bristlista, beställningspunkt, lagerplatser' },
{ slug: 'kampanjer-rabatter', name: 'Kampanjer & Rabatter', cat: 'operativ', desc: 'Priskampanjer och erbjudanden', longDesc: 'Priskampanjer och erbjudanden. Nyckelfunktioner: Rabattregler, tidsbegränsade kampanjer, marginalbevakning' },
{ slug: 'kundklubb', name: 'Kundklubb', cat: 'operativ', desc: 'Lojalitetsprogram med poäng', longDesc: 'Lojalitetsprogram. Nyckelfunktioner: Poängintjäning, belöningar, riktade kampanjer' },
{ slug: 'prishantering', name: 'Prishantering', cat: 'operativ', desc: 'Prissättning med marginalbevakning', longDesc: 'Centraliserad prissättning. Nyckelfunktioner: Inpris vs utpris, marginalkalkyl, prisändringslogg' },
{ slug: 'butiksdrift-schema', name: 'Butiksdrift & Schema', cat: 'operativ', desc: 'Bemanning och öppettider', longDesc: 'Bemanning och öppettider. Nyckelfunktioner: Schemaläggning, timrapport, budgetjämförelse' },
],
},
{
slug: 'ehandel', name: 'E-handel', icon: ShoppingCart,
description: 'Ordrar, frakt & returer',
modules: [
{ slug: 'ehandelskontoplan', name: 'E-handelskontoplan', cat: 'bokforing', desc: 'BAS-konton för order, frakt, returer, avgifter', longDesc: 'BAS-konton för orderintäkter, fraktintäkt, returer, plattformsavgifter. Nyckelfunktioner: Konton 3001 (varuförsäljning), 3540 (fraktintäkt), 3740 (returkostnad), 6590 (plattformsavgift)' },
{ slug: 'lagervardering-ehandel', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO/vägt genomsnitt för e-handel', longDesc: 'Löpande lagervärde för bokslut. Nyckelfunktioner: FIFO, vägt genomsnitt, månadsbokslut, inkuransbedömning' },
{ slug: 'returbokforing', name: 'Returbokföring', cat: 'bokforing', desc: 'Automatisk kreditering vid returer', longDesc: 'Automatisk kreditering vid returer. Nyckelfunktioner: Lageråterföring, konto 3740, momskorrigering, periodsummering' },
{ slug: 'multi-currency', name: 'Multi-currency', cat: 'bokforing', desc: 'Bokför utländsk valuta i SEK', longDesc: 'Bokför försäljning i utländsk valuta i SEK. Nyckelfunktioner: Valutaomvandling via Riksbanken-kurs, kursdifferens konto 3960/7960' },
{ slug: 'eu-moms-oss', name: 'EU-moms (OSS)', cat: 'bokforing', desc: 'One Stop Shop för EU-distansförsäljning', longDesc: 'One Stop Shop för moms vid EU-distansförsäljning. Nyckelfunktioner: Moms per destinationsland, OSS-deklarationsunderlag, tröskelvärdebevakning' },
{ slug: 'plattformsavgifter', name: 'Plattformsavgifter', cat: 'bokforing', desc: 'Bokför Shopify/Stripe/Klarna-avgifter', longDesc: 'Bokför avgifter från Shopify, Stripe, Klarna korrekt. Nyckelfunktioner: Bruttoförsäljning → avgift → nettoutbetalning, periodisering' },
{ slug: 'returprocent', name: 'Returprocent', cat: 'rapport', desc: 'Returer som andel av försäljning', longDesc: 'Returer som andel av försäljning. Nyckelfunktioner: Per produktkategori, kostnad per retur, trend' },
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittorder i kronor', longDesc: 'Snittorder i kronor. Nyckelfunktioner: Trend, kanalfördelning, kampanjeffekt' },
{ slug: 'kanalfordelning', name: 'Kanalfördelning', cat: 'rapport', desc: 'Intäkt per försäljningskanal', longDesc: 'Intäkt per försäljningskanal. Nyckelfunktioner: Egen webb vs marknadsplats, marginal per kanal' },
{ slug: 'fraktkostnad-vs-intakt', name: 'Fraktkostnad vs intäkt', cat: 'rapport', desc: 'Fraktnetto per order', longDesc: 'Fraktnetto per order. Nyckelfunktioner: Fraktsubvention, break-even-analys' },
{ slug: 'shopify-woo-import', name: 'Shopify/Woo-import', cat: 'import', desc: 'Importera ordrar från Shopify/WooCommerce', longDesc: 'Importera ordrar och utbetalningsrapporter. Nyckelfunktioner: CSV-export, auto-bokföring per order inkl. moms, frakt, rabatter' },
{ slug: 'klarna-rapport-import', name: 'Klarna-rapport-import', cat: 'import', desc: 'Importera Klarna-utbetalningar', longDesc: 'Importera Klarna-utbetalningar och avgifter. Nyckelfunktioner: Bruttobelopp, avgiftsavdrag, nettoutbetalning, periodmatchning' },
{ slug: 'fraktrapport-import', name: 'Fraktrapport-import', cat: 'import', desc: 'Importera fraktkostnader per order', longDesc: 'Importera fraktkostnader per order. Nyckelfunktioner: Postnord/DHL/Budbee-rapporter, kostnadsfördelning' },
{ slug: 'orderhantering', name: 'Orderhantering', cat: 'operativ', desc: 'Central ordervy från alla kanaler', longDesc: 'Central ordervy. Nyckelfunktioner: Orderstatus, plocklista, batch-hantering' },
{ slug: 'frakthantering', name: 'Frakthantering', cat: 'operativ', desc: 'Hantera frakt med transportörer', longDesc: 'Hantera frakt. Nyckelfunktioner: Fraktsedlar, spårning, returlogistik' },
{ slug: 'returhantering-operativ', name: 'Returhantering', cat: 'operativ', desc: 'Hantera returer och byten', longDesc: 'Hantera returer och byten. Nyckelfunktioner: Returgodkännande, lageråterföring, återbetalning' },
{ slug: 'produktdatahantering', name: 'Produktdatahantering', cat: 'operativ', desc: 'Central produktinformation', longDesc: 'Central produktinfo. Nyckelfunktioner: Beskrivningar, bilder, attribut' },
],
},
{
slug: 'frisor', name: 'Frisör & Skönhet', icon: Scissors,
description: 'Tidsbokning, kunder & produkter',
modules: [
{ slug: 'salongkontoplan', name: 'Salongkontoplan', cat: 'bokforing', desc: 'BAS-konton: behandling, produktförsäljning, presentkort', longDesc: 'BAS-konton uppdelade: tjänsteintäkter, produktförsäljning, presentkort. Nyckelfunktioner: Konton 3010 (behandling), 3020 (produktförsäljning), 2420 (presentkortsskuld)' },
{ slug: 'provisionsberakning', name: 'Provisionsberäkning', cat: 'bokforing', desc: 'Provision per frisör/terapeut som lönekostnad', longDesc: 'Räkna provision per frisör/terapeut och bokför som lönekostnad. Nyckelfunktioner: Procent på tjänst/produkt, trappsteg, konto 7010/7210, månadssammanställning' },
{ slug: 'presentkort-som-skuld', name: 'Presentkort som skuld', cat: 'bokforing', desc: 'Presentkort som förutbetald intäkt', longDesc: 'Bokför sålda presentkort som förutbetald intäkt tills inlösen. Nyckelfunktioner: Konto 2420 (skuld), intäktsföring vid inlösen, förfallohantering' },
{ slug: 'kassaavstamning-salong', name: 'Kassaavstämning salong', cat: 'bokforing', desc: 'Daglig avstämning kort/Swish/kontant', longDesc: 'Daglig avstämning kort/Swish/kontant. Nyckelfunktioner: Z-rapport, avvikelse, verifikation per dag' },
{ slug: 'personalliggare-salong', name: 'Personalliggare salong', cat: 'bokforing', desc: 'Personalliggare enligt Skatteverket', longDesc: 'Elektronisk personalliggare enligt Skatteverket. Nyckelfunktioner: In/ut-registrering, export vid kontroll' },
{ slug: 'intakt-per-stol', name: 'Intäkt per stol', cat: 'rapport', desc: 'Omsättning per arbetsstation', longDesc: 'Omsättning per arbetsstation. Nyckelfunktioner: Per dag/vecka, beläggningsgrad, optimeringsförslag' },
{ slug: 'provisionsandel', name: 'Provisionsandel', cat: 'rapport', desc: 'Provisionskostnad som andel av intäkt', longDesc: 'Provisionskostnad som andel av tjänsteintäkt. Nyckelfunktioner: Per frisör, trend, lönsamhet per anställd' },
{ slug: 'produktforsaljning-per-besok', name: 'Produktförsäljning per besök', cat: 'rapport', desc: 'Merförsäljning av produkter', longDesc: 'Merförsäljning av produkter. Nyckelfunktioner: Snitt per kund, andel av omsättning' },
{ slug: 'kassarapport-import-salong', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa från iZettle', longDesc: 'Importera dagskassa från kassasystem. Nyckelfunktioner: CSV från iZettle/Zettle, auto-kontosättning' },
{ slug: 'bokningssystem-import', name: 'Bokningssystem-import', cat: 'import', desc: 'Importera från Timma, Fresha, Planway', longDesc: 'Importera bokningsdata och intäkter. Nyckelfunktioner: Export från Timma, Fresha, Planway; intäktsfördelning per behandlare' },
{ slug: 'tidsbokning', name: 'Tidsbokning', cat: 'operativ', desc: 'Online och receptionsbokning', longDesc: 'Online och receptionsbokning. Nyckelfunktioner: Bokningswidget, SMS-påminnelse, väntelista' },
{ slug: 'kundkort', name: 'Kundkort', cat: 'operativ', desc: 'Profil med behandlingshistorik', longDesc: 'Profil med behandlingshistorik. Nyckelfunktioner: Favoritbehandlare, allergier, färgrecept' },
{ slug: 'sms-paminnelser', name: 'SMS-påminnelser', cat: 'operativ', desc: 'Automatiska bokningspåminnelser', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: Bokningspåminnelse, återbesökspåminnelse' },
{ slug: 'skiftschema-salong', name: 'Skiftschema', cat: 'operativ', desc: 'Schemalägg personal med stolsfördelning', longDesc: 'Schemalägg personal. Nyckelfunktioner: Stolsfördelning, ledighetsönskemål' },
],
},
{
slug: 'transport', name: 'Transport & Logistik', icon: Truck,
description: 'Flotta, rutter & leveranser',
modules: [
{ slug: 'transportkontoplan', name: 'Transportkontoplan', cat: 'bokforing', desc: 'BAS-konton: bränsle, leasing, underhåll, försäkring', longDesc: 'BAS-konton per kostnadskategori: bränsle, leasing, underhåll, försäkring. Nyckelfunktioner: Konton per fordon möjligt, kostnadskonton 5610/5620/5611/5612' },
{ slug: 'fordonsavskrivning', name: 'Fordonsavskrivning', cat: 'bokforing', desc: 'Avskrivning per fordon (räkenskapsenlig)', longDesc: 'Hantera avskrivning per fordon. Nyckelfunktioner: Linjär/degressiv, räkenskapsenlig avskrivning, konto 1240/7832' },
{ slug: 'leasinghantering', name: 'Leasinghantering', cat: 'bokforing', desc: 'Operationell vs finansiell leasing', longDesc: 'Bokför leasing korrekt (operationell vs finansiell). Nyckelfunktioner: Leasingavgift som driftskostnad, restvärde, avtalsperiod' },
{ slug: 'trangselskatt', name: 'Trängselskatt', cat: 'bokforing', desc: 'Avdragsgill kostnad (konto 5615)', longDesc: 'Bokför trängselskatt som avdragsgill kostnad. Nyckelfunktioner: Auto-kontosättning konto 5615, per fordon, periodsammanställning' },
{ slug: 'milersattning-vs-faktisk-kostnad', name: 'Milersättning vs faktisk kostnad', cat: 'bokforing', desc: 'Jämför schablon (25 kr/mil) mot verklig kostnad', longDesc: 'Jämför schablon (25 kr/mil) mot verklig fordonskostnad. Nyckelfunktioner: Per fordon, rekommendation avdragsmetod, Skatteverket-schabloner' },
{ slug: 'kostnad-per-mil', name: 'Kostnad per mil', cat: 'rapport', desc: 'Total driftskostnad per mil och fordon', longDesc: 'Total driftskostnad per mil och per fordon. Nyckelfunktioner: Bränsle + underhåll + avskrivning + försäkring, trend' },
{ slug: 'intakt-per-fordon', name: 'Intäkt per fordon', cat: 'rapport', desc: 'Omsättning fördelad per fordon', longDesc: 'Omsättning fördelad per fordon. Nyckelfunktioner: Lönsamhet per fordon, underutnyttjade resurser' },
{ slug: 'bransleeffektivitet', name: 'Bränsleeffektivitet', cat: 'rapport', desc: 'Bränsleförbrukning per fordon', longDesc: 'Bränsleförbrukning per fordon och period. Nyckelfunktioner: Liter/mil, trend, avvikelse mot förväntat' },
{ slug: 'branslekort-import', name: 'Bränslekort-import', cat: 'import', desc: 'Importera tankningar (Circle K, OKQ8, Preem)', longDesc: 'Importera tankningar från bränslekort (Circle K, OKQ8, Preem). Nyckelfunktioner: CSV, auto-kontosättning per fordon' },
{ slug: 'vagtulls-import', name: 'Vägtulls-import', cat: 'import', desc: 'Importera trängselskatt (Transportstyrelsen)', longDesc: 'Importera trängselskatt och broavgifter. Nyckelfunktioner: Transportstyrelsen-format, auto-kontosättning' },
{ slug: 'flottahantering', name: 'Flottahantering', cat: 'operativ', desc: 'Fordonsregister och översikt', longDesc: 'Fordonsregister och översikt. Nyckelfunktioner: Besiktning, försäkring, leasingperioder' },
{ slug: 'ruttplanering', name: 'Ruttplanering', cat: 'operativ', desc: 'Optimera körrutter', longDesc: 'Optimera körrutter. Nyckelfunktioner: Adressklustring, tidsestimering, kapacitet' },
{ slug: 'leveranssparning', name: 'Leveransspårning', cat: 'operativ', desc: 'Realtidsspårning av leveranser', longDesc: 'Realtidsspårning av leveranser. Nyckelfunktioner: GPS, ETA, leveransbevis, kundnotifiering' },
{ slug: 'fordonsunderhall', name: 'Fordonsunderhåll', cat: 'operativ', desc: 'Planera service och reparationer', longDesc: 'Planera service och reparationer. Nyckelfunktioner: Serviceintervall, reservdelshistorik' },
{ slug: 'chauforshantering', name: 'Chaufförshantering', cat: 'operativ', desc: 'Personal- och behörighetsregister', longDesc: 'Personal och behörigheter. Nyckelfunktioner: Körkortsklass, YKB, timrapport' },
{ slug: 'fraktsedlar-dokument', name: 'Fraktsedlar & Dokument', cat: 'operativ', desc: 'Generera fraktdokument', longDesc: 'Generera fraktdokument. Nyckelfunktioner: CMR, följesedel, digital signatur' },
],
},
{
slug: 'juridik', name: 'Juridik & Redovisning', icon: Scale,
description: 'Ärenden, tidrapport & klienter',
modules: [
{ slug: 'juristkontoplan', name: 'Juristkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvoden, klientmedel, utlägg', longDesc: 'BAS-konton för arvodesintäkter, klientmedel, utlägg. Nyckelfunktioner: Konton 3010 (arvoden), 1690 (klientmedel i förvaltning), 6990 (utlägg för klients räkning)' },
{ slug: 'klientmedelskonto', name: 'Klientmedelskonto', cat: 'bokforing', desc: 'Separat hantering av klientmedel', longDesc: 'Separat hantering av klientmedel (disciplinnämndsregler). Nyckelfunktioner: Konto 1690/2890, klientmedelslista, avstämning mot bankkonto, rapportering' },
{ slug: 'wip-vardering', name: 'WIP-värdering', cat: 'bokforing', desc: 'Pågående arbete ej fakturerat', longDesc: 'Värdera pågående arbete (nedlagd tid ej fakturerad). Nyckelfunktioner: Beräkning av WIP, konto 1470, månatlig omvärdering' },
{ slug: 'a-conto-bokforing', name: 'A conto-bokföring', cat: 'bokforing', desc: 'A conto-fakturor och slutavräkning', longDesc: 'Bokför a conto-fakturor och slutavräkning. Nyckelfunktioner: Förskottsbokföring konto 2420, avräkning vid slutfaktura' },
{ slug: 'debiteringsgrad-juridik', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per jurist/konsult, mål vs utfall, trend' },
{ slug: 'realisationsgrad', name: 'Realisationsgrad', cat: 'rapport', desc: 'Fakturerat vs tidvärde', longDesc: 'Fakturerat belopp vs tidvärde (nedskrivning av tid). Nyckelfunktioner: Per ärende, mönsteranalys, lönsamhet per ärendetyp' },
{ slug: 'genomsnittlig-timintakt', name: 'Genomsnittlig timintäkt', cat: 'rapport', desc: 'Faktiskt fakturerat per timme', longDesc: 'Faktiskt fakturerat per timme. Nyckelfunktioner: Per jurist, klient, ärendetyp; jämförelse mot prislista' },
{ slug: 'wip-rapport', name: 'WIP-rapport', cat: 'rapport', desc: 'Pågående arbete med åldringsanalys', longDesc: 'Totalt pågående arbete ej fakturerat. Nyckelfunktioner: Per klient/ärende, åldringsanalys, risk för nedskrivning' },
{ slug: 'tidrapport-import-juridik', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Clio, Maconomy', longDesc: 'Importera tid från ärendehanteringssystem. Nyckelfunktioner: CSV från Time, Clio, Maconomy; koppling till ärende och klient' },
{ slug: 'arendehantering-juridik', name: 'Ärendehantering', cat: 'operativ', desc: 'Administrera klientärenden', longDesc: 'Administrera klientärenden. Nyckelfunktioner: Ärendetyper, statusflöde, deadline-bevakning' },
{ slug: 'tidrapportering-juridik', name: 'Tidrapportering', cat: 'operativ', desc: 'Registrera tid per ärende', longDesc: 'Registrera tid per ärende. Nyckelfunktioner: Debiterbara vs interna timmar, godkännande' },
{ slug: 'dokumenthantering', name: 'Dokumenthantering', cat: 'operativ', desc: 'Versionshanterade dokument', longDesc: 'Versionshanterade dokument per ärende. Nyckelfunktioner: Mallbibliotek, e-signering, klientdelning' },
{ slug: 'deadlinebevakning', name: 'Deadlinebevakning', cat: 'operativ', desc: 'Påminnelser för tidsfrister', longDesc: 'Påminnelser för tidsfrister. Nyckelfunktioner: Preskriptionstider, eskaleringsregler' },
{ slug: 'intressekonfliktskontroll', name: 'Intressekonfliktskontroll', cat: 'operativ', desc: 'Kontrollera jäv vid nya ärenden', longDesc: 'Kontrollera jäv vid nya ärenden. Nyckelfunktioner: Partsregister, automatisk sökning, logg' },
],
},
{
slug: 'utbildning', name: 'Utbildning & Förskola', icon: GraduationCap,
description: 'Schema, elever & närvaro',
modules: [
{ slug: 'utbildningskontoplan', name: 'Utbildningskontoplan', cat: 'bokforing', desc: 'BAS-konton: kommunal peng, avgift, statsbidrag', longDesc: 'BAS-konton för kommunal peng, statsbidrag, föräldraavgift. Nyckelfunktioner: Konton 3010 (kommunal ersättning), 3020 (föräldraavgift), 3910 (statsbidrag)' },
{ slug: 'maxtaxa-fakturering', name: 'Maxtaxa-fakturering', cat: 'bokforing', desc: 'Avgift enligt maxtaxa-reglerna', longDesc: 'Automatisk beräkning av avgift enligt maxtaxa-reglerna. Nyckelfunktioner: Inkomstkontroll, syskonrabatt, schema-avdrag, frånvaroavdrag' },
{ slug: 'statsbidragsperiodisering', name: 'Statsbidragsperiodisering', cat: 'bokforing', desc: 'Periodisera statsbidrag korrekt', longDesc: 'Periodisera statsbidrag korrekt över den period de avser. Nyckelfunktioner: Konto 2970 (förutbetald intäkt), villkorsuppfyllnad, återbetalningsrisk' },
{ slug: 'momsfrihet-utbildning', name: 'Momsfrihet utbildning', cat: 'bokforing', desc: 'Momsfria utbildningstjänster', longDesc: 'Utbildningstjänster är momsfria. Nyckelfunktioner: Korrekt momsbedömning, sidoverksamhet (cafeteria, uthyrning) kan vara momspliktig' },
{ slug: 'kostnad-per-barn-elev', name: 'Kostnad per barn/elev', cat: 'rapport', desc: 'Driftskostnad per inskrivet barn', longDesc: 'Total driftskostnad per inskrivet barn. Nyckelfunktioner: Personal, lokal, mat, material; jämförelse med kommunal peng' },
{ slug: 'personaltathet', name: 'Personaltäthet', cat: 'rapport', desc: 'Antal barn per heltidsanställd', longDesc: 'Antal barn per heltidsanställd. Nyckelfunktioner: Trend, lagkrav (förskolans riktlinje), schemaanalys' },
{ slug: 'kommunal-peng-import', name: 'Kommunal peng-import', cat: 'import', desc: 'Importera utbetalningsbesked', longDesc: 'Importera utbetalningsbesked från kommun. Nyckelfunktioner: Periodisering, kontosättning, avstämning' },
{ slug: 'schemalaeggning', name: 'Schemaläggning', cat: 'operativ', desc: 'Bygg undervisningsschema', longDesc: 'Undervisningsschema. Nyckelfunktioner: Per klass/lärare/sal, konfliktkontroll' },
{ slug: 'elevregister', name: 'Elevregister', cat: 'operativ', desc: 'Elev- och barnuppgifter', longDesc: 'Elev- och barnuppgifter. Nyckelfunktioner: Kontakt, vårdnadshavare, allergi, specialbehov' },
{ slug: 'narvarohantering', name: 'Närvarohantering', cat: 'operativ', desc: 'Registrera frånvaro', longDesc: 'Registrera frånvaro. Nyckelfunktioner: Daglig närvaro, mönsteranalys, föräldraavisering' },
{ slug: 'foraldrakommunikation', name: 'Föräldrakommunikation', cat: 'operativ', desc: 'Meddelanden till vårdnadshavare', longDesc: 'Meddelanden till vårdnadshavare. Nyckelfunktioner: Push/e-post/SMS, nyhetsflöde' },
{ slug: 'matsedel-allergikost', name: 'Matsedel & Allergikost', cat: 'operativ', desc: 'Publicera matsedel', longDesc: 'Publicera matsedel. Nyckelfunktioner: Veckomatsdel, allergiflaggor' },
{ slug: 'vikariebokning', name: 'Vikariebokning', cat: 'operativ', desc: 'Hantera vikarier', longDesc: 'Hantera vikarier. Nyckelfunktioner: Vikariepool, snabbbokning, kostnadsspårning' },
],
},
{
slug: 'jordbruk', name: 'Jordbruk & Livsmedel', icon: Wheat,
description: 'Skörd, djurhållning & spårbarhet',
modules: [
{ slug: 'jordbrukskontoplan', name: 'Jordbrukskontoplan', cat: 'bokforing', desc: 'BAS-konton: gröda, djur, skog, EU-stöd', longDesc: 'BAS-konton anpassade för gröda, djur, skogsbruk, EU-stöd. Nyckelfunktioner: Konton 3010 (växtodling), 3020 (djurförsäljning), 3910 (EU-stöd), 1280 (biologiska tillgångar)' },
{ slug: 'skogskonto', name: 'Skogskonto', cat: 'bokforing', desc: 'Insättning/uttag skogskonto (max 60%)', longDesc: 'Hantera insättning och uttag från skogskonto. Nyckelfunktioner: Insättning (max 60% av skogsintäkt), beskattning vid uttag, konto 1760' },
{ slug: 'expansionsfond', name: 'Expansionsfond', cat: 'bokforing', desc: 'Avsättning för enskild firma (20.6% skatt)', longDesc: 'Beräkna avsättning till expansionsfond för enskild firma. Nyckelfunktioner: Beräkning av underlag, expansionsfondsskatt 20,6%, NE-bilaga' },
{ slug: 'rantefordelning', name: 'Räntefördelning', cat: 'bokforing', desc: 'Positiv/negativ räntefördelning', longDesc: 'Positiv/negativ räntefördelning på kapital. Nyckelfunktioner: Kapitalunderlag, statslåneränta, inkomstfördelning tjänst/kapital' },
{ slug: 'eu-stod-som-intakt', name: 'EU-stöd som intäkt', cat: 'bokforing', desc: 'Periodisera EU-jordbruksstöd', longDesc: 'Periodisera EU-jordbruksstöd korrekt. Nyckelfunktioner: Intäktsföring per period, villkorsskuld, jämförelse ansökt vs utbetalt' },
{ slug: 'biologiska-tillgangar', name: 'Biologiska tillgångar', cat: 'bokforing', desc: 'Värdera djur och grödor', longDesc: 'Värdera djur och grödor som tillgång. Nyckelfunktioner: Anskaffningsvärde vs verkligt värde, konto 1280, årlig omvärdering' },
{ slug: 'momssplit-livsmedel', name: 'Momssplit livsmedel', cat: 'bokforing', desc: 'Moms 12% livsmedel, 25% övrigt', longDesc: 'Moms 12% vid livsmedelsförsäljning, 25% övriga varor. Nyckelfunktioner: Automatisk momskod per produkttyp' },
{ slug: 'avkastning-per-hektar', name: 'Avkastning per hektar', cat: 'rapport', desc: 'Intäkt minus kostnad per yta', longDesc: 'Intäkt minus kostnad per odlad yta. Nyckelfunktioner: Per gröda, säsongsjämförelse, vädereffekt' },
{ slug: 'bidragsberoende', name: 'Bidragsberoende', cat: 'rapport', desc: 'EU-stöd som andel av intäkt', longDesc: 'EU-stöd som andel av total intäkt. Nyckelfunktioner: Trend, risk vid policyändring' },
{ slug: 'djurkostnad-per-enhet', name: 'Djurkostnad per enhet', cat: 'rapport', desc: 'Total kostnad per djur', longDesc: 'Total kostnad per djur (foder, veterinär, stall). Nyckelfunktioner: Per djurtyp, jämförelse med intäkt vid slakt/försäljning' },
{ slug: 'sam-utbetalningsimport', name: 'SAM-utbetalningsimport', cat: 'import', desc: 'Importera EU-stödutbetalningar', longDesc: 'Importera utbetalningsbesked för EU-jordbruksstöd. Nyckelfunktioner: Jordbruksverket-format, periodisering, auto-kontosättning' },
{ slug: 'skordeplanering', name: 'Skördeplanering', cat: 'operativ', desc: 'Planera odlingscykler', longDesc: 'Planera odlingscykler. Nyckelfunktioner: Fältregister, grödor, sådd/skörd-datum' },
{ slug: 'djurhallning', name: 'Djurhållning', cat: 'operativ', desc: 'Register och uppföljning', longDesc: 'Register och uppföljning av besättning. Nyckelfunktioner: Individregister, hälsologg, Jordbruksverket-rapportering' },
{ slug: 'sparbarhet', name: 'Spårbarhet', cat: 'operativ', desc: 'Spåra livsmedel från jord till bord', longDesc: 'Spåra livsmedel från jord till bord. Nyckelfunktioner: Batchnummer, leveranskedja, återkallning' },
{ slug: 'maskinlogg', name: 'Maskinlogg', cat: 'operativ', desc: 'Drifttid och underhåll', longDesc: 'Drifttid och underhåll. Nyckelfunktioner: Maskinregister, timlogg, servicepåminnelse' },
{ slug: 'certifieringar', name: 'Certifieringar', cat: 'operativ', desc: 'KRAV, ekologisk certifiering', longDesc: 'KRAV, ekologisk, EU-ekologisk. Nyckelfunktioner: Certifikatregister, giltighetstid, revisionsförberedelse' },
],
},
{
slug: 'media', name: 'Media & Kommunikation', icon: Film,
description: 'Projekt, innehåll & publicering',
modules: [
{ slug: 'mediakontoplan', name: 'Mediakontoplan', cat: 'bokforing', desc: 'BAS-konton: projekt, licens, royalty', longDesc: 'BAS-konton per intäktstyp: projektarvoden, licensintäkt, royalties. Nyckelfunktioner: Konton 3010 (projektintäkt), 3020 (licensintäkt), 3910 (royalty)' },
{ slug: 'projektredovisning-media', name: 'Projektredovisning', cat: 'bokforing', desc: 'Intäkter och kostnader per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Budget vs utfall, WIP vid pågående projekt, projektbokslut' },
{ slug: 'freelancer-bokforing', name: 'Freelancer-bokföring', cat: 'bokforing', desc: 'Frilansarfakturor med F-skatt-kontroll', longDesc: 'Hantera fakturor från frilansare korrekt. Nyckelfunktioner: F-skatt-kontroll, auto-kontosättning konto 4010, kostnad per projekt' },
{ slug: 'kulturmoms', name: 'Kulturmoms', cat: 'bokforing', desc: 'Moms 6% på publicering/film/kultur', longDesc: 'Hantera moms 6% vid publicering, film, kultur. Nyckelfunktioner: Korrekt momssats per tjänsttyp, blandad verksamhet' },
{ slug: 'ip-tillgangar', name: 'IP-tillgångar', cat: 'bokforing', desc: 'Immateriella rättigheter', longDesc: 'Aktivera och avskriva immateriella rättigheter. Nyckelfunktioner: Aktivering, avskrivningsplan, konto 1010/1020' },
{ slug: 'projektlonsamhet-media', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Budget vs utfall, timkostnad vs debiterat' },
{ slug: 'freelancerandel', name: 'Freelancerandel', cat: 'rapport', desc: 'Freelancerkostnad av produktion', longDesc: 'Andel av produktionskostnad som går till freelancers. Nyckelfunktioner: Trend, lönsamhetspåverkan, per projekttyp' },
{ slug: 'kundlonsamhet', name: 'Kundlönsamhet', cat: 'rapport', desc: 'Intäkt och marginal per kund', longDesc: 'Intäkt och marginal per kund. Nyckelfunktioner: Tidsförbrukning vs fakturerat, återkommande kunder' },
{ slug: 'freelancer-fakturaimport', name: 'Freelancer-fakturaimport', cat: 'import', desc: 'Importera frilansarfakturor', longDesc: 'Importera fakturor från frilansare. Nyckelfunktioner: Parsning, F-skatt-verifiering, projektkoppling' },
{ slug: 'kampanjrapport-import', name: 'Kampanjrapport-import', cat: 'import', desc: 'Importera kampanjresultat', longDesc: 'Importera kampanjresultat. Nyckelfunktioner: Intäkter per kampanj, provisionskostnader' },
{ slug: 'projekthantering-media', name: 'Projekthantering', cat: 'operativ', desc: 'Styra kundprojekt', longDesc: 'Styra kundprojekt. Nyckelfunktioner: Projekttavla, tidslinje, kundgodkännande' },
{ slug: 'innehallsplanering', name: 'Innehållsplanering', cat: 'operativ', desc: 'Redaktionskalender', longDesc: 'Redaktionskalender. Nyckelfunktioner: Kanalvy, statusflöde, deadlines' },
{ slug: 'mediebank', name: 'Mediebank', cat: 'operativ', desc: 'Lagring av bilder, video, grafik', longDesc: 'Central lagring av media. Nyckelfunktioner: Taggning, sök, rättighetsspårning' },
{ slug: 'tidrapport-debitering-media', name: 'Tidrapport & Debitering', cat: 'operativ', desc: 'Logga tid per kund', longDesc: 'Logga tid per kund. Nyckelfunktioner: Timer, budget vs utfall, fakturaunderlag' },
],
},
{
slug: 'fitness', name: 'Fitness & Sport', icon: Dumbbell,
description: 'Medlemskap, klasser & pass',
modules: [
{ slug: 'fitnesskontoplan', name: 'Fitnesskontoplan', cat: 'bokforing', desc: 'BAS-konton: medlemskap, PT, klippkort, shop', longDesc: 'BAS-konton per intäktstyp: medlemskap, PT, klippkort, shop. Nyckelfunktioner: Konton 3010 (medlemsavgift), 3020 (PT), 2420 (klippkortsskuld), 3030 (shop)' },
{ slug: 'momssplit-idrott-pt', name: 'Momssplit idrott/PT', cat: 'bokforing', desc: 'Moms 6% idrott, 25% PT/spa/shop', longDesc: 'Moms 6% på idrottslig verksamhet, 25% på PT, spa, shop. Nyckelfunktioner: Automatisk momssats per tjänsttyp, blandad verksamhet' },
{ slug: 'autogiro-periodisering', name: 'Autogiro-periodisering', cat: 'bokforing', desc: 'Autogiro som intäkt rätt period', longDesc: 'Bokför autogiro-dragningar som intäkt rätt period. Nyckelfunktioner: Förskottsbetalning, periodisering konto 2970, misslyckade dragningar' },
{ slug: 'klippkort-som-skuld', name: 'Klippkort som skuld', cat: 'bokforing', desc: 'Klippkort som förutbetald intäkt', longDesc: 'Sålda klippkort/startpaket bokförs som förutbetald intäkt. Nyckelfunktioner: Konto 2420, intäktsföring per nyttjat tillfälle, förfallohantering' },
{ slug: 'friskvardsbidrag', name: 'Friskvårdsbidrag', cat: 'bokforing', desc: 'Friskvårdsbidrag (max 5 000 SEK)', longDesc: 'Hantera friskvårdsbidrag från arbetsgivare. Nyckelfunktioner: Skatteverkets gräns (5 000 SEK), kvittogenerering, dokumentation' },
{ slug: 'churn-rate', name: 'Churn rate', cat: 'rapport', desc: 'Medlemsavgång per månad', longDesc: 'Medlemsavgång per månad. Nyckelfunktioner: Trend, orsaksanalys, retention per avtalstyp' },
{ slug: 'intakt-per-medlem', name: 'Intäkt per medlem', cat: 'rapport', desc: 'Genomsnittlig intäkt per medlem', longDesc: 'Genomsnittlig intäkt per aktiv medlem. Nyckelfunktioner: Inklusive PT, shop, extra tjänster' },
{ slug: 'belaggningsgrad-klasser', name: 'Beläggningsgrad klasser', cat: 'rapport', desc: 'Fyllnadsgrad per träningspass', longDesc: 'Fyllnadsgrad per gruppträningspass. Nyckelfunktioner: Per klass/tid, optimering av schema' },
{ slug: 'autogiro-rapport-import', name: 'Autogiro-rapport-import', cat: 'import', desc: 'Importera autogiro (Bankgirot)', longDesc: 'Importera autogiro-dragningsrapport (Bankgirot). Nyckelfunktioner: Lyckade/misslyckade, auto-kontosättning, period-matchning' },
{ slug: 'kassarapport-import-fitness', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa', longDesc: 'Importera dagskassa. Nyckelfunktioner: Drop-in, PT-paket, shop; auto-kontosättning' },
{ slug: 'medlemshantering', name: 'Medlemshantering', cat: 'operativ', desc: 'Registrera och hantera medlemskap', longDesc: 'Registrera och hantera medlemskap. Nyckelfunktioner: Avtalstyper, förnyelse, uppsägning' },
{ slug: 'klassbokning', name: 'Klassbokning', cat: 'operativ', desc: 'Boka platser i träningsklasser', longDesc: 'Boka platser i träningspass. Nyckelfunktioner: Schema, platsbegränsning, väntelista' },
{ slug: 'pt-bokning', name: 'PT-bokning', cat: 'operativ', desc: 'Boka personlig tränare', longDesc: 'Boka personlig tränare. Nyckelfunktioner: Paket, kundhistorik, kalender' },
{ slug: 'tilltradeskontroll', name: 'Tillträdeskontroll', cat: 'operativ', desc: 'Inpassering till anläggning', longDesc: 'Inpassering till anläggning. Nyckelfunktioner: Nyckelbricka/QR, besökslogg' },
],
},
{
slug: 'fordon', name: 'Fordon & Verkstad', icon: Wrench,
description: 'Arbetsorder, delar & fordon',
modules: [
{ slug: 'verkstadskontoplan', name: 'Verkstadskontoplan', cat: 'bokforing', desc: 'BAS-konton: arbete, reservdelar, besiktning', longDesc: 'BAS-konton per intäktstyp: arbete, reservdelar, besiktning. Nyckelfunktioner: Konton 3010 (arbetsintäkt), 3020 (reservdelar), 4010 (inköp reservdelar)' },
{ slug: 'arbetsorder-till-faktura', name: 'Arbetsorder → Faktura', cat: 'bokforing', desc: 'Faktura direkt från arbetsorder', longDesc: 'Generera faktura direkt från arbetsorder. Nyckelfunktioner: Timpris + delar, automatisk summering, konto-koppling' },
{ slug: 'reservdelslager', name: 'Reservdelslager', cat: 'bokforing', desc: 'Lagervärdering reservdelar (FIFO)', longDesc: 'Lagervärdering av reservdelar. Nyckelfunktioner: FIFO, konto 1460, månadsbokslut, inkuransavdrag' },
{ slug: 'vmb-begagnade-delar', name: 'VMB (begagnade delar)', cat: 'bokforing', desc: 'Vinstmarginalbeskattning', longDesc: 'Vinstmarginalbeskattning vid försäljning av begagnade reservdelar. Nyckelfunktioner: Beräkning av vinstmarginal, moms enbart på marginalen, konto 2640' },
{ slug: 'garantiavsattning', name: 'Garantiavsättning', cat: 'bokforing', desc: 'Avsätt för garantikostnader', longDesc: 'Avsätt för framtida garantikostnader. Nyckelfunktioner: Avsättning konto 2290, upplösning vid faktisk kostnad' },
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittfaktura per arbetsorder', longDesc: 'Snittfaktura per arbetsorder. Nyckelfunktioner: Arbete vs delar, trend, kundtypsanalys' },
{ slug: 'reservdelsmarginal', name: 'Reservdelsmarginal', cat: 'rapport', desc: 'Inpris vs utpris på delar', longDesc: 'Inpris vs utpris på delar. Nyckelfunktioner: Per kategori, trend' },
{ slug: 'verkstadsbelaggning', name: 'Verkstadsbeläggning', cat: 'rapport', desc: 'Utnyttjandegrad lyftar/mekaniker', longDesc: 'Utnyttjandegrad av lyftar och mekaniker. Nyckelfunktioner: Per resurs, optimering' },
{ slug: 'reservdelsleverantor-import', name: 'Reservdelsleverantör-import', cat: 'import', desc: 'Importera från Mekonomen, Autoexperten', longDesc: 'Importera priser och leveranser. Nyckelfunktioner: Mekonomen, Autoexperten-format, auto-kontosättning' },
{ slug: 'arbetsorder', name: 'Arbetsorder', cat: 'operativ', desc: 'Skapa och hantera verkstadsarbeten', longDesc: 'Skapa och hantera arbeten. Nyckelfunktioner: Registrering, mekaniker-tilldelning, statusflöde' },
{ slug: 'fordonsregister', name: 'Fordonsregister', cat: 'operativ', desc: 'Register med fordonshistorik', longDesc: 'Register med fordonshistorik. Nyckelfunktioner: Regnummer, servicehistorik, besiktningsdatum' },
{ slug: 'verkstadsplanering', name: 'Verkstadsplanering', cat: 'operativ', desc: 'Beläggning av lyftar och personal', longDesc: 'Beläggning av lyftar och personal. Nyckelfunktioner: Kalendervy, bokningstider, akut-slot' },
{ slug: 'besiktningspaminnelse', name: 'Besiktningspåminnelse', cat: 'operativ', desc: 'Påminnelser inför besiktning', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: SMS/e-post, historik' },
],
},
{
slug: 'bemanning', name: 'Bemanning & HR', icon: UserPlus,
description: 'Kandidater, uppdrag & timmar',
modules: [
{ slug: 'bemanningskontoplan', name: 'Bemanningskontoplan', cat: 'bokforing', desc: 'BAS-konton: uthyrning, rekrytering, UE, löner', longDesc: 'BAS-konton per intäkts- och kostnadstyp: uthyrning, rekrytering, underkonsulter. Nyckelfunktioner: Konton 3010 (uthyrningsintäkt), 3020 (rekryteringsarvode), 4010 (UE-kostnad), 7010 (löner)' },
{ slug: 'tidrapport-dubbelbokforing', name: 'Tidrapport → Dubbelbokföring', cat: 'bokforing', desc: 'Genererar kundfaktura och löneunderlag', longDesc: 'Tidrapport genererar både kundfaktura och löneunderlag. Nyckelfunktioner: Timrapport, kunddebitering, lönekostnad, marginalberäkning per uppdrag' },
{ slug: 'arbetsgivaravgifter-periodisering', name: 'Arbetsgivaravgifter-periodisering', cat: 'bokforing', desc: 'Periodisera arbetsgivaravgifter (31.42%)', longDesc: 'Periodisera arbetsgivaravgifter korrekt per månad. Nyckelfunktioner: Beräkning per anställd, konto 7510, semesterlöneskuld, period-vy' },
{ slug: 'ue-verifiering-bemanning', name: 'UE-verifiering', cat: 'bokforing', desc: 'F-skatt och försäkring för underkonsulter', longDesc: 'Kontrollera F-skatt och försäkring för underkonsulter. Nyckelfunktioner: Skatteverkets API-kontroll, dokumentation, fakturamottagning' },
{ slug: 'traktamente-vid-uthyrning', name: 'Traktamente vid uthyrning', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente för uthyrd personal. Nyckelfunktioner: Skatteverkets schabloner, skattefri del, förmånsbeskattning vid övernattning' },
{ slug: 'marginal-per-konsult', name: 'Marginal per konsult', cat: 'rapport', desc: 'Debiterat minus lönekostnad', longDesc: 'Debiterat minus lönekostnad per uthyrd person. Nyckelfunktioner: Per uppdrag, trend, jämförelse' },
{ slug: 'belaggningsgrad-bemanning', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Andel uthyrd tid', longDesc: 'Andel uthyrd tid av total tid. Nyckelfunktioner: Per konsult, team, totalt' },
{ slug: 'fakturerat-per-konsult', name: 'Fakturerat per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person och period. Nyckelfunktioner: Ranking, budget vs utfall' },
{ slug: 'tidrapport-import-bemanning', name: 'Tidrapport-import', cat: 'import', desc: 'Importera timmar från kundsystem', longDesc: 'Importera timmar från kundsystem. Nyckelfunktioner: CSV, kundgodkännande-status, auto-koppling till uppdrag' },
{ slug: 'lonesystem-import', name: 'Lönesystem-import', cat: 'import', desc: 'Importera från Hogia/Visma Lön', longDesc: 'Importera löneunderlag. Nyckelfunktioner: Export från Hogia, Visma Lön; auto-bokföring av lön + sociala avgifter' },
{ slug: 'kandidatregister', name: 'Kandidatregister', cat: 'operativ', desc: 'Rekryteringsdatabas', longDesc: 'Rekryteringsdatabas. Nyckelfunktioner: CV, kompetens, status, GDPR-rensning' },
{ slug: 'uppdragshantering-bemanning', name: 'Uppdragshantering', cat: 'operativ', desc: 'Matcha kandidater med uppdrag', longDesc: 'Matcha kandidater med uppdrag. Nyckelfunktioner: Kravprofil, matchning, förlängning' },
{ slug: 'avtalshantering-bemanning', name: 'Avtalshantering', cat: 'operativ', desc: 'Kundavtal och konsultavtal', longDesc: 'Kundavtal och konsultavtal. Nyckelfunktioner: Templates, förnyelsepåminnelser' },
{ slug: 'kompetensregister', name: 'Kompetensregister', cat: 'operativ', desc: 'Certifieringar och utbildningar', longDesc: 'Certifieringar och utbildningar. Nyckelfunktioner: Per person, giltighetstid' },
{ slug: 'compliance-bemanning', name: 'Compliance', cat: 'operativ', desc: 'ID06, arbetstillstånd, försäkringar', longDesc: 'Lagar och branschkrav. Nyckelfunktioner: ID06-kontroll, arbetstillstånd' },
],
},
{
slug: 'tillverkning', name: 'Tillverkning & Industri', icon: Factory,
description: 'Produktion, lager & kvalitet',
modules: [
{ slug: 'tillverkningskontoplan', name: 'Tillverkningskontoplan', cat: 'bokforing', desc: 'BAS-konton per lagertyp och kostnadsställe', longDesc: 'BAS-konton per lagertyp och kostnadsställe. Nyckelfunktioner: Konton 1410 (råvarulager), 1420 (PIA), 1450 (färdigvarulager), 4010 (materialinköp)' },
{ slug: 'trestegslagervarderin', name: 'Trestegslagervärdering', cat: 'bokforing', desc: 'Värdera råvaror, PIA och färdigvaror', longDesc: 'Värdera råvaror, halvfabrikat (PIA) och färdigvaror separat. Nyckelfunktioner: FIFO per lagernivå, omvärdering vid produktion, konto 1410/1420/1450' },
{ slug: 'bom-kalkyl-lagervarde', name: 'BOM-kalkyl → Lagervärde', cat: 'bokforing', desc: 'Strukturlista styr självkostnad', longDesc: 'Strukturlista (BOM) styr självkostnad per tillverkad enhet. Nyckelfunktioner: Materialkomponenter, arbetskostnad, OH-pålägg, lagervärde per BOM' },
{ slug: 'produktionsavvikelse', name: 'Produktionsavvikelse', cat: 'bokforing', desc: 'Avvikelse standard vs faktisk kostnad', longDesc: 'Bokför avvikelse mellan kalkylerad och faktisk kostnad. Nyckelfunktioner: Standard- vs verklig kostnad, avvikelsekonto 4900' },
{ slug: 'maskinavskrivning-industri', name: 'Maskinavskrivning', cat: 'bokforing', desc: 'Avskrivning produktionsutrustning', longDesc: 'Avskrivningsplan för produktionsutrustning. Nyckelfunktioner: Räkenskapsenlig avskrivning, konto 1210/7831, restvärdeavskrivning' },
{ slug: 'energiskatteavdrag', name: 'Energiskatteavdrag', cat: 'bokforing', desc: 'Avdrag för energiskatt vid industri', longDesc: 'Avdrag för energiskatt vid industriell tillverkning. Nyckelfunktioner: Nedsättning av energiskatt, ansökan till Skatteverket, periodisering' },
{ slug: 'materialeffektivitet', name: 'Materialeffektivitet', cat: 'rapport', desc: 'Faktisk vs BOM-förbrukning', longDesc: 'Faktisk materialförbrukning vs BOM-kalkyl. Nyckelfunktioner: Spill %, kostnad för avvikelse, trend' },
{ slug: 'kostnad-per-producerad-enhet', name: 'Kostnad per producerad enhet', cat: 'rapport', desc: 'Total enhetskostnad', longDesc: 'Total kostnad (material + arbete + OH) per enhet. Nyckelfunktioner: Per produkt, period-jämförelse' },
{ slug: 'oee', name: 'OEE', cat: 'rapport', desc: 'Overall Equipment Effectiveness', longDesc: 'Maskinutnyttjandegrad. Nyckelfunktioner: Tillgänglighet × prestanda × kvalitet' },
{ slug: 'produktionsrapport-import', name: 'Produktionsrapport-import', cat: 'import', desc: 'Importera utfallsdata per order', longDesc: 'Importera utfallsdata per tillverkningsorder. Nyckelfunktioner: Godkänt/kasserat, materialförbrukning, auto-kontosättning' },
{ slug: 'lagerexport-import', name: 'Lagerexport-import', cat: 'import', desc: 'Importera lagerförändringar', longDesc: 'Importera lagerförändringar. Nyckelfunktioner: Per lagernivå, inventeringsresultat' },
{ slug: 'produktionsplanering', name: 'Produktionsplanering', cat: 'operativ', desc: 'Schemalägga tillverkningsorder', longDesc: 'Schemalägga tillverkningsorder. Nyckelfunktioner: Ordervy, maskinbeläggning, kapacitet' },
{ slug: 'strukturlista-bom', name: 'Strukturlista (BOM)', cat: 'operativ', desc: 'Materialstruktur per produkt', longDesc: 'Definiera materialstruktur. Nyckelfunktioner: Komponenter, nivåer, alternativa delar' },
{ slug: 'kvalitetskontroll', name: 'Kvalitetskontroll', cat: 'operativ', desc: 'Säkerställ produktkvalitet', longDesc: 'Säkerställ produktkvalitet. Nyckelfunktioner: Kontrollplaner, mätprotokoll, avvikelser' },
{ slug: 'maskinunderhall', name: 'Maskinunderhåll', cat: 'operativ', desc: 'Förebyggande underhåll', longDesc: 'Förebyggande och avhjälpande underhåll. Nyckelfunktioner: Schema, felanmälan, reservdelsförbrukning' },
{ slug: 'sparbarhet-batch', name: 'Spårbarhet & Batch', cat: 'operativ', desc: 'Spåra råvaror till färdig produkt', longDesc: 'Spåra råvaror till färdig produkt. Nyckelfunktioner: Batchnummer, komponentspårning, återkallning' },
],
},
{
slug: 'konsult', name: 'Konsult & Rådgivning', icon: Briefcase,
description: 'Uppdrag, tid & debitering',
modules: [
{ slug: 'konsultkontoplan', name: 'Konsultkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvode, resor, utlägg', longDesc: 'BAS-konton för konsultintäkt, utlägg, resor. Nyckelfunktioner: Konton 3010 (konsultarvode), 5800 (resekostnad), 6990 (vidarefakturerade utlägg)' },
{ slug: 'traktamente', name: 'Traktamente', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente vid tjänsteresor enligt Skatteverkets schabloner. Nyckelfunktioner: Hel/halvdag, reducering vid fri måltid, skattefri del, förmånsvärde' },
{ slug: 'hemmakontor-avdrag', name: 'Hemmakontor-avdrag', cat: 'bokforing', desc: 'Schablonavdrag (2000/4000 SEK)', longDesc: 'Schablonavdrag för hemmakontor. Nyckelfunktioner: Fast belopp (2 000/4 000 SEK), alternativt verklig kostnad med yta-beräkning' },
{ slug: 'wip-bevakning-konsult', name: 'WIP-bevakning', cat: 'bokforing', desc: 'Pågående ej fakturerat arbete', longDesc: 'Pågående ej fakturerat arbete. Nyckelfunktioner: Konto 1470, per uppdrag, åldringsanalys' },
{ slug: 'debiteringsgrad-konsult', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total', longDesc: 'Debiterbara timmar / total arbetstid. Nyckelfunktioner: Mål vs utfall, trend' },
{ slug: 'intakt-per-konsult', name: 'Intäkt per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person. Nyckelfunktioner: Ranking, budget vs utfall' },
{ slug: 'tidrapport-import-konsult', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl', longDesc: 'Importera tid från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV, koppling till uppdrag, fakturaunderlag' },
{ slug: 'uppdragshantering-konsult', name: 'Uppdragshantering', cat: 'operativ', desc: 'Administrera kunduppdrag', longDesc: 'Administrera kunduppdrag. Nyckelfunktioner: Milstolpar, statusvy, lönsamhetsanalys' },
{ slug: 'tidrapportering-konsult', name: 'Tidrapportering', cat: 'operativ', desc: 'Tidregistrering per uppdrag', longDesc: 'Detaljerad tidregistrering. Nyckelfunktioner: Timer, kategorier, veckoöversikt' },
{ slug: 'offert-avtal', name: 'Offert & Avtal', cat: 'operativ', desc: 'Skapa offerter och avtal', longDesc: 'Skapa offerter. Nyckelfunktioner: Mallar, godkännande, avtalsstatus' },
],
},
{
slug: 'event', name: 'Event & Underhållning', icon: PartyPopper,
description: 'Evenemang, biljetter & artister',
modules: [
{ slug: 'eventkontoplan', name: 'Eventkontoplan', cat: 'bokforing', desc: 'BAS-konton: biljetter, sponsring, F&B, merch', longDesc: 'BAS-konton per intäktstyp: biljetter, sponsring, F&B, merch. Nyckelfunktioner: Konton 3010 (biljettintäkt), 3020 (sponsorintäkt), 3030 (F&B), 3040 (merch)' },
{ slug: 'biljettintakt-som-forskott', name: 'Biljettintäkt som förskott', cat: 'bokforing', desc: 'Förskottssålda biljetter som skuld', longDesc: 'Bokför förskottssålda biljetter som skuld tills eventet äger rum. Nyckelfunktioner: Konto 2420, intäktsföring vid genomfört event, avbokningshantering' },
{ slug: 'kulturmoms-event', name: 'Kulturmoms event', cat: 'bokforing', desc: 'Moms 6% kultur, 25% F&B/merch', longDesc: 'Hantera reducerad moms på kulturella föreställningar. Nyckelfunktioner: Moms 6% på biljetter, 25% på mat/dryck/merch, blandad verksamhet' },
{ slug: 'artistskatt-sink', name: 'Artistskatt (SINK)', cat: 'bokforing', desc: 'SINK-skatt (15%) utländska artister', longDesc: 'Hantera källskatt för utländska artister. Nyckelfunktioner: Beräkning av SINK (15%), inbetalning till Skatteverket, underlag per artist' },
{ slug: 'sponsorintaktsbokforing', name: 'Sponsorintäktsbokföring', cat: 'bokforing', desc: 'Sponsoravtal med periodisering', longDesc: 'Bokför sponsoravtal med motprestationsperiodisering. Nyckelfunktioner: Periodisering per event, konto 3910, avtalsvärde vs motprestation' },
{ slug: 'intakt-per-besokare', name: 'Intäkt per besökare', cat: 'rapport', desc: 'Total intäkt per besökare', longDesc: 'Total intäkt delat på antal besökare. Nyckelfunktioner: Biljett + F&B + merch, jämförelse mellan event' },
{ slug: 'budget-vs-utfall', name: 'Budget vs utfall', cat: 'rapport', desc: 'Eventbudget vs verkligt resultat', longDesc: 'Eventbudget jämfört med verkligt resultat. Nyckelfunktioner: Per kostnadspost, totalt, vinstmarginal' },
{ slug: 'sponsorandel', name: 'Sponsorandel', cat: 'rapport', desc: 'Sponsorintäkt som andel av total', longDesc: 'Sponsorintäkt som andel av total intäkt. Nyckelfunktioner: Per event, trend, beroendeanalys' },
{ slug: 'biljettsystem-import', name: 'Biljettsystem-import', cat: 'import', desc: 'Importera från Ticketmaster, Eventbrite', longDesc: 'Importera försäljningsdata från biljettsystem. Nyckelfunktioner: Ticketmaster, Eventbrite, NORTIC; auto-kontosättning inkl avgifter' },
{ slug: 'pos-rapport-import-event', name: 'POS-rapport-import', cat: 'import', desc: 'Importera F&B- och merch-försäljning', longDesc: 'Importera F&B- och merch-försäljning. Nyckelfunktioner: Kassarapport från eventbar/shop, momssplit' },
{ slug: 'evenemangsplanering', name: 'Evenemangsplanering', cat: 'operativ', desc: 'Planera och koordinera event', longDesc: 'Planera och koordinera event. Nyckelfunktioner: Checklista, tidslinje, budget' },
{ slug: 'biljettforsaljning', name: 'Biljettförsäljning', cat: 'operativ', desc: 'Sälj och hantera biljetter', longDesc: 'Sälj och hantera biljetter. Nyckelfunktioner: Biljettyper, priskategorier, QR-validering' },
{ slug: 'artist-talangbokning', name: 'Artist- & Talangbokning', cat: 'operativ', desc: 'Boka artister och föreläsare', longDesc: 'Boka artister och föreläsare. Nyckelfunktioner: Kontrakt, riderhantering, schema' },
{ slug: 'sponsorhantering', name: 'Sponsorhantering', cat: 'operativ', desc: 'Hantera sponsoravtal', longDesc: 'Hantera sponsoravtal. Nyckelfunktioner: Avtalsnivåer, motprestationer, uppföljning' },
{ slug: 'volontarhantering', name: 'Volontärhantering', cat: 'operativ', desc: 'Koordinera frivilliga', longDesc: 'Koordinera frivilliga. Nyckelfunktioner: Registrering, skifttilldelning, kommunikation' },
],
},
{
slug: 'fastighet', name: 'Fastighetsförvaltning', icon: Building,
description: 'Objekt, hyresgäster & underhåll',
modules: [
{ slug: 'fastighetskontoplan', name: 'Fastighetskontoplan', cat: 'bokforing', desc: 'BAS-konton per fastighet och kostnadstyp', longDesc: 'BAS-konton per fastighet och kostnadstyp. Nyckelfunktioner: Konton 1110 (byggnader), 3010 (hyresintäkt), 5010 (fastighetsskötsel), 7720 (fastighetsskatt)' },
{ slug: 'hyresintakt-periodisering', name: 'Hyresintäkt-periodisering', cat: 'bokforing', desc: 'Periodisera hyresintäkter per månad', longDesc: 'Periodisera hyresintäkter korrekt per månad. Nyckelfunktioner: Förskottshyra (konto 2970), hyresrabatt, tomgångskostnad' },
{ slug: 'fastighetsskatt', name: 'Fastighetsskatt', cat: 'bokforing', desc: 'Beräkna fastighetsskatt/avgift', longDesc: 'Beräkna och bokföra fastighetsskatt/avgift. Nyckelfunktioner: Taxeringsvärde, skattesats per fastighetstyp, konto 7720, periodisering' },
{ slug: 'fastighetsavskrivning', name: 'Fastighetsavskrivning', cat: 'bokforing', desc: 'Avskrivning per byggnad (K2/K3)', longDesc: 'Avskrivningsplan per byggnad (lång löptid). Nyckelfunktioner: Komponentavskrivning (K3) eller linjär (K2), konto 1119/7820' },
{ slug: 'underhallsfond', name: 'Underhållsfond', cat: 'bokforing', desc: 'Avsätt medel för planerat underhåll', longDesc: 'Avsätt medel för planerat underhåll. Nyckelfunktioner: Avsättning per år, upplösning vid genomfört underhåll, 10-årsplan kopplad' },
{ slug: 'rot-vid-renovering', name: 'ROT vid renovering', cat: 'bokforing', desc: 'ROT-avdrag vid renovering', longDesc: 'Hantera ROT-avdrag för hyresgäster vid renovering. Nyckelfunktioner: Beräkning, personnummer, Skatteverket-rapportering' },
{ slug: 'indexupprakning', name: 'Indexuppräkning', cat: 'bokforing', desc: 'Automatisk hyreshöjning vid index', longDesc: 'Automatisk beräkning av hyreshöjning vid index. Nyckelfunktioner: KPI-koppling, beräkning per kontrakt, underlag för avisering' },
{ slug: 'driftnetto-per-fastighet', name: 'Driftnetto per fastighet', cat: 'rapport', desc: 'NOI per fastighet', longDesc: 'Hyresintäkt minus driftskostnad. Nyckelfunktioner: Per fastighet/objekt, jämförelse, trend' },
{ slug: 'vakansgrad', name: 'Vakansgrad', cat: 'rapport', desc: 'Andel outhyrda ytor', longDesc: 'Andel outhyrda ytor. Nyckelfunktioner: Per fastighet, trendanalys, intäktsbortfall' },
{ slug: 'underhallskostnad-per-m2', name: 'Underhållskostnad per m²', cat: 'rapport', desc: 'Underhåll per kvadratmeter', longDesc: 'Total underhållskostnad per kvadratmeter. Nyckelfunktioner: Plan vs utfall, per fastighet, kategori' },
{ slug: 'hyresreskontra-import', name: 'Hyresreskontra-import', cat: 'import', desc: 'Importera hyresreskontra', longDesc: 'Importera hyresreskontra. Nyckelfunktioner: Avier, betalningar, restbelopp; auto-kontosättning' },
{ slug: 'energirapport-import', name: 'Energirapport-import', cat: 'import', desc: 'Importera energiförbrukning', longDesc: 'Importera energiförbrukning. Nyckelfunktioner: El/vatten/fjärrvärme, per fastighet, kostnadsfördelning' },
{ slug: 'objektregister', name: 'Objektregister', cat: 'operativ', desc: 'Fastighets- och lokalöversikt', longDesc: 'Fastighets- och lokalöversikt. Nyckelfunktioner: Adresser, ytor, byggnadsår, dokument' },
{ slug: 'hyresgasthantering', name: 'Hyresgästhantering', cat: 'operativ', desc: 'Administrera hyresavtal', longDesc: 'Administrera hyresavtal. Nyckelfunktioner: Avtalstider, kontakt, uppsägningsflöde' },
{ slug: 'hyresavier', name: 'Hyresavier', cat: 'operativ', desc: 'Generera och skicka avier', longDesc: 'Generera och skicka avier. Nyckelfunktioner: Automatisk avisering, tillägg, autogiro' },
{ slug: 'felanmalan', name: 'Felanmälan', cat: 'operativ', desc: 'Hantera felanmälningar', longDesc: 'Hantera felanmälningar. Nyckelfunktioner: Webformulär, prioritet, hantverkarstyrning' },
{ slug: 'underhallsplanering', name: 'Underhållsplanering', cat: 'operativ', desc: 'Långsiktig underhållsplanering', longDesc: 'Långsiktig och akut. Nyckelfunktioner: 10-årsplan, kostnadsprognos, status' },
{ slug: 'besiktning-rondering', name: 'Besiktning & Rondering', cat: 'operativ', desc: 'Dokumentera besiktningar', longDesc: 'Dokumentera besiktningar. Nyckelfunktioner: Protokoll, foto, avvikelserapport' },
{ slug: 'energiovervakning', name: 'Energiövervakning', cat: 'operativ', desc: 'Följ energiförbrukning', longDesc: 'Följ energiförbrukning. Nyckelfunktioner: El/vatten/värme, jämförelse, energideklaration' },
],
},
]
export function getSectorBySlug(slug: string): Sector | undefined {
return sectors.find(s => s.slug === slug)
}
export function getModuleBySlug(sectorSlug: string, moduleSlug: string): { sector: Sector; module: ModuleItem } | undefined {
const sector = sectors.find(s => s.slug === sectorSlug)
if (!sector) return undefined
const module = sector.modules.find(m => m.slug === moduleSlug)
if (!module) return undefined
return { sector, module }
}
-377
View File
@@ -1,377 +0,0 @@
/**
* Notification scheduling and quiet hours handling
*/
import { SupabaseClient } from '@supabase/supabase-js'
import {
sendPushToMultiple,
createTaxDeadlinePayload,
createInvoicePayload,
type PushSubscriptionKeys,
} from './web-push'
import type { NotificationSettings, NotificationType } from '@/types'
/**
* Check if current time is within quiet hours (Sweden timezone)
*/
export function isWithinQuietHours(
settings: NotificationSettings,
now: Date = new Date()
): boolean {
// Convert to Sweden timezone
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now)
const [hours, minutes] = swedenTime.split(':').map(Number)
const currentMinutes = hours * 60 + minutes
// Parse quiet hours
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
const quietStartMinutes = startHours * 60 + startMinutes
const quietEndMinutes = endHours * 60 + endMinutes
// Handle overnight quiet hours (e.g., 21:00 to 08:00)
if (quietStartMinutes > quietEndMinutes) {
// Quiet if after start OR before end
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
}
// Normal range (e.g., 01:00 to 06:00)
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
}
/**
* Check if a notification has already been sent (prevent duplicates)
*/
async function wasNotificationSent(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<boolean> {
const { data } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', userId)
.eq('notification_type', notificationType)
.eq('reference_id', referenceId)
.eq('days_before', daysBefore)
.single()
return !!data
}
/**
* Log a sent notification
*/
async function logNotification(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<void> {
await supabase.from('notification_log').insert({
user_id: userId,
notification_type: notificationType,
reference_id: referenceId,
days_before: daysBefore,
delivery_status: 'sent',
})
}
/**
* Get user's push subscriptions
*/
async function getUserSubscriptions(
supabase: SupabaseClient,
userId: string
): Promise<PushSubscriptionKeys[]> {
const { data } = await supabase
.from('push_subscriptions')
.select('endpoint, p256dh, auth')
.eq('user_id', userId)
.eq('is_active', true)
return data || []
}
/**
* Get user's notification settings
*/
async function getUserNotificationSettings(
supabase: SupabaseClient,
userId: string
): Promise<NotificationSettings | null> {
const { data } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', userId)
.single()
return data
}
/**
* Disable gone (unsubscribed) push subscriptions
*/
async function disableGoneSubscriptions(
supabase: SupabaseClient,
endpoints: string[]
): Promise<void> {
if (endpoints.length === 0) return
await supabase
.from('push_subscriptions')
.update({ is_active: false })
.in('endpoint', endpoints)
}
/**
* Send tax deadline notifications
*/
export async function sendTaxDeadlineNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
// Calculate dates for 7 days and 1 day before
const in7Days = new Date(today)
in7Days.setDate(in7Days.getDate() + 7)
const in7DaysStr = in7Days.toISOString().split('T')[0]
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
// Fetch tax deadlines due in 7 days or 1 day
const { data: deadlines } = await supabase
.from('deadlines')
.select('id, user_id, title, due_date')
.eq('deadline_type', 'tax')
.eq('is_completed', false)
.in('status', ['upcoming', 'action_needed'])
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
if (!deadlines || deadlines.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userDeadlines = new Map<string, typeof deadlines>()
for (const deadline of deadlines) {
const list = userDeadlines.get(deadline.user_id) || []
list.push(deadline)
userDeadlines.set(deadline.user_id, list)
}
for (const [userId, userDls] of userDeadlines) {
// Get user settings
const settings = await getUserNotificationSettings(supabase, userId)
// Skip if tax deadlines disabled
if (settings && !settings.tax_deadlines_enabled) {
skipped += userDls.length
continue
}
// Skip if push disabled
if (settings && !settings.push_enabled) {
skipped += userDls.length
continue
}
// Skip if within quiet hours
if (settings && isWithinQuietHours(settings)) {
skipped += userDls.length
continue
}
// Get subscriptions
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
skipped += userDls.length
continue
}
for (const deadline of userDls) {
const daysUntil = Math.ceil(
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
// Check if already sent for this day count
const alreadySent = await wasNotificationSent(
supabase,
userId,
'tax_deadline',
deadline.id,
daysUntil
)
if (alreadySent) {
skipped++
continue
}
// Create and send notification
const payload = createTaxDeadlinePayload(
deadline.title,
deadline.due_date,
daysUntil,
deadline.id
)
const result = await sendPushToMultiple(subscriptions, payload)
// Log the notification
await logNotification(supabase, userId, 'tax_deadline', deadline.id, daysUntil)
// Disable gone subscriptions
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
if (result.successful > 0) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
/**
* Send invoice reminder notifications
*/
export async function sendInvoiceNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
// Calculate dates
const in3Days = new Date(today)
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().split('T')[0]
const daysAgo3 = new Date(today)
daysAgo3.setDate(daysAgo3.getDate() - 3)
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
const daysAgo7 = new Date(today)
daysAgo7.setDate(daysAgo7.getDate() - 7)
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
// Fetch invoices: due soon (3d, 0d) or overdue (+3d, +7d)
const { data: invoices } = await supabase
.from('invoices')
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
.in('status', ['sent', 'overdue'])
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
if (!invoices || invoices.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userInvoices = new Map<string, typeof invoices>()
for (const invoice of invoices) {
const list = userInvoices.get(invoice.user_id) || []
list.push(invoice)
userInvoices.set(invoice.user_id, list)
}
for (const [userId, userInvs] of userInvoices) {
const settings = await getUserNotificationSettings(supabase, userId)
if (settings && !settings.invoice_reminders_enabled) {
skipped += userInvs.length
continue
}
if (settings && !settings.push_enabled) {
skipped += userInvs.length
continue
}
if (settings && isWithinQuietHours(settings)) {
skipped += userInvs.length
continue
}
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
skipped += userInvs.length
continue
}
for (const invoice of userInvs) {
const dueDate = new Date(invoice.due_date)
const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const isOverdue = daysUntil < 0
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
const alreadySent = await wasNotificationSent(
supabase,
userId,
notificationType,
invoice.id,
Math.abs(daysUntil)
)
if (alreadySent) {
skipped++
continue
}
// customer is a single object from the join, but TypeScript sees it as an array from select()
const customer = invoice.customer as unknown as { name: string } | null
const customerName = customer?.name || 'Okänd kund'
const payload = createInvoicePayload(
invoice.invoice_number,
customerName,
invoice.total,
invoice.due_date,
isOverdue,
invoice.id
)
const result = await sendPushToMultiple(subscriptions, payload)
await logNotification(supabase, userId, notificationType, invoice.id, Math.abs(daysUntil))
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
if (result.successful > 0) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
-203
View File
@@ -1,203 +0,0 @@
/**
* Web Push configuration and sending utilities
*/
import webpush from 'web-push'
// Configure web-push with VAPID keys
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
if (vapidPublicKey && vapidPrivateKey) {
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
}
/**
* Push subscription keys from the browser
*/
export interface PushSubscriptionKeys {
endpoint: string
p256dh: string
auth: string
}
/**
* Notification payload
*/
export interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
tag?: string
data?: {
url?: string
type?: string
id?: string
}
actions?: Array<{
action: string
title: string
icon?: string
}>
}
/**
* Send a push notification to a subscription
*/
export async function sendPushNotification(
subscription: PushSubscriptionKeys,
payload: NotificationPayload
): Promise<{ success: boolean; error?: string }> {
if (!vapidPublicKey || !vapidPrivateKey) {
console.error('VAPID keys not configured')
return { success: false, error: 'VAPID keys not configured' }
}
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}
try {
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload),
{
TTL: 86400, // 24 hours
urgency: 'normal',
}
)
return { success: true }
} catch (error: unknown) {
const err = error as { statusCode?: number; message?: string }
// Handle subscription gone (user unsubscribed)
if (err.statusCode === 410) {
return { success: false, error: 'subscription_gone' }
}
console.error('Push notification error:', err)
return { success: false, error: err.message || 'Unknown error' }
}
}
/**
* Send push notifications to multiple subscriptions
*/
export async function sendPushToMultiple(
subscriptions: PushSubscriptionKeys[],
payload: NotificationPayload
): Promise<{
successful: number
failed: number
goneSubscriptions: string[]
}> {
let successful = 0
let failed = 0
const goneSubscriptions: string[] = []
const results = await Promise.allSettled(
subscriptions.map((sub) => sendPushNotification(sub, payload))
)
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
successful++
} else {
failed++
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
goneSubscriptions.push(subscriptions[index].endpoint)
}
}
})
return { successful, failed, goneSubscriptions }
}
/**
* Get the public VAPID key for client-side subscription
*/
export function getVapidPublicKey(): string | null {
return vapidPublicKey || null
}
/**
* Create notification payload for tax deadline
*/
export function createTaxDeadlinePayload(
title: string,
dueDate: string,
daysUntil: number,
deadlineId: string
): NotificationPayload {
const urgencyText = daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
return {
title: `📋 ${title}`,
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `tax-deadline-${deadlineId}`,
data: {
url: '/calendar',
type: 'tax_deadline',
id: deadlineId,
},
actions: [
{ action: 'view', title: 'Visa' },
{ action: 'dismiss', title: 'Avfärda' },
],
}
}
/**
* Create notification payload for invoice reminder
*/
export function createInvoicePayload(
invoiceNumber: string,
customerName: string,
amount: number,
dueDate: string,
isOverdue: boolean,
invoiceId: string
): NotificationPayload {
const title = isOverdue
? `⚠️ Obetald faktura #${invoiceNumber}`
: `💰 Faktura #${invoiceNumber} förfaller`
const body = isOverdue
? `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`
: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`
return {
title,
body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,
data: {
url: `/invoices/${invoiceId}`,
type: isOverdue ? 'invoice_overdue' : 'invoice_due',
id: invoiceId,
},
actions: [
{ action: 'view', title: 'Visa faktura' },
],
}
}
/**
* Format date for notifications
*/
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
+6 -12
View File
@@ -1,4 +1,4 @@
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus, SchablonavdragSummary } from '@/types'
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus } from '@/types'
// Current Swedish tax rates (2026)
const TAX_RATES = {
@@ -71,20 +71,16 @@ export function calculateGrundavdrag(taxableIncome: number): number {
* Calculate tax estimates for Enskild Firma
* @param netIncome - Net income after expenses
* @param preliminaryTaxPaidYTD - Preliminary tax paid year to date
* @param schablonavdrag - Optional schablonavdrag summary for deductions
* @param _reserved - Reserved for future use (deductions extension)
* @param momsFromUnpaidInvoices - VAT from unpaid invoices that needs to be paid
*/
export function calculateEFTax(
netIncome: number,
preliminaryTaxPaidYTD: number = 0,
schablonavdrag?: SchablonavdragSummary | null,
_reserved?: unknown,
momsFromUnpaidInvoices: number = 0
): TaxEstimate {
// Apply schablonavdrag deductions to net income
const schablonavdragDeduction = schablonavdrag?.total_deduction || 0
const adjustedNetIncome = netIncome - schablonavdragDeduction
if (adjustedNetIncome <= 0) {
if (netIncome <= 0) {
return {
egenavgifter: 0,
income_tax: 0,
@@ -93,17 +89,16 @@ export function calculateEFTax(
total_tax_liability: momsFromUnpaidInvoices,
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: momsFromUnpaidInvoices - preliminaryTaxPaidYTD,
schablonavdrag_deduction: schablonavdragDeduction,
}
}
// Egenavgifter (self-employment contributions) - 28.97%
const egenavgifter = adjustedNetIncome * TAX_RATES.egenavgifter
const egenavgifter = netIncome * TAX_RATES.egenavgifter
// Taxable income (after egenavgifter deduction)
// 25% of egenavgifter is deductible from taxable income
const egenavgifterDeduction = egenavgifter * 0.25
const taxableIncome = adjustedNetIncome - egenavgifterDeduction
const taxableIncome = netIncome - egenavgifterDeduction
// Calculate progressive grundavdrag based on income level
const grundavdrag = calculateGrundavdrag(taxableIncome)
@@ -128,7 +123,6 @@ export function calculateEFTax(
total_tax_liability: Math.round(totalTax),
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: Math.round(totalTax - preliminaryTaxPaidYTD),
schablonavdrag_deduction: schablonavdragDeduction,
grundavdrag: Math.round(grundavdrag),
}
}
-199
View File
@@ -1,199 +0,0 @@
/**
* Schablonavdrag (Standard Deductions) Calculator
* Swedish tax deductions that don't require detailed receipts
*/
import type { SchablonavdragSettings, SchablonavdragSummary, MileageEntry } from '@/types'
// Housing types for hemmakontor deduction
export type HousingType = 'villa' | 'apartment' // villa = privatbostadsfastighet, apartment = hyresrätt/bostadsrätt
// Current Swedish schablonavdrag rates (2026)
export const SCHABLONAVDRAG_RATES = {
hemmakontor: {
// Different rates based on housing type
villa: 2000, // 2,000 kr/year for villa/privatbostadsfastighet
apartment: 4000, // 4,000 kr/year for hyresrätt/bostadsrätt
min_hours_per_year: 800, // Minimum 800 hours working from home required
description: 'Schablonavdrag för hemmakontor',
},
bil: {
rate_per_mil: 25, // 25 kr per Swedish mile (10 km)
rate_per_km: 2.5, // 2.50 kr per kilometer
description: 'Schablonavdrag för bilkostnader (milersättning)',
},
}
/**
* Calculate hemmakontor (home office) deduction
* Rate depends on housing type:
* - Villa/privatbostadsfastighet: 2,000 kr/year
* - Hyresrätt/Bostadsrätt: 4,000 kr/year
* Prorated by months active
*/
export function calculateHemmakontorDeduction(
monthsActive: number = 12,
housingType: HousingType = 'apartment'
): number {
const validMonths = Math.min(Math.max(monthsActive, 0), 12)
const annualAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
return Math.round((annualAmount / 12) * validMonths)
}
/**
* Calculate mileage deduction from total kilometers
* Rate: 25 kr/mil = 2.50 kr/km
*/
export function calculateMileageDeduction(totalKm: number): number {
if (totalKm <= 0) return 0
// 2.50 kr per kilometer (25 kr per Swedish mile)
return Math.round(totalKm * SCHABLONAVDRAG_RATES.bil.rate_per_km * 100) / 100
}
/**
* Calculate mileage deduction from entries
*/
export function calculateMileageDeductionFromEntries(entries: MileageEntry[]): number {
const totalKm = entries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
return calculateMileageDeduction(totalKm)
}
/**
* Get complete schablonavdrag summary for a user and year
*/
export function getSchablonavdragSummary(
settings: SchablonavdragSettings | null,
mileageEntries: MileageEntry[],
year: number = new Date().getFullYear(),
monthsInBusiness: number = 12
): SchablonavdragSummary {
// Default settings if none provided
const schablonavdragSettings: SchablonavdragSettings = settings || {
hemmakontor_enabled: false,
hemmakontor_housing_type: 'apartment',
bil_enabled: false,
}
// Get housing type (default to apartment for higher deduction)
const housingType: HousingType = schablonavdragSettings.hemmakontor_housing_type || 'apartment'
// Calculate hemmakontor deduction based on housing type
const hemmakontorDeduction = schablonavdragSettings.hemmakontor_enabled
? calculateHemmakontorDeduction(monthsInBusiness, housingType)
: 0
// Get the annual amount for this housing type
const hemmakontorAnnualAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
// Filter entries for the specified year
const yearEntries = mileageEntries.filter((entry) => {
const entryYear = new Date(entry.date).getFullYear()
return entryYear === year
})
// Calculate mileage totals
const totalKm = yearEntries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
const mileageDeduction = schablonavdragSettings.bil_enabled
? calculateMileageDeduction(totalKm)
: 0
return {
year,
hemmakontor: {
enabled: schablonavdragSettings.hemmakontor_enabled,
housing_type: housingType,
months_active: monthsInBusiness,
annual_amount: hemmakontorAnnualAmount,
deduction: hemmakontorDeduction,
},
mileage: {
enabled: schablonavdragSettings.bil_enabled,
total_km: totalKm,
rate_per_km: SCHABLONAVDRAG_RATES.bil.rate_per_km,
total_deduction: mileageDeduction,
entries_count: yearEntries.length,
},
total_deduction: hemmakontorDeduction + mileageDeduction,
}
}
/**
* Validate a mileage entry
*/
export function validateMileageEntry(entry: {
date: string
distance_km: number
purpose: string
}): { valid: boolean; errors: string[] } {
const errors: string[] = []
// Check date
if (!entry.date) {
errors.push('Datum krävs')
} else {
const entryDate = new Date(entry.date)
if (isNaN(entryDate.getTime())) {
errors.push('Ogiltigt datum')
} else if (entryDate > new Date()) {
errors.push('Datum kan inte vara i framtiden')
}
}
// Check distance
if (!entry.distance_km || entry.distance_km <= 0) {
errors.push('Avståndet måste vara större än 0 km')
} else if (entry.distance_km > 10000) {
errors.push('Orimligt avstånd (max 10 000 km per resa)')
}
// Check purpose
if (!entry.purpose || entry.purpose.trim().length < 3) {
errors.push('Ändamål krävs (minst 3 tecken)')
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Get mileage entries grouped by month
*/
export function groupMileageEntriesByMonth(
entries: MileageEntry[]
): Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }> {
const grouped = new Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }>()
for (const entry of entries) {
const date = new Date(entry.date)
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
if (!grouped.has(key)) {
grouped.set(key, { entries: [], totalKm: 0, totalDeduction: 0 })
}
const group = grouped.get(key)!
group.entries.push(entry)
group.totalKm += Number(entry.distance_km)
group.totalDeduction += Number(entry.total_deduction)
}
return grouped
}
/**
* Format month key for display (Swedish)
*/
export function formatMonthKey(key: string): string {
const [year, month] = key.split('-')
const monthNames = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'
]
return `${monthNames[parseInt(month) - 1]} ${year}`
}

Some files were not shown because too many files have changed in this diff Show More