Refactor to generic ERP base

Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-13 15:39:13 +01:00
co-authored by Claude Opus 4.6
parent a25d10a528
commit 0aecf5b41c
184 changed files with 1418 additions and 24763 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ interface TourStep {
position?: 'top' | 'bottom' | 'center'
}
const TOUR_COMPLETED_KEY = 'influencer_dashboard_tour_completed'
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
const tourSteps: TourStep[] = [
{
+1 -1
View File
@@ -36,7 +36,7 @@ interface NewUserChecklistProps {
className?: string
}
const CHECKLIST_DISMISSED_KEY = 'influencer_checklist_dismissed'
const CHECKLIST_DISMISSED_KEY = 'erp_checklist_dismissed'
export default function NewUserChecklist({
hasCustomers,
+1 -7
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Loader2, ArrowRight, Building2, User, Users, Check } from 'lucide-react'
import { Loader2, ArrowRight, Building2, User, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EntityType } from '@/types'
@@ -33,12 +33,6 @@ const entityOptions: {
description: 'Du har ett registrerat AB med organisationsnummer',
icon: Building2,
},
{
value: 'light',
label: 'Egenanställd',
description: 'Du arbetar via egenanställningsföretag som Frilans Finans, Cool Company eller Gigapay',
icon: Users,
},
]
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
@@ -78,7 +78,7 @@ export default function Step2CompanyDetails({
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Influencer-verksamhet'}
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
@@ -1,100 +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 { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
interface Step2LightProps {
initialData: { company_name?: string }
onNext: (data: { company_name: string }) => void
onBack: () => void
isSaving: boolean
}
export default function Step2LightPersonalInfo({
initialData,
onNext,
onBack,
isSaving,
}: Step2LightProps) {
const [name, setName] = useState(initialData.company_name || '')
const [error, setError] = useState<string | null>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) {
setError('Namn krävs')
return
}
setError(null)
onNext({ company_name: trimmed })
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Dina uppgifter</h1>
<p className="text-muted-foreground mt-2">
Ange ditt namn som det visas i appen
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Personuppgifter</CardTitle>
<CardDescription>
Ditt namn används i navigeringen och för att identifiera dig.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="company_name">Fullständigt namn *</Label>
<Input
id="company_name"
placeholder="Anna Andersson"
value={name}
onChange={(e) => {
setName(e.target.value)
if (error) setError(null)
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,526 +0,0 @@
'use client'
import { useState, useEffect, useCallback } 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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, ArrowRight, ArrowLeft, Search, Check } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import type { MunicipalityRate, UmbrellaProviderDefault } from '@/types'
interface Step3LightProps {
initialData: {
municipality_code?: string
municipal_tax_rate?: number
church_tax?: boolean
church_tax_rate?: number
church_parish_code?: string
umbrella_provider?: string
umbrella_fee_percent?: number
umbrella_pension_percent?: number
umbrella_fee_custom?: boolean
}
onNext: (data: Record<string, unknown>) => void
onBack: () => void
isSaving: boolean
}
export default function Step3LightTaxProfile({
initialData,
onNext,
onBack,
isSaving,
}: Step3LightProps) {
const supabase = createClient()
// Municipality state
const [municipalitySearch, setMunicipalitySearch] = useState('')
const [municipalityResults, setMunicipalityResults] = useState<MunicipalityRate[]>([])
const [selectedMunicipality, setSelectedMunicipality] = useState<MunicipalityRate | null>(null)
const [showMunicipalityDropdown, setShowMunicipalityDropdown] = useState(false)
const [municipalityLoading, setMunicipalityLoading] = useState(false)
// Church tax state
const [churchTax, setChurchTax] = useState(initialData.church_tax ?? false)
const [parishes, setParishes] = useState<MunicipalityRate[]>([])
const [selectedParish, setSelectedParish] = useState<MunicipalityRate | null>(null)
const [parishLoading, setParishLoading] = useState(false)
// Umbrella provider state
const [umbrellaProviders, setUmbrellaProviders] = useState<UmbrellaProviderDefault[]>([])
const [selectedProvider, setSelectedProvider] = useState(initialData.umbrella_provider || '')
const [feePercent, setFeePercent] = useState<number | null>(initialData.umbrella_fee_percent ?? null)
const [pensionPercent, setPensionPercent] = useState<number | null>(initialData.umbrella_pension_percent ?? null)
const [feeCustom, setFeeCustom] = useState(initialData.umbrella_fee_custom ?? false)
const [showCustomFees, setShowCustomFees] = useState(initialData.umbrella_fee_custom ?? false)
// Form state
const [municipalityCode, setMunicipalityCode] = useState(initialData.municipality_code || '')
const [municipalTaxRate, setMunicipalTaxRate] = useState<number | null>(initialData.municipal_tax_rate ?? null)
const [churchTaxRate, setChurchTaxRate] = useState<number | null>(initialData.church_tax_rate ?? null)
const [churchParishCode, setChurchParishCode] = useState(initialData.church_parish_code || '')
// Debounced municipality search
const searchMunicipalities = useCallback(async (query: string) => {
if (query.length < 2) {
setMunicipalityResults([])
return
}
setMunicipalityLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.ilike('municipality_name', `%${query}%`)
.is('parish_code', null)
.order('municipality_name')
.limit(10)
if (!error && data) {
setMunicipalityResults(data)
}
} catch {
// silently fail
} finally {
setMunicipalityLoading(false)
}
}, [supabase])
// Debounce the municipality search
useEffect(() => {
const timer = setTimeout(() => {
searchMunicipalities(municipalitySearch)
}, 300)
return () => clearTimeout(timer)
}, [municipalitySearch, searchMunicipalities])
// Load initial municipality if we have a code
useEffect(() => {
if (initialData.municipality_code && !selectedMunicipality) {
const loadMunicipality = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', initialData.municipality_code!)
.is('parish_code', null)
.limit(1)
.single()
if (data) {
setSelectedMunicipality(data)
setMunicipalitySearch(data.municipality_name)
}
}
loadMunicipality()
}
}, [initialData.municipality_code, selectedMunicipality, supabase])
// Load initial parish if we have a parish code
useEffect(() => {
if (initialData.church_parish_code && !selectedParish && initialData.municipality_code) {
const loadParish = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('parish_code', initialData.church_parish_code!)
.eq('municipality_code', initialData.municipality_code!)
.limit(1)
.single()
if (data) {
setSelectedParish(data)
}
}
loadParish()
}
}, [initialData.church_parish_code, initialData.municipality_code, selectedParish, supabase])
// Fetch parishes when municipality is selected and church tax is on
useEffect(() => {
if (churchTax && municipalityCode) {
const fetchParishes = async () => {
setParishLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', municipalityCode)
.not('parish_code', 'is', null)
.order('parish_name')
if (!error && data) {
setParishes(data)
}
} catch {
// silently fail
} finally {
setParishLoading(false)
}
}
fetchParishes()
} else {
setParishes([])
}
}, [churchTax, municipalityCode, supabase])
// Fetch umbrella providers on mount
useEffect(() => {
const fetchProviders = async () => {
const { data, error } = await supabase
.from('umbrella_provider_defaults')
.select('*')
.order('display_name')
if (!error && data) {
setUmbrellaProviders(data)
}
}
fetchProviders()
}, [supabase])
// Handle municipality selection
const handleSelectMunicipality = (municipality: MunicipalityRate) => {
setSelectedMunicipality(municipality)
setMunicipalitySearch(municipality.municipality_name)
setMunicipalityCode(municipality.municipality_code)
setMunicipalTaxRate(municipality.total_rate / 100)
setShowMunicipalityDropdown(false)
// Reset parish selection when municipality changes
setSelectedParish(null)
setChurchTaxRate(null)
setChurchParishCode('')
}
// Handle parish selection
const handleSelectParish = (parishCode: string) => {
const parish = parishes.find((p) => p.parish_code === parishCode)
if (parish) {
setSelectedParish(parish)
setChurchTaxRate(parish.church_rate ? parish.church_rate / 100 : null)
setChurchParishCode(parish.parish_code || '')
}
}
// Handle provider selection
const handleSelectProvider = (providerName: string) => {
setSelectedProvider(providerName)
if (providerName === 'Annan') {
setFeePercent(null)
setPensionPercent(null)
setShowCustomFees(true)
setFeeCustom(true)
return
}
const provider = umbrellaProviders.find((p) => p.display_name === providerName)
if (provider) {
setFeePercent(provider.default_fee_percent)
setPensionPercent(provider.pension_percent)
if (!showCustomFees) {
setFeeCustom(false)
}
}
}
// Handle form submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onNext({
municipality_code: municipalityCode,
municipal_tax_rate: municipalTaxRate,
church_tax: churchTax,
church_tax_rate: churchTax ? churchTaxRate : null,
church_parish_code: churchTax ? churchParishCode : null,
umbrella_provider: selectedProvider || null,
umbrella_fee_percent: feePercent,
umbrella_pension_percent: pensionPercent,
umbrella_fee_custom: feeCustom,
})
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Skatteprofil</h1>
<p className="text-muted-foreground mt-2">
Ange din kommun och egenanställningsföretag för korrekt skatteberäkning
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Kommun</CardTitle>
<CardDescription>
Din kommunalskattesats används för att uppskatta skatt på gåvor och hobbyinkomst.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Municipality search */}
<div className="space-y-2">
<Label htmlFor="municipality_search">Sök kommun</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="municipality_search"
placeholder="Skriv kommunnamn..."
value={municipalitySearch}
onChange={(e) => {
setMunicipalitySearch(e.target.value)
setShowMunicipalityDropdown(true)
if (selectedMunicipality) {
setSelectedMunicipality(null)
setMunicipalityCode('')
setMunicipalTaxRate(null)
}
}}
onFocus={() => {
if (municipalityResults.length > 0) {
setShowMunicipalityDropdown(true)
}
}}
className="pl-9"
/>
{municipalityLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
{/* Municipality dropdown */}
{showMunicipalityDropdown && municipalityResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border/60 bg-popover shadow-md max-h-60 overflow-auto">
{municipalityResults.map((municipality) => (
<button
key={municipality.id}
type="button"
className="flex w-full items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors"
onClick={() => handleSelectMunicipality(municipality)}
>
<span>{municipality.municipality_name}</span>
<span className="text-muted-foreground text-xs">
{municipality.total_rate.toFixed(2)}%
</span>
</button>
))}
</div>
)}
</div>
{/* Selected municipality display */}
{selectedMunicipality && (
<div className="flex items-center gap-2 mt-2">
<Check className="h-4 w-4 text-primary" />
<span className="text-sm">
{selectedMunicipality.municipality_name}
</span>
<Badge variant="secondary" className="text-xs">
{selectedMunicipality.total_rate.toFixed(2)}% kommunalskatt
</Badge>
</div>
)}
</div>
{/* Church membership section */}
<div className="pt-4 border-t space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="church_tax">Medlem i Svenska kyrkan?</Label>
<p className="text-xs text-muted-foreground">
Kyrkoskatt tillkommer utöver kommunalskatten
</p>
</div>
<Switch
id="church_tax"
checked={churchTax}
onCheckedChange={(checked) => {
setChurchTax(checked)
if (!checked) {
setSelectedParish(null)
setChurchTaxRate(null)
setChurchParishCode('')
}
}}
/>
</div>
{churchTax && municipalityCode && (
<div className="space-y-2">
<Label>Församling</Label>
{parishLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar församlingar...
</div>
) : parishes.length > 0 ? (
<Select
value={churchParishCode}
onValueChange={handleSelectParish}
>
<SelectTrigger>
<SelectValue placeholder="Välj församling" />
</SelectTrigger>
<SelectContent>
{parishes.map((parish) => (
<SelectItem
key={parish.parish_code}
value={parish.parish_code!}
>
{parish.parish_name} ({parish.church_rate?.toFixed(2)}%)
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
Inga församlingar hittades för vald kommun.
</p>
)}
{selectedParish && selectedParish.church_rate && (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
<span className="text-sm">
Kyrkoskatt: {selectedParish.church_rate.toFixed(2)}%
</span>
</div>
)}
</div>
)}
{churchTax && !municipalityCode && (
<p className="text-sm text-muted-foreground">
Välj kommun ovan för att se församlingar.
</p>
)}
</div>
{/* Umbrella provider section */}
<div className="pt-4 border-t space-y-4">
<div>
<Label>Egenanställningsföretag</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Välj det företag du arbetar via för att beräkna avgifter korrekt.
</p>
</div>
<Select
value={selectedProvider}
onValueChange={handleSelectProvider}
>
<SelectTrigger>
<SelectValue placeholder="Välj egenanställningsföretag" />
</SelectTrigger>
<SelectContent>
{umbrellaProviders.map((provider) => (
<SelectItem
key={provider.id}
value={provider.display_name}
>
{provider.display_name}
</SelectItem>
))}
<SelectItem value="Annan">Annan</SelectItem>
</SelectContent>
</Select>
{selectedProvider && feePercent !== null && !showCustomFees && (
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">
Avgift: {feePercent}%
</Badge>
{pensionPercent !== null && pensionPercent > 0 && (
<Badge variant="secondary">
Pension: {pensionPercent}%
</Badge>
)}
</div>
)}
{selectedProvider && !showCustomFees && selectedProvider !== 'Annan' && (
<button
type="button"
className="text-sm text-primary hover:underline"
onClick={() => {
setShowCustomFees(true)
setFeeCustom(true)
}}
>
Anpassa avgifter
</button>
)}
{showCustomFees && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="fee_percent">Avgift (%)</Label>
<Input
id="fee_percent"
type="number"
step="0.1"
min="0"
max="100"
placeholder="t.ex. 6"
value={feePercent ?? ''}
onChange={(e) => {
setFeePercent(e.target.value ? parseFloat(e.target.value) : null)
setFeeCustom(true)
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pension_percent">Pension (%)</Label>
<Input
id="pension_percent"
type="number"
step="0.1"
min="0"
max="100"
placeholder="t.ex. 4.5"
value={pensionPercent ?? ''}
onChange={(e) => {
setPensionPercent(e.target.value ? parseFloat(e.target.value) : null)
setFeeCustom(true)
}}
/>
</div>
</div>
)}
</div>
{/* Navigation buttons */}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -159,7 +159,7 @@ export default function Step3TaxRegistration({
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 80 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Som influencer med sponsorintäkter är du troligen momsregistrerad.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 80 000 kr per år behöver du momsregistrera dig.</p>
</div>
}
side="right"