Files
accounted/app/(onboarding)/onboarding/page.tsx
T
Jakob WennbergandClaude Opus 4.6 66a4027f1e feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:19:56 +01:00

434 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
const STEP_TITLES = [
'Verksamhetsform',
'Företagsuppgifter',
'Skatteregistrering',
'F-skatt',
'Anslut bank',
]
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
}
export default function OnboardingPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<OnboardingPageContent />
</Suspense>
)
}
function OnboardingPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
useEffect(() => {
async function loadSettings() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
const { data, error } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (data) {
setSettings(data)
const step = data.onboarding_step || 1
// Clamp to max steps (handles existing users mid-onboarding from old 7-step flow)
setCurrentStep(step > totalSteps ? totalSteps : step)
}
setIsLoading(false)
}
loadSettings()
}, [supabase, router, toast])
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
}, [searchParams])
const saveSettings = async (updates: Partial<CompanySettings>, nextStep?: number) => {
setIsSaving(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return false
}
const updatedSettings = {
...settings,
...updates,
onboarding_step: nextStep || currentStep,
}
// Remove read-only and transient fields before updating
const {
id: _id, user_id: _uid, created_at: _ca, updated_at: _ua,
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
...settingsToSave
} = updatedSettings as Record<string, unknown>
const { error } = await supabase
.from('company_settings')
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' })
if (error) {
console.error('Error saving settings:', JSON.stringify(error), 'code:', error.code, 'message:', error.message, 'details:', error.details)
console.error('Payload was:', JSON.stringify({ ...settingsToSave, user_id: user.id }))
toast({
title: 'Fel',
description: error.message || 'Kunde inte spara. Försök igen.',
variant: 'destructive',
})
setIsSaving(false)
return false
}
setSettings(updatedSettings)
setIsSaving(false)
return true
}
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Fix org number bug: clear dependent fields when entity type changes
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
stepData = { ...stepData, org_number: '', company_name: '' }
}
const nextStep = currentStep + 1
const success = await saveSettings(stepData, nextStep)
if (success) {
// After step 1 (entity type selection): seed chart of accounts
if (currentStep === 1 && stepData.entity_type) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
await supabase.rpc('seed_chart_of_accounts', {
p_user_id: user.id,
p_entity_type: stepData.entity_type,
})
}
} catch (err) {
console.error('Failed to seed chart of accounts:', err)
}
}
// After step 3 (tax registration): create initial fiscal period
if (currentStep === 3) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
const firstYearStart = stepData.first_year_start as string | undefined
const firstYearEnd = stepData.first_year_end as string | undefined
let startStr: string
let endStr: string
let periodName: string
if (isFirstYear && firstYearStart && firstYearEnd) {
// First fiscal year: use exact dates provided
startStr = firstYearStart
endStr = firstYearEnd
const startYear = new Date(firstYearStart).getFullYear()
const endYear = new Date(firstYearEnd).getFullYear()
periodName = startYear === endYear
? `Första räkenskapsåret ${startYear}`
: `Första räkenskapsåret ${startYear}/${endYear}`
} else {
// Ongoing: compute 12-month period from fiscal_year_start_month
let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
// For enskild firma: force calendar year
if (settings.entity_type === 'enskild_firma') {
startMonth = 1
}
const currentYear = new Date().getFullYear()
startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
let endYear: number
let endMonth: number
if (startMonth === 1) {
endYear = currentYear
endMonth = 12
} else {
endYear = currentYear + 1
endMonth = startMonth - 1
}
const lastDay = new Date(endYear, endMonth, 0).getDate()
endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
periodName = startMonth === 1
? `Räkenskapsår ${currentYear}`
: `Räkenskapsår ${currentYear}/${currentYear + 1}`
}
// Validate period duration
const validationError = validatePeriodDuration(startStr, endStr)
if (validationError) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(validationError),
variant: 'destructive',
})
setCurrentStep(3)
return
}
// Delete any existing fiscal periods that have no journal entries,
// so re-running onboarding with different dates doesn't create
// overlapping periods (DB exclusion constraint would reject it).
const { data: existingPeriods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', user.id)
if (existingPeriods && existingPeriods.length > 0) {
for (const ep of existingPeriods) {
const { count } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('fiscal_period_id', ep.id)
if (count === 0) {
await supabase
.from('fiscal_periods')
.delete()
.eq('id', ep.id)
}
}
}
await supabase.from('fiscal_periods').upsert({
user_id: user.id,
name: periodName,
period_start: startStr,
period_end: endStr,
}, {
onConflict: 'user_id,period_start,period_end',
})
}
} catch (err) {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.',
variant: 'destructive',
})
}
}
if (nextStep > totalSteps) {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
} else {
setCurrentStep(nextStep)
}
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = async () => {
const nextStep = currentStep + 1
const success = await saveSettings({}, nextStep)
if (success) {
setCurrentStep(nextStep)
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const renderSteps = () => (
<>
{currentStep === 1 && (
<Step1EntityType
initialData={{ entity_type: settings.entity_type as EntityType }}
onNext={(data) => handleNext(data)}
isSaving={isSaving}
/>
)}
{currentStep === 2 && (
<Step2CompanyDetails
key={settings.entity_type}
initialData={{
company_name: settings.company_name ?? undefined,
org_number: settings.org_number ?? undefined,
address_line1: settings.address_line1 ?? undefined,
postal_code: settings.postal_code ?? undefined,
city: settings.city ?? undefined,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? undefined,
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
vat_registered: settings.vat_registered ?? undefined,
vat_number: settings.vat_number ?? undefined,
moms_period: settings.moms_period as MomsPeriod | undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 4 && (
<Step4PreliminaryTax
initialData={{
preliminary_tax_monthly: settings.preliminary_tax_monthly ?? undefined,
}}
onNext={(data) => handleNext(data)}
onBack={handleBack}
onSkip={handleSkip}
isSaving={isSaving}
/>
)}
{currentStep === 5 && (
<Step5ConnectBank
initialData={{
bank_name: settings.bank_name ?? undefined,
clearing_number: settings.clearing_number ?? undefined,
account_number: settings.account_number ?? undefined,
iban: settings.iban ?? undefined,
bic: settings.bic ?? undefined,
}}
onComplete={async (data) => {
if (data) {
await saveSettings(data, totalSteps)
}
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}}
onBack={handleBack}
onSkip={async () => {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}}
isSaving={isSaving}
/>
)}
</>
)
return (
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
{/* Header */}
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b">
<div className="max-w-2xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Steg {currentStep} av {totalSteps}
</span>
<span className="text-sm font-medium">
{stepTitles[currentStep - 1]}
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</div>
{/* Content */}
<div className="max-w-2xl mx-auto px-4 py-8">
{renderSteps()}
</div>
</div>
)
}