New base
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 +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 +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'
|
||||
|
||||
@@ -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 "Hämta NE-bilaga" 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
+4
-4
@@ -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
-1
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -33,7 +33,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#1a1a1a",
|
||||
themeColor: "#304D83",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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 "Hämta NE-bilaga" 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+3
-3
@@ -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()),
|
||||
@@ -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' }],
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+12
-12
@@ -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"
|
||||
+1
-1
@@ -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 {
|
||||
+5
-5
@@ -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>
|
||||
+1
-1
@@ -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()
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 "Förhandsgranska" 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user