feat(onboarding): journey PR D — wizard removal, /companies/new mode='add', searchable BankID picker (#1150)

* feat(onboarding): journey PR D — wizard removal, /companies/new mode='add', searchable BankID picker

Closes the onboarding migration (plan PR D). The journey is now the only
onboarding; the flag conditional is gone.

- /companies/new: server page rendering OnboardingJourney mode='add'
  (quiet escape link back to the app). Improvement over the old page: the
  add-company path now persists the TIC lookup snapshot too.
- Delete WelcomeOnboarding + Step1-4 + the three dead variants
  (Step2SectorSelection, Step3ExtensionSuggestions, Step4PreliminaryTax).
  onboarding-illustrations stays (backdrop uses it).
- BankIdCompanyPicker restyled to the journey's searchable list (founder
  decision: list at ANY count): filter with single-match Enter, roster
  rows with name/form/roll/orgnr, member companies under their own
  section opening directly. Contract unchanged: picks still route to
  /onboarding?org_number= and this page still makes zero TIC calls.
- i18n: prune 108 wizard-only onboarding keys and the whole companies_new
  namespace (no consumers left); add journey_cancel_add + three picker
  keys. sv and en in lockstep.

Verified: full vitest suite 9339 passed, eslint 0 errors, guards pass,
production build compiles with /onboarding, /companies/new and
/select-company routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(onboarding): address PR review — drop unused hasExistingCompanies plumbing, prune stale select_company keys

Restores error_no_access/error_switch_failed (used via ternary inside
t(), which the pruner's regex missed); full pruned-key set re-verified
as bare strings against the whole codebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 16:03:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 53e343ee92
commit 213d611e54
16 changed files with 196 additions and 3053 deletions
+2
View File
@@ -360,3 +360,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-24] Accounted MCP naming is an additive namespace selected with tool_namespace=accounted: internal gnubok_* ids, authorization maps, API-key prefixes, and the gnubok-mcp package remain canonical compatibility surfaces, while new clients advertise accounted_* aliases through accounted-mcp so existing connections never invalidate.
[2026-07-24] PR-review pass on bug/invalid-imports: declined CodeRabbit's ask to fill un-posted prior years in asset-note-figures ibAck via the engine when priorPosted.length>0. The note must tie out to the ledger-driven balansrakning, which reflects posted-only accumulated depreciation; estimating a skipped year would over-state ibAck and BREAK the tie-out this module exists to preserve. A posted-history gap is a real books gap that the build-data tie-out warning correctly surfaces (fix = post the missing year, not paper over it). Engine fallback stays gated to priorPosted.length===0 (pre-onboarding, nothing booked).
[2026-07-24] Kept the leading-`!` Tailwind important syntax (`!p-0`/`!pl-0`/`!pr-0`) in the transaction tables over CodeRabbit's trailing-`!` (`p-0!`) rewrite: verified against the installed tailwindcss 4.1.18 compiler that `!p-0` alone emits `.\!p-0{padding:… !important}`, so both forms work in v4 and the flush-edge columns are not broken. Declined the 18-site churn.
[2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle).
[2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing).
+11 -45
View File
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding'
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
import type { EntityType } from '@/types'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
@@ -54,22 +53,13 @@ export default async function OnboardingPage({
redirect('/login')
}
// Check if user already has companies (adding another vs first-time)
const { data: existingMembership } = await supabase
.from('company_members')
.select('company_id')
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
const hasCompanies = !!existingMembership
// Fetch profile and team
const [{ data: profile }, { data: teamMembership }] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('team_members').select('team_id').eq('user_id', user.id).limit(1).maybeSingle(),
])
let teamId = teamMembership?.team_id
// Ensure user has a team (fallback for edge cases)
@@ -82,59 +72,35 @@ export default async function OnboardingPage({
redirect('/login')
}
const firstName = profile?.full_name?.split(' ')[0] || null
// The BankID picker routes here with ?org_number=… for every pick. Strip
// formatting so whatever Step 2 displays matches what the rest of the flow
// will store.
const { org_number: rawOrgNumber } = await searchParams
const initialOrgNumber = rawOrgNumber ? rawOrgNumber.replace(/[\s-]/g, '') : undefined
// BankID prefill: look up the CompanyRoles row (no Lens call) to pre-fill
// Step 1's entity_type radio and Step 2's company_name. If no role matches,
// the user fills everything manually: same fallback as a non-BankID
// signup. `preverifiedOrgNumber` tells Step 2 to skip the client-side
// /lookup since CompanyRoles already confirms existence.
// BankID prefill: look up the CompanyRoles row (no Lens call) to seed
// entity_type + company_name. If no role matches, everything is manual:
// same fallback as a non-BankID signup. The journey auto-submits the
// deep-linked orgnr, which runs the same single Lens lookup as manual
// entry (plan addendum 2026-07-24); on lookup failure the flow degrades
// to asking the questions with these role fields as prefill.
let initialEntityType: EntityType | undefined
let initialLegalName: string | undefined
let preverifiedOrgNumber: string | undefined
if (initialOrgNumber) {
const match = await findCompanyRoleByOrgNumber(supabase, user.id, initialOrgNumber)
if (match) {
initialEntityType = mapTicEntityType(match.legalEntityType) ?? undefined
initialLegalName = match.legalName
preverifiedOrgNumber = initialOrgNumber
}
}
// Journey rollout flag (onboarding migration PR C): preview first,
// founder click-through, then production. The journey deliberately
// ignores preverifiedOrgNumber: the deep-linked orgnr runs the same
// single Lens lookup as manual entry (plan addendum 2026-07-24), and
// lookup failure degrades to asking the questions instead.
if (process.env.NEXT_PUBLIC_ONBOARDING_JOURNEY === 'true') {
return (
<OnboardingJourney
teamId={teamId}
hasExistingCompanies={hasCompanies}
mode="first"
initialOrgNumber={initialOrgNumber}
initialEntityType={initialEntityType}
initialLegalName={initialLegalName}
/>
)
}
return (
<WelcomeOnboarding
firstName={firstName}
<OnboardingJourney
teamId={teamId}
skipWelcome
hasExistingCompanies={hasCompanies}
mode="first"
initialOrgNumber={initialOrgNumber}
initialEntityType={initialEntityType}
initialLegalName={initialLegalName}
preverifiedOrgNumber={preverifiedOrgNumber}
/>
)
}
+31 -327
View File
@@ -1,339 +1,43 @@
'use client'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop'
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ArrowLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import { getBranding } from '@/lib/branding/service'
export const dynamic = 'force-dynamic'
const branding = getBranding()
/**
* Add-another-company: the same journey as first-run onboarding in
* mode='add' (quiet escape link back to the app, no BankID prefill:
* this route takes no ?org_number, exactly like the old wizard page).
*/
export default async function NewCompanyPage() {
const supabase = await createClient()
import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
type TFn = (key: string, values?: Record<string, string | number>) => string
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle'), label: t('step1_label') },
{ title: t('step2_title'), subtitle: t('step2_subtitle'), label: t('step2_label') },
{ title: t('step3_title'), subtitle: t('step3_subtitle'), label: t('step3_label') },
{ title: t('step4_title'), subtitle: t('step4_subtitle'), label: t('step4_label') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
export default function NewCompanyPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<NewCompanyContent />
</Suspense>
)
}
const LOG = '[new-company]'
function logError(message: string, extra?: Record<string, unknown>) {
console.error(LOG, message, extra ?? '')
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `new-company: ${message}`, extra }),
}).catch(() => {})
}
function NewCompanyContent() {
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('companies_new')
const STEP_INFO = buildStepInfo(t)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const totalSteps = 4
const [teamId, setTeamId] = useState<string | null>(null)
// Verify auth and fetch team_id on mount
useEffect(() => {
async function checkAuth() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
// Fetch user's team_id
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
if (teamMembership?.team_id) {
setTeamId(teamMembership.team_id)
} else {
// Ensure user has a team (fallback)
const { data: newTeamId } = await supabase.rpc('ensure_user_team')
setTeamId(newTeamId)
}
setIsLoading(false)
}
checkAuth()
}, [supabase, router])
const handleNext = async (stepData: Partial<CompanySettings>) => {
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
stepData = { ...stepData, org_number: '', company_name: '' }
setTicLookup(null)
}
const mergedSettings = { ...settings, ...stepData }
// Validate fiscal period at step 3 before advancing
if (currentStep === 3) {
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
}
// Steps 1-3: collect data client-side only, advance step
if (currentStep < totalSteps) {
setSettings(mergedSettings)
setCurrentStep(currentStep + 1)
return
}
// Step 4 (final): create everything via server action.
// Going through a server action ensures that if the Next.js server is
// unreachable, nothing touches Supabase: no ghost companies.
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
if (!teamId) {
logError('handleNext aborted: no teamId')
toast({ title: t('toast_error_title'), description: t('toast_no_team'), variant: 'destructive' })
return
}
setIsSaving(true)
try {
const result = await createCompanyFromOnboarding({
teamId,
settings: mergedSettings as Record<string, unknown>,
fiscalPeriod: {
startDate: periodResult.startStr,
endDate: periodResult.endStr,
name: periodResult.periodName,
},
})
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
toast({
title: t('toast_error_title'),
description: result.error || t('toast_create_failed'),
variant: 'destructive',
})
return
}
toast({
title: t('toast_company_created'),
description: t('toast_switched_to_new'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: t('toast_error_title'), description: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
let teamId = teamMembership?.team_id
if (!teamId) {
const { data: newTeamId } = await supabase.rpc('ensure_user_team')
teamId = newTeamId
}
if (!teamId) {
redirect('/login')
}
const stepInfo = STEP_INFO[currentStep - 1]
return (
<div className="min-h-screen flex flex-col bg-background">
{/* Header */}
<header className="relative bg-[#141414] text-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-display font-bold text-white/[0.02] leading-none select-none">
{String(currentStep).padStart(2, '0')}
</span>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
<div className="flex items-center justify-between mb-5 md:mb-6">
<div className="flex items-center gap-2.5">
<Link
href="/"
className="flex items-center gap-2 text-white/60 hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="font-display text-base tracking-tight" style={{ fontWeight: 700 }}>
{branding.appName.toLowerCase()}
</span>
</div>
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
const num = i + 1
return (
<div
key={i}
className={cn(
'h-[3px] rounded-full transition-all duration-500',
num === currentStep && 'w-7 bg-white',
num < currentStep && 'w-4 bg-white/50',
num > currentStep && 'w-4 bg-white/[0.1]',
)}
/>
)
})}
</div>
<span className="text-[10px] text-white/30 tracking-[0.15em] uppercase">
{currentStep} / {totalSteps}
</span>
</div>
<div key={`title-${currentStep}`} className="animate-fade-in">
<h1 className="font-display text-2xl md:text-3xl tracking-tight leading-[1.1]">
{stepInfo.title}
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
{stepInfo.subtitle}
</p>
</div>
</div>
</header>
{/* Form content */}
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div key={`step-${currentStep}`} className="animate-slide-up">
{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}
ticEnabled={ticEnabled}
onTicLookup={setTicLookup}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? (ticLookup ? ticLookup.registration.fTax : undefined),
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 4 && (
<Step4VatAccounting
initialData={{
vat_registered: settings.vat_registered ?? (ticLookup ? ticLookup.registration.vat : undefined),
vat_number: settings.vat_number ?? undefined,
moms_period: (settings.moms_period as MomsPeriod | null) ?? 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}
/>
)}
</div>
</div>
</main>
<div className="min-h-screen bg-background">
<OnboardingBackdrop />
<OnboardingJourney teamId={teamId} mode="add" />
</div>
)
}
-422
View File
@@ -1,422 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { deriveFirstYearDefaults, parseStartMonthDay } from '@/lib/company/first-year-defaults'
import { useToast } from '@/components/ui/use-toast'
import { Building2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
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 Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
import {
ILLUSTRATIONS,
illustrationSrc,
type IllustrationName,
} from '@/components/onboarding/onboarding-illustrations'
// One halftone instrument per step, ghosted in white ink on the dark card
// header. The tools get more computational as the steps get more fiscal:
// pencil (choose form) -> notebook (company details) -> adding machine
// (tax registration) -> calculator (VAT & method).
const STEP_ART: { name: IllustrationName; className: string }[] = [
{ name: 'pencil', className: 'w-28 -rotate-12 right-5 -bottom-6' },
{ name: 'notebook', className: 'w-28 rotate-3 right-4 -bottom-3' },
{ name: 'key-adding-machine', className: 'w-32 -rotate-3 right-2 -bottom-10' },
{ name: 'calculator', className: 'w-28 rotate-3 right-4 -bottom-8' },
]
type TFn = (key: string, values?: Record<string, string | number>) => string
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle') },
{ title: t('step2_title'), subtitle: t('step2_subtitle') },
{ title: t('step3_title'), subtitle: t('step3_subtitle') },
{ title: t('step4_title'), subtitle: t('step4_subtitle') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
const LOG = '[welcome-onboarding]'
function logError(message: string, extra?: Record<string, unknown>) {
console.error(LOG, message, extra ?? '')
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `welcome-onboarding: ${message}`, extra }),
}).catch(() => {})
}
interface WelcomeOnboardingProps {
firstName?: string | null
teamId: string
skipWelcome?: boolean
hasExistingCompanies?: boolean
/** Pre-fill Step 2 org_number when the picker routed here via ?org_number=. */
initialOrgNumber?: string
/** Mapped from a TIC `legalEntityType`. Pre-selects Step 1's radio. */
initialEntityType?: EntityType
/** Legal name from CompanyRoles. Pre-fills Step 2's company_name field. */
initialLegalName?: string
/** Set when the orgnr came from BankID CompanyRoles. Step 2 treats the
* field as pre-verified: skips the client-side Lens `/lookup` since
* CompanyRoles already confirms the company exists. Cleared the moment
* the user edits the orgnr. */
preverifiedOrgNumber?: string
}
export default function WelcomeOnboarding({
firstName,
teamId,
skipWelcome,
hasExistingCompanies,
initialOrgNumber,
initialEntityType,
initialLegalName,
preverifiedOrgNumber,
}: WelcomeOnboardingProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('onboarding')
const STEP_INFO = buildStepInfo(t)
const [started, setStarted] = useState(skipWelcome ?? false)
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
// Seed settings with what BankID CompanyRoles already told us. address /
// F-skatt / VAT come later: user types address in Step 2 and confirms
// F-skatt/VAT in Step 4. We deliberately don't pre-fetch from Lens to
// preserve the 3000/mo budget for the manual-orgnr path.
const [settings, setSettings] = useState<Partial<CompanySettings>>(() => {
const seed: Partial<CompanySettings> = {}
if (initialOrgNumber) seed.org_number = initialOrgNumber
if (initialEntityType) seed.entity_type = initialEntityType
if (initialLegalName) seed.company_name = initialLegalName
return seed
})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const totalSteps = 4
const hour = new Date().getHours()
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Reset org_number/company_name only on a genuine change (user going back
// and picking a different entity type). First-time selection must not
// wipe a pre-fill (e.g. ?org_number= deep-link from /select-company).
if (
currentStep === 1 &&
stepData.entity_type &&
settings.entity_type &&
stepData.entity_type !== settings.entity_type
) {
stepData = { ...stepData, org_number: '', company_name: '' }
setTicLookup(null)
}
const mergedSettings = { ...settings, ...stepData }
// Validate fiscal period at step 3 before advancing
if (currentStep === 3) {
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
}
// Steps 1-3: collect data client-side only, advance step
if (currentStep < totalSteps) {
setSettings(mergedSettings)
setCurrentStep(currentStep + 1)
return
}
// Step 4 (final): create everything via server action.
// Going through a server action ensures that if the Next.js server is
// unreachable, nothing touches Supabase: no ghost companies.
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const result = await createCompanyFromOnboarding({
teamId,
settings: mergedSettings as Record<string, unknown>,
fiscalPeriod: {
startDate: periodResult.startStr,
endDate: periodResult.endStr,
name: periodResult.periodName,
},
ticLookup,
})
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
let title = t('toast_error_title')
let description: string = result.error || t('toast_create_failed')
let backToStep2 = false
if (result.error === 'org_number_invalid') {
title = t('toast_org_invalid_title')
description = t('toast_org_invalid_description')
backToStep2 = true
}
toast({
title,
description,
variant: 'destructive',
})
// Back user up to step 2 so they can correct the org number.
if (backToStep2) {
setCurrentStep(2)
}
return
}
toast({
title: t('toast_welcome_title'),
description: t('toast_company_ready'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const stepInfo = STEP_INFO[currentStep - 1]
// Welcome screen: show before user clicks "Lägg till ditt första företag"
if (!started) {
return (
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
<p className="text-muted-foreground/50 text-sm mb-2">{greeting}</p>
<h1 className="font-display text-4xl md:text-5xl tracking-tight leading-[1.05] mb-10">
{t('welcome_title', { appName: branding.appName })}
</h1>
<button
onClick={() => setStarted(true)}
className="px-4 py-2 rounded-lg bg-foreground text-background text-sm font-medium hover:bg-foreground/85 transition-colors duration-150"
>
{hasExistingCompanies ? t('add_a_company') : t('add_first_company')}
</button>
</div>
)
}
return (
<div className="stagger-enter">
{/* Greeting header */}
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
{hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')}
</p>
</header>
{/* Onboarding card */}
<div className="max-w-lg">
<div className="rounded-xl border bg-card overflow-hidden" style={{ boxShadow: 'var(--shadow-md)' }}>
{/* Card header with step info */}
<div className="bg-[#141414] text-white px-6 py-5 relative overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
{/* Step instrument, ghosted in white ink. Keyed by step so it
crossfades together with the form below. */}
{(() => {
const art = STEP_ART[currentStep - 1]
const dims = ILLUSTRATIONS[art.name]
return (
<div
key={`art-${currentStep}`}
className={cn('absolute animate-fade-in', art.className)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={illustrationSrc(art.name)}
width={dims.w}
height={dims.h}
alt=""
loading="eager"
decoding="async"
className="block h-auto w-full opacity-30 [filter:grayscale(1)_invert(1)]"
/>
</div>
)
})()}
</div>
<div className="relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-white/60" />
<span className="text-xs text-white/40 tracking-wide uppercase">{t('new_company')}</span>
</div>
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
const num = i + 1
return (
<div
key={i}
className={cn(
'h-[3px] rounded-full transition-all duration-500',
num === currentStep && 'w-6 bg-white',
num < currentStep && 'w-3 bg-white/50',
num > currentStep && 'w-3 bg-white/[0.1]',
)}
/>
)
})}
<span className="text-[10px] text-white/30 ml-1.5">
{currentStep}/{totalSteps}
</span>
</div>
</div>
<h2 className="font-display text-lg tracking-tight leading-tight">
{stepInfo.title}
</h2>
<p className="text-white/40 mt-1 text-sm">
{stepInfo.subtitle}
</p>
</div>
</div>
{/* Form content */}
<div className="px-6 py-6">
<div key={`step-${currentStep}`} className="animate-slide-up">
{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}
ticEnabled={ticEnabled}
onTicLookup={setTicLookup}
preverifiedOrgNumber={preverifiedOrgNumber}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (() => {
// Derive both first-year defaults from TIC's registrationDate
// (null-safe: returns { false, undefined } for established
// companies and for missing registrationDate).
const firstYearDefaults = deriveFirstYearDefaults(
ticLookup?.registrationDate,
)
return (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? (ticLookup ? ticLookup.registration.fTax : undefined),
// Prefer the user's previously-saved value, then fall back
// to TIC's v2 `fiscalYear.startMonthDay` (e.g. "07-01" →
// start month 7). Parsing the MM-DD lets us skip the
// manual end-month picker for the ~95% of companies that
// have a registered fiscal year in Bolagsverket.
fiscal_year_start_month:
settings.fiscal_year_start_month
?? parseStartMonthDay(ticLookup?.fiscalYear?.startMonthDay)
?? undefined,
// Companies registered <12 months ago land in their first
// fiscal year: pre-check the toggle and seed the start
// date so the user only confirms the end date.
is_first_fiscal_year:
settings.is_first_fiscal_year ?? firstYearDefaults.isFirstFiscalYear,
first_year_start:
settings.first_year_start ?? firstYearDefaults.firstYearStart,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)
})()}
{currentStep === 4 && (
<Step4VatAccounting
initialData={{
vat_registered: settings.vat_registered ?? (ticLookup ? ticLookup.registration.vat : undefined),
vat_number: settings.vat_number ?? undefined,
moms_period: (settings.moms_period as MomsPeriod | null) ?? 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}
/>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+133 -184
View File
@@ -1,19 +1,28 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Building2, ArrowRight, Loader2, Plus, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Loader2, AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { switchCompany } from '@/lib/company/actions'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { getBranding } from '@/lib/branding/service'
import '@/components/onboarding/journey/journey.css'
const branding = getBranding()
/**
* BankID company picker, restyled to the journey's searchable list
* (founder decision 2026-07-24: the list is the standard at ANY count).
* The contract is unchanged: picking a CompanyRoles engagement routes to
* /onboarding?org_number=… (one Lens lookup happens there, on pick);
* member companies switch + open directly. The roster itself is free
* CompanyRoles data: this page never calls TIC.
*/
export interface MemberCompany {
id: string
name: string
@@ -36,9 +45,7 @@ interface BankIdCompanyPickerProps {
enrichmentStale: boolean
}
type SetupState =
| { kind: 'idle' }
| { kind: 'opening'; companyId: string }
type SetupState = { kind: 'idle' } | { kind: 'opening'; companyId: string }
// Swedish legal entity names (Aktiebolag, Enskild firma, etc.) are statutory
// terms: kept in Swedish in both locales.
@@ -46,7 +53,6 @@ function humanEntityType(t: string | null | undefined): string {
if (!t) return ''
if (t === 'aktiebolag') return 'Aktiebolag'
if (t === 'enskild_firma') return 'Enskild firma'
// TIC legalEntityType strings ('AB', 'HB', etc.) fall through to this
return t
}
@@ -69,7 +75,6 @@ function positionLabel(role: EnrichmentCompanyRole): string {
export default function BankIdCompanyPicker({
firstName,
teamId,
memberCompanies,
ticCompanies,
enrichmentStale,
@@ -78,12 +83,35 @@ export default function BankIdCompanyPicker({
const { toast } = useToast()
const t = useTranslations('select_company')
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
const [query, setQuery] = useState('')
const hour = new Date().getHours()
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const busy = setup.kind !== 'idle'
const q = query.trim().toLowerCase()
const filteredTic = useMemo(
() =>
ticCompanies.filter(
({ role }) =>
!q ||
role.legalName.toLowerCase().includes(q) ||
role.companyRegistrationNumber.replace(/[\s-]/g, '').includes(q.replace(/[\s-]/g, '')),
),
[ticCompanies, q],
)
const filteredMembers = useMemo(
() =>
memberCompanies.filter(
(c) =>
!q ||
c.name.toLowerCase().includes(q) ||
(c.orgNumber ?? '').replace(/[\s-]/g, '').includes(q.replace(/[\s-]/g, '')),
),
[memberCompanies, q],
)
async function handleOpenMember(companyId: string) {
if (busy) return
setSetup({ kind: 'opening', companyId })
@@ -99,198 +127,119 @@ export default function BankIdCompanyPicker({
window.location.assign('/')
}
// BankID picker no longer one-click-provisions. Every pick routes to the
// onboarding wizard with the orgnr (and entity_type via the server-side
// CompanyRoles match in /onboarding) pre-filled. F-skatt / VAT / address
// are confirmed by the user in Steps 2-4 instead of being auto-fetched
// from TIC: costs us ~1 Lens call per signup but avoids any TIC budget
// spend (companyRoles is on the Identity API, which is a separate quota).
// Every engagement pick routes to the journey with the orgnr; the journey
// runs ONE Lens lookup on arrival and prefills facts (plan addendum
// 2026-07-24). CompanyRoles itself is on the Identity API: separate quota.
function handleCreateFromTic(role: EnrichmentCompanyRole) {
if (busy) return
const orgNumber = role.companyRegistrationNumber.replace(/[\s-]/g, '')
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
}
function onSearchEnter() {
if (filteredTic.length === 1) handleCreateFromTic(filteredTic[0].role)
}
return (
<div className="stagger-enter">
<header className="mb-10">
<header className="mb-8 text-center">
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
{t('subtitle')}
</p>
<p className="text-muted-foreground text-sm mt-1.5">{t('subtitle')}</p>
</header>
<div className="max-w-lg space-y-8">
{enrichmentStale && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/30">
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('enrichment_stale')}
</p>
</div>
</div>
)}
{memberCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
{t('section_your_companies', { appName: branding.appName.toLowerCase() })}
</h2>
<ul className="space-y-2">
{memberCompanies.map((c) => {
const isOpening = setup.kind === 'opening' && setup.companyId === c.id
return (
<li key={c.id}>
<button
type="button"
onClick={() => handleOpenMember(c.id)}
disabled={busy}
className={cn(
'w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'opacity-60 cursor-not-allowed',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{c.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{c.orgNumber ? `${c.orgNumber} · ` : ''}
{humanEntityType(c.entityType)}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{c.role !== 'owner' && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/80">
{c.role}
</span>
)}
{isOpening
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
</div>
</button>
</li>
)
})}
</ul>
</section>
)}
{ticCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
{t('section_bankid_companies')}
</h2>
<ul className="space-y-2">
{ticCompanies.map(({ role, status }) => {
const cleaned = role.companyRegistrationNumber.replace(/[\s-]/g, '')
const position = positionLabel(role)
const entityLabel = humanTicEntityType(role.legalEntityType)
const mappable = mapEntityType(role.legalEntityType) !== null
// Companies already in gnubok under another account are still
// offered for setup: org-number reuse is allowed (a director
// may keep a separate test copy). Keep a muted note so the
// "also already in {app}" context isn't lost.
const existsNote =
status === 'exists' ? (
<p className="text-xs text-muted-foreground/70 mt-2">
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</p>
) : null
if (!mappable) {
return (
<li key={cleaned}>
<Link
href={`/onboarding?org_number=${encodeURIComponent(cleaned)}`}
className={cn(
'block w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'pointer-events-none opacity-60',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
{position ? ` · ${position}` : ''}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
{t('setup_manually')}
</span>
</div>
{existsNote}
</Link>
</li>
)
}
return (
<li key={cleaned}>
<button
type="button"
onClick={() => handleCreateFromTic(role)}
disabled={busy}
className={cn(
'w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'opacity-60 cursor-not-allowed',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
{position ? ` · ${position}` : ''}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
{existsNote}
</button>
</li>
)
})}
</ul>
</section>
)}
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('no_companies_found')}
</p>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden>
<div className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs uppercase tracking-[0.08em] text-muted-foreground">
{t('or_separator')}
</span>
{enrichmentStale && (
<div className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/30">
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">{t('enrichment_stale')}</p>
</div>
</div>
)}
<Link
href="/onboarding"
className={cn(
'flex items-center justify-center gap-2 w-full rounded-lg border border-dashed p-4 text-sm font-medium transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'pointer-events-none opacity-60',
)}
>
<Plus className="h-4 w-4" />
{t('add_company_manually')}
<div className="jny-biginput jny-filter" style={{ margin: '0 auto' }}>
<input
value={query}
placeholder={t('search_placeholder')}
aria-label={t('search_placeholder')}
autoComplete="off"
autoFocus
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearchEnter()}
/>
</div>
<div className="jny-rowlist" style={{ maxHeight: '52vh' }}>
{filteredTic.map(({ role, status }) => {
const cleaned = role.companyRegistrationNumber.replace(/[\s-]/g, '')
const position = positionLabel(role)
const entityLabel = humanTicEntityType(role.legalEntityType)
const mappable = mapEntityType(role.legalEntityType) !== null
const metaParts = [entityLabel, position].filter(Boolean)
if (!mappable) metaParts.push(t('setup_manually'))
if (status === 'exists') {
metaParts.push(t('already_in_app', { appName: branding.appName.toLowerCase() }))
}
return (
<button
key={cleaned}
type="button"
className="jny-rowpick"
disabled={busy}
onClick={() => handleCreateFromTic(role)}
>
<span className="jny-rleft">
<span className="jny-rname">{role.legalName}</span>
<span className="jny-rmeta">{metaParts.join(' · ')}</span>
</span>
<span className="jny-rorg">{role.companyRegistrationNumber}</span>
</button>
)
})}
{filteredMembers.length > 0 && (
<div className="jny-rowsec">
{t('section_your_companies', { appName: branding.appName.toLowerCase() })}
</div>
)}
{filteredMembers.map((c) => {
const isOpening = setup.kind === 'opening' && setup.companyId === c.id
return (
<button
key={c.id}
type="button"
className="jny-rowpick is-existing"
disabled={busy}
onClick={() => handleOpenMember(c.id)}
>
<span className="jny-rleft">
<span className="jny-rname">{c.name}</span>
<span className="jny-rmeta">
{[humanEntityType(c.entityType), c.role !== 'owner' ? c.role : '']
.filter(Boolean)
.join(' · ') || t('opens_directly')}
</span>
</span>
<span className="jny-rorg">
{isOpening ? <Loader2 className="h-4 w-4 animate-spin" /> : c.orgNumber ?? ''}
</span>
</button>
)
})}
{filteredTic.length === 0 && filteredMembers.length === 0 && (
<div className="jny-rownote">
{ticCompanies.length + memberCompanies.length === 0
? t('no_companies_found')
: t('no_search_matches')}
</div>
)}
</div>
<div className="mt-6 text-center">
<Link href="/onboarding" className="jny-btn-quiet" style={{ textDecoration: 'none' }}>
{t('add_company_manually')} &hellip;
</Link>
</div>
</div>
-133
View File
@@ -1,133 +0,0 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Loader2, ArrowRight, Building2, User, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EntityType } from '@/types'
interface Step1Props {
initialData: { entity_type?: EntityType }
onNext: (data: { entity_type: EntityType }) => void
isSaving: boolean
}
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
const t = useTranslations('onboarding')
const [selected, setSelected] = useState<EntityType | undefined>(initialData.entity_type)
// "Enskild firma" and "Aktiebolag" are statutory legal entity types: kept
// in Swedish in both locales.
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: t('step1_ef_description'),
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: t('step1_ab_description'),
icon: Building2,
},
]
const handleNext = () => {
if (!selected) {
const msg = 'step 1: fortsätt clicked without entity type selected'
console.error('[onboarding]', msg)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg }) }).catch(() => {})
return
}
onNext({ entity_type: selected })
}
return (
<div className="space-y-8">
<div className="grid gap-3">
{entityOptions.map((option) => {
const Icon = option.icon
const isSelected = selected === option.value
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={() => !option.disabled && setSelected(option.value as EntityType)}
className="text-left w-full"
>
<Card
className={cn(
'relative p-4 transition-colors',
option.disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-primary/50',
isSelected && 'border-primary ring-2 ring-primary/20'
)}
>
<div className="flex items-start gap-4">
<div className={cn(
'p-2 rounded-lg',
isSelected ? 'bg-primary/10' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-5 w-5',
isSelected ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{option.label}</span>
{option.disabled && (
<Badge variant="secondary" className="text-xs">{t('coming_soon')}</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{option.description}
</p>
</div>
{isSelected && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3.5 w-3.5" />
</div>
)}
</div>
</Card>
</button>
)
})}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3">
<Button
onClick={handleNext}
disabled={!selected || isSaving}
size="lg"
className="w-full sm:w-auto"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
@@ -1,349 +0,0 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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, CheckCircle2, AlertTriangle } from 'lucide-react'
import type { EntityType } from '@/types'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
const schema = z.object({
company_name: z.string().min(1, 'Företagsnamn krävs'),
org_number: z.string()
.min(1, 'Organisationsnummer krävs')
.refine(
(val) => normalizeOrgNumber(val) !== null,
'Ogiltigt organisationsnummer. Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.',
),
address_line1: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface Step2Props {
initialData: Partial<FormData>
entityType?: EntityType
ticEnabled?: boolean
onTicLookup?: (result: CompanyLookupResult | null) => void
onNext: (data: FormData) => void
onBack: () => void
isSaving: boolean
orgNumberLocked?: boolean
// Orgnr we already trust without a Lens call: typically because it came
// from BankID CompanyRoles which confirms the user has a director role at
// this company. When set and the form's orgnr matches, Step 2 skips the
// debounced `/lookup` to avoid burning a Lens call on something we know
// exists. The guard clears as soon as the user edits the field.
preverifiedOrgNumber?: string | null
}
export default function Step2CompanyDetails({
initialData,
entityType,
ticEnabled,
onTicLookup,
onNext,
onBack,
isSaving,
orgNumberLocked,
preverifiedOrgNumber,
}: Step2Props) {
const t = useTranslations('onboarding')
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: {
company_name: initialData.company_name || '',
org_number: initialData.org_number || '',
address_line1: initialData.address_line1 || '',
postal_code: initialData.postal_code || '',
city: initialData.city || '',
},
})
const [isLooking, setIsLooking] = useState(false)
const [lookupError, setLookupError] = useState<string | null>(null)
const [lookupDone, setLookupDone] = useState<CompanyLookupResult | null>(null)
const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null)
const abortRef = useRef<AbortController | null>(null)
const dupAbortRef = useRef<AbortController | null>(null)
// Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so
// the client-side Lens lookup is skipped for that exact value. Cleared
// (set to null) the moment the user edits the org number: a different
// orgnr is no longer covered by the BankID confirmation and needs a real
// lookup.
const prefetchedForOrgRef = useRef<string | null>(
preverifiedOrgNumber ? normalizeOrgNumber(preverifiedOrgNumber) : null,
)
const orgNumber = watch('org_number')
// Soft, account-scoped duplicate warning: if THIS user already has a
// (non-archived) company with the same org number, surface a non-blocking
// note. Org-number reuse is allowed (see lib/company/actions.ts), so this
// never disables submit. The endpoint is RLS-scoped to the caller's own
// companies, so it can't reveal or count another account's.
useEffect(() => {
if (!orgNumber || normalizeOrgNumber(orgNumber) === null) {
setExistingOwn(null)
return
}
const timer = setTimeout(() => {
dupAbortRef.current?.abort()
const controller = new AbortController()
dupAbortRef.current = controller
fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(orgNumber)}`, {
signal: controller.signal,
})
.then(async (res) => {
if (controller.signal.aborted || !res.ok) return
const { data } = await res.json()
setExistingOwn(data?.companies?.[0] ?? null)
})
.catch(() => {
// Advisory only: never blocks creation.
})
}, 500)
return () => {
clearTimeout(timer)
dupAbortRef.current?.abort()
}
}, [orgNumber])
useEffect(() => {
if (!ticEnabled || !orgNumber || normalizeOrgNumber(orgNumber) === null) {
return
}
// Server already fetched this orgnr (BankID deep-link). Don't burn a
// second TIC call to re-confirm what we already have in `initialLookup`.
// Once the user edits the field, normalizeOrgNumber(orgNumber) will
// diverge from the prefetched value and the lookup re-arms.
const normalized = normalizeOrgNumber(orgNumber)
if (prefetchedForOrgRef.current && normalized === prefetchedForOrgRef.current) {
return
}
// Any subsequent edit invalidates the prefetched-match guard for good.
prefetchedForOrgRef.current = null
setLookupError(null)
setLookupDone(null)
const timer = setTimeout(() => {
// Abort any in-flight request
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setIsLooking(true)
fetchCompanyLookup(orgNumber, { ticEnabled: true, signal: controller.signal })
.then((outcome) => {
if (controller.signal.aborted || outcome.status === 'aborted') return
if (outcome.status === 'disabled') {
// Lookup surface unavailable (extension off / dispatcher miss):
// degrade silently to manual entry, the input is not at fault.
return
}
if (outcome.status === 'not_found') {
setLookupError(t('step2_lookup_not_found'))
onTicLookup?.(null)
return
}
if (outcome.status === 'error') {
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
return
}
const data = outcome.result
setLookupDone(data)
onTicLookup?.(data)
// Auto-fill from TIC: overwrite since user just entered a new org number
if (data.companyName) setValue('company_name', data.companyName)
if (data.address?.street) setValue('address_line1', data.address.street)
if (data.address?.postalCode) setValue('postal_code', data.address.postalCode)
if (data.address?.city) setValue('city', data.address.city)
})
.finally(() => {
if (!controller.signal.aborted) setIsLooking(false)
})
}, 500)
return () => {
clearTimeout(timer)
abortRef.current?.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticEnabled, orgNumber])
const isAB = entityType === 'aktiebolag'
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('step2_card_title')}</CardTitle>
<CardDescription>
{ticEnabled
? t('step2_card_desc_tic')
: isAB
? t('step2_card_desc_ab')
: t('step2_card_desc_ef')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onNext, (errs) => {
const fields = Object.keys(errs).join(', ')
console.error('[onboarding] step 2 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 2 validation failed', extra: { fields } }) }).catch(() => {})
})} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="org_number">
{t('step2_org_number_label')}
</Label>
<Input
id="org_number"
placeholder={isAB ? 'XXXXXX-XXXX' : t('step2_org_number_placeholder_ef')}
{...register('org_number')}
readOnly={orgNumberLocked}
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
/>
{errors.org_number && (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
)}
<p className="text-xs text-muted-foreground">
{isAB
? t('step2_org_help_ab')
: t('step2_org_help_ef')}
</p>
{ticEnabled && isLooking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('step2_fetching_details')}
</div>
)}
{ticEnabled && lookupDone && !lookupDone.isCeased && (
<div className="flex items-center gap-2 text-sm text-primary">
<CheckCircle2 className="h-3.5 w-3.5" />
{lookupDone.companyName}
</div>
)}
{ticEnabled && lookupDone?.isCeased && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
{t('step2_ceased_inline', { companyName: lookupDone.companyName })}
</div>
)}
{ticEnabled && lookupError && (
<p className="text-xs text-muted-foreground">{lookupError}</p>
)}
{existingOwn && (
<div className="flex items-start gap-2 rounded-md bg-warning/15 px-3 py-2 text-sm text-warning-foreground">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-warning" />
<span>{t('step2_company_exists_own', { name: existingOwn.name })}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? t('step2_company_name_ab') : t('step2_company_name_ef')}
</Label>
<Input
id="company_name"
placeholder={isAB ? t('step2_company_name_placeholder_ab') : t('step2_company_name_placeholder_ef')}
{...register('company_name')}
/>
{errors.company_name && (
<p className="text-sm text-destructive">{errors.company_name.message}</p>
)}
</div>
<div className="pt-4 border-t">
<h3 className="mb-4">{t('step2_address_heading')}</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">{t('step2_street_address')}</Label>
<Input
id="address_line1"
placeholder={t('step2_street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">{t('step2_postal_code')}</Label>
<Input
id="postal_code"
placeholder="123 45"
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('step2_city')}</Label>
<Input
id="city"
placeholder="Stockholm"
{...register('city')}
/>
</div>
</div>
</div>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-3 pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
className="w-full sm:w-auto"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back')}
</Button>
<Button
type="submit"
disabled={isSaving}
className="w-full sm:w-auto"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,124 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { SECTORS } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { Briefcase, ArrowRight, Loader2 } from 'lucide-react'
interface Step2SectorSelectionProps {
onNext: (data: { sector_slug: string | null }) => void
onBack: () => void
isSaving: boolean
}
export default function Step2SectorSelection({ onNext, onBack, isSaving }: Step2SectorSelectionProps) {
const [selected, setSelected] = useState<string | null>(null)
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
return (
<div className="space-y-6">
<div>
<h2 className="text-lg">Vilken bransch verkar du inom?</h2>
<p className="text-sm text-muted-foreground mt-1">
Vi anpassar verktyg och tillägg baserat på din bransch. Du kan alltid ändra detta senare.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{industrySectors.map(sector => {
const Icon = resolveIcon(sector.icon)
const isSelected = selected === sector.slug
return (
<Card
key={sector.slug}
className={cn(
'cursor-pointer transition-colors',
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
)}
onClick={() => setSelected(sector.slug)}
>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<div className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0',
isSelected ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
)}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-medium">{sector.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{sector.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{sector.extensions.length} branschverktyg
</p>
</div>
</div>
</CardContent>
</Card>
)
})}
{/* "Other" option */}
<Card
className={cn(
'cursor-pointer transition-all',
selected === 'other'
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
)}
onClick={() => setSelected('other')}
>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<div className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0',
selected === 'other' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
)}>
<Briefcase className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-medium">Annan bransch</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generella verktyg utan branschspecifika tillägg
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button variant="ghost" onClick={() => onNext({ sector_slug: null })}>
Hoppa över
</Button>
</div>
<Button
onClick={() => onNext({ sector_slug: selected === 'other' ? null : selected })}
disabled={!selected || isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
@@ -1,144 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { useTranslations } from 'next-intl'
import { ArrowRight, Loader2 } from 'lucide-react'
import type { SectorSlug, ExtensionDefinition, ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_LABEL_KEY: Record<ExtensionCategory, string> = {
accounting: 'category_accounting',
reports: 'category_reports',
import: 'category_import',
operations: 'category_operations',
}
interface Step3Props {
sectorSlug: string | null
onNext: (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => void
onBack: () => void
isSaving: boolean
}
export default function Step3ExtensionSuggestions({ sectorSlug, onNext, onBack, isSaving }: Step3Props) {
const tExt = useTranslations('extensions')
const generalSector = getSector('general')
const selectedSector = sectorSlug ? getSector(sectorSlug as SectorSlug) : null
const [toggles, setToggles] = useState<Record<string, boolean>>({})
const handleToggle = (ext: ExtensionDefinition) => {
const key = `${ext.sector}/${ext.slug}`
setToggles(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSubmit = () => {
const enabled = Object.entries(toggles)
.filter(([, enabled]) => enabled)
.map(([key]) => {
const [sector_slug, extension_slug] = key.split('/')
return { sector_slug, extension_slug }
})
onNext({ enabled_extensions: enabled })
}
const handleSkip = () => {
onNext({ enabled_extensions: [] })
}
// Group: first general, then sector-specific
const sections: { label: string; extensions: ExtensionDefinition[] }[] = []
if (generalSector) {
sections.push({ label: generalSector.name, extensions: generalSector.extensions })
}
if (selectedSector) {
sections.push({ label: selectedSector.name, extensions: selectedSector.extensions })
}
const enabledCount = Object.values(toggles).filter(Boolean).length
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Välj dina tillägg</h2>
<p className="text-sm text-muted-foreground mt-1">
Aktivera de verktyg du vill använda. Du kan alltid ändra detta senare under Tillägg.
</p>
</div>
{sections.map(section => (
<div key={section.label}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{section.label}
</h3>
<div className="space-y-2">
{section.extensions.map(ext => {
const Icon = resolveIcon(ext.icon)
const key = `${ext.sector}/${ext.slug}`
const isEnabled = toggles[key] ?? false
return (
<div
key={key}
className={cn(
'flex items-center justify-between gap-3 rounded-lg border p-3 transition-colors',
isEnabled && 'border-primary/30 bg-primary/5'
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg flex-shrink-0',
isEnabled ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
)}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{ext.name}</p>
<span className="text-[10px] text-muted-foreground">
{tExt(CATEGORY_LABEL_KEY[ext.category])}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-1">{ext.description}</p>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={() => handleToggle(ext)}
/>
</div>
)
})}
</div>
</div>
))}
<div className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button variant="ghost" onClick={handleSkip}>
Hoppa över
</Button>
</div>
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
{enabledCount > 0 ? `Nästa (${enabledCount} valda)` : 'Nästa'}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
@@ -1,573 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import {
FiscalPeriodDateFields,
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
import type { EntityType } from '@/types'
const schema = z.object({
f_skatt: z.boolean(),
is_first_fiscal_year: z.boolean(),
// First year fields (conditional, validated via superRefine below)
first_year_start: z.string().optional(),
first_year_end: z.string().optional(),
// Ongoing year field (conditional)
fiscal_year_end_month: z.number().min(1).max(12).optional(),
}).superRefine((data, ctx) => {
if (data.is_first_fiscal_year) {
if (!data.first_year_start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Välj startdatum för första räkenskapsåret.',
path: ['first_year_start'],
})
}
if (!data.first_year_end) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Välj slutdatum för första räkenskapsåret.',
path: ['first_year_end'],
})
}
}
})
type FormData = z.infer<typeof schema>
// Output type passed to onNext: includes computed fiscal_year_start_month
interface Step3Output {
f_skatt: boolean
fiscal_year_start_month: number
is_first_fiscal_year: boolean
first_year_start?: string
first_year_end?: string
}
interface Step3Props {
initialData: Partial<Step3Output>
entityType?: EntityType
onNext: (data: Step3Output) => void
onBack: () => void
isSaving: boolean
}
const monthNames = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
/**
* Get the last day of a given month (1-indexed).
*/
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Compute valid first-year end dates for enskild firma.
* EF must use calendar year, so end is always Dec 31.
*/
function getEFFirstYearEndDates(startYear: number, startMonth: number): { label: string; value: string }[] {
// EF always ends Dec 31 of either same year or (if start is Jan) same year
// If start is late in the year, only option is Dec 31 same year
// Periods cannot exceed 18 months
const options: { label: string; value: string }[] = []
// Option 1: Dec 31 of same year (if startMonth <= 12)
const months1 = 12 - startMonth + 1
if (months1 >= 1 && months1 <= 18) {
const endDate = `${startYear}-12-31`
options.push({
label: `31 december ${startYear} (${months1} mån)`,
value: endDate,
})
}
// Option 2: Dec 31 of next year (if that gives <= 18 months)
const months2 = months1 + 12
if (months2 >= 1 && months2 <= 18 && startMonth > 6) {
// Only makes sense if start month > June (otherwise > 18 months)
const endDate = `${startYear + 1}-12-31`
options.push({
label: `31 december ${startYear + 1} (${months2} mån)`,
value: endDate,
})
}
return options
}
/**
* Compute valid first-year end dates for aktiebolag given a chosen end month.
* Returns one or two options (ending in the nearest years that give 1-18 months).
*/
function getABFirstYearEndDates(
startYear: number,
startMonth: number,
endMonth: number
): { label: string; value: string }[] {
const options: { label: string; value: string }[] = []
// Try ending in the same year or next year
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
const months = (endYear - startYear) * 12 + (endMonth - startMonth) + 1
if (months >= 6 && months <= 18) {
const day = lastDayOfMonth(endYear, endMonth)
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`
options.push({
label: `${day} ${monthNames[endMonth - 1].toLowerCase()} ${endYear} (${months} mån)`,
value: endDate,
})
}
}
return options
}
export default function Step3TaxRegistration({
initialData,
entityType,
onNext,
onBack,
isSaving,
}: Step3Props) {
const t = useTranslations('onboarding')
const isEF = entityType === 'enskild_firma'
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
const {
handleSubmit,
watch,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: {
f_skatt: initialData.f_skatt ?? true,
is_first_fiscal_year: initialData.is_first_fiscal_year ?? false,
first_year_start: initialData.first_year_start || '',
first_year_end: initialData.first_year_end || '',
fiscal_year_end_month: initialData.fiscal_year_start_month
? (initialData.fiscal_year_start_month === 1 ? 12 : initialData.fiscal_year_start_month - 1)
: 12,
},
})
const isFirstYear = watch('is_first_fiscal_year')
const firstYearStart = watch('first_year_start')
const fiscalYearEndMonth = watch('fiscal_year_end_month')
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState<number>(
initialData.first_year_end
? parseDateParts(initialData.first_year_end).month
: 12
)
// Parse first year start for date computations
const parsedStart = useMemo(() => {
if (!firstYearStart) return null
const parts = parseDateParts(firstYearStart)
if (isNaN(parts.year) || isNaN(parts.month)) return null
return { year: parts.year, month: parts.month }
}, [firstYearStart])
// Compute end date options for first year
const firstYearEndOptions = useMemo(() => {
if (!parsedStart) return []
if (isEF) {
return getEFFirstYearEndDates(parsedStart.year, parsedStart.month)
}
return getABFirstYearEndDates(parsedStart.year, parsedStart.month, abEndMonth)
}, [parsedStart, isEF, abEndMonth])
const onSubmit = async (data: FormData) => {
let fiscalYearStartMonth: number
let firstStart: string | undefined
let firstEnd: string | undefined
if (data.is_first_fiscal_year && data.first_year_start && data.first_year_end) {
// Validate the 6-18 month BFL 3 kap. window + EF calendar-year rule
const validation = validateFirstPeriod(
data.first_year_start,
data.first_year_end,
entityType,
)
if (validation.error) {
toast({
title: t('step3_invalid_period'),
description: validation.error,
variant: 'destructive',
})
return
}
// Derive start month from end date
const endMonth = parseDateParts(data.first_year_end).month
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
firstStart = data.first_year_start
firstEnd = data.first_year_end
} else if (isEF) {
// EF must always be calendar year
fiscalYearStartMonth = 1
} else {
// AB ongoing: derive from end month
const endMonth = data.fiscal_year_end_month || 12
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
}
// Warn on non-calendar fiscal year for AB (EF is always calendar year)
if (!isEF && fiscalYearStartMonth !== 1) {
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const endLabel = firstEnd
? `${parseDateParts(firstEnd).day} ${monthNames[parseDateParts(firstEnd).month - 1].toLowerCase()} ${parseDateParts(firstEnd).year}`
: monthNames[endMonth - 1].toLowerCase()
const ok = await confirm({
title: t('step3_broken_year_title'),
description: t('step3_broken_year_description', { endLabel }),
confirmLabel: t('step3_broken_year_confirm'),
cancelLabel: t('step3_broken_year_cancel'),
variant: 'warning',
})
if (!ok) return
}
const output: Step3Output = {
f_skatt: data.f_skatt,
fiscal_year_start_month: fiscalYearStartMonth,
is_first_fiscal_year: data.is_first_fiscal_year,
...(firstStart && { first_year_start: firstStart }),
...(firstEnd && { first_year_end: firstEnd }),
}
onNext(output)
}
return (
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
<Card>
<CardHeader>
<CardTitle>{t('step3_card_title')}</CardTitle>
<CardDescription>
{t('step3_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit, (errs) => {
const fields = Object.keys(errs).join(', ')
console.error('[onboarding] step 3 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 3 validation failed', extra: { fields } }) }).catch(() => {})
// Show first validation error to user
const firstError = Object.values(errs)[0]
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* F-skatt */}
<div className="flex items-start space-x-3">
<Controller
name="f_skatt"
control={control}
render={({ field }) => (
<Checkbox
id="f_skatt"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">{t('step3_fskatt_tip_title')}</p>
<p>{t('step3_fskatt_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step3_fskatt_tip_note')}</p>
</div>
}
side="right"
>
<Label htmlFor="f_skatt" className="cursor-pointer">
{t('step3_fskatt_label')}
</Label>
</InfoTooltip>
<p className="text-sm text-muted-foreground">
{t('step3_fskatt_help')}
</p>
</div>
</div>
{/* Fiscal year section */}
<div className="pt-4 border-t space-y-4">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">{t('step3_fy_tip_title')}</p>
<p>{t('step3_fy_tip_body')}</p>
{isEF && (
<p className="text-xs text-muted-foreground">{t('step3_fy_tip_ef_note')}</p>
)}
</div>
}
side="right"
>
<Label className="text-base font-medium">{t('step3_fy_question')}</Label>
</InfoTooltip>
{/* Toggle: First year vs Ongoing */}
<Controller
name="is_first_fiscal_year"
control={control}
render={({ field }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => field.onChange(true)}
className="text-left"
>
<Card className={cn(
'p-3 transition-colors cursor-pointer hover:border-primary/50',
field.value && 'border-primary ring-2 ring-primary/20'
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{t('step3_first_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_first_fy_subtitle')}</p>
</div>
{field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
</Card>
</button>
<button
type="button"
onClick={() => field.onChange(false)}
className="text-left"
>
<Card className={cn(
'p-3 transition-colors cursor-pointer hover:border-primary/50',
!field.value && 'border-primary ring-2 ring-primary/20'
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{t('step3_other_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_other_fy_subtitle')}</p>
</div>
{!field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
</Card>
</button>
</div>
)}
/>
{/* First fiscal year options */}
{isFirstYear && (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<Controller
name="first_year_start"
control={control}
render={({ field: startField }) => (
<Controller
name="first_year_end"
control={control}
render={({ field: endField }) => (
<FiscalPeriodDateFields
startDate={startField.value || ''}
onStartDateChange={(v) => {
startField.onChange(v)
// Reset end when start changes: its valid options depend on start
if (endField.value) endField.onChange('')
}}
startHelpText={t('step3_start_help')}
endDate={endField.value || ''}
entityType={entityType}
endDateSlot={
<>
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>{t('step3_fy_end_month_label')}</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => {
if (v) {
setAbEndMonth(parseInt(v))
if (endField.value) endField.onChange('')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>{t('step3_end_date_label')}</Label>
<Select
value={endField.value || ''}
onValueChange={(v) => { if (v) endField.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder={t('step3_select_end_date')} />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.first_year_end && (
<p className="text-xs text-destructive">{errors.first_year_end.message}</p>
)}
</div>
)}
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
{t('step3_no_valid_end')}
</p>
)}
{errors.first_year_start && (
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
)}
</>
}
/>
)}
/>
)}
/>
</div>
)}
{/* Ongoing fiscal year options */}
{!isFirstYear && (
<div className="space-y-2">
{isEF ? (
<div className="rounded-lg bg-muted/50 p-4">
<p className="text-sm font-medium">{t('step3_calendar_year')}</p>
<p className="text-xs text-muted-foreground mt-1">
{t('step3_ef_calendar_required')}
</p>
</div>
) : (
<div className="space-y-2">
<Label>{t('step3_when_fy_ends')}</Label>
<Controller
name="fiscal_year_end_month"
control={control}
render={({ field }) => (
<Select
value={field.value?.toString() || '12'}
onValueChange={(v) => { if (v) field.onChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-sm text-muted-foreground">
{t('step3_calendar_or_broken')}
</p>
{fiscalYearEndMonth && (
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
{t('step3_your_fy')}
</div>
<p className="text-sm text-muted-foreground">
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
</p>
<p className="text-xs text-muted-foreground">{t('step3_twelve_months')}</p>
</div>
)}
</div>
)}
</div>
)}
</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" />
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,139 +0,0 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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, Calculator, SkipForward } from 'lucide-react'
const schema = z.object({
preliminary_tax_monthly: z.number().min(0).optional().nullable(),
})
type FormData = z.infer<typeof schema>
interface Step4Props {
initialData: Partial<FormData>
onNext: (data: FormData) => void
onBack: () => void
onSkip: () => void
isSaving: boolean
}
export default function Step4PreliminaryTax({
initialData,
onNext,
onBack,
onSkip,
isSaving,
}: Step4Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: {
preliminary_tax_monthly: initialData.preliminary_tax_monthly ?? undefined,
},
})
const onSubmit = (data: FormData) => {
onNext({
preliminary_tax_monthly: data.preliminary_tax_monthly || null,
})
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Månatlig F-skatt
</CardTitle>
<CardDescription>
Detta är frivilligt men hjälper oss visa varningar om du betalar för lite skatt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit, (errs) => {
const fields = Object.keys(errs).join(', ')
console.error('[onboarding] step 4 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
})} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Preliminärskatt per månad (kr)
</Label>
<Input
id="preliminary_tax_monthly"
type="number"
placeholder="0"
{...register('preliminary_tax_monthly', { valueAsNumber: true })}
/>
{errors.preliminary_tax_monthly && (
<p className="text-sm text-destructive">
{errors.preliminary_tax_monthly.message}
</p>
)}
<p className="text-sm text-muted-foreground">
Beloppet hittar du på ditt F-skattsedelsbeslut från Skatteverket.
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="font-medium mb-2">Varför är detta användbart?</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Vi jämför din inbetalda skatt mot beräknad skatt</li>
<li>• Du får varningar om du riskerar restskatt</li>
<li>• Lättare att planera för slutskattebeskedet</li>
</ul>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-3 pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<div className="flex flex-col sm:flex-row gap-2">
<Button
type="button"
variant="outline"
onClick={onSkip}
disabled={isSaving}
className="w-full sm:w-auto"
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,308 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Info } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import type { MomsPeriod, EntityType } from '@/types'
const schema = z.object({
vat_registered: z.boolean(),
vat_number: z.string().optional(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
accounting_method: z.enum(['accrual', 'cash']),
}).superRefine((data, ctx) => {
if (data.vat_registered && !data.moms_period) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Välj momsredovisningsperiod.',
path: ['moms_period'],
})
}
})
type FormData = z.infer<typeof schema>
interface Step4Output {
vat_registered: boolean
vat_number?: string
moms_period?: MomsPeriod
accounting_method: 'accrual' | 'cash'
}
interface Step4Props {
initialData: Partial<Step4Output>
entityType?: EntityType
orgNumber?: string
onNext: (data: Step4Output) => void
onBack: () => void
isSaving: boolean
}
export default function Step4VatAccounting({
initialData,
entityType,
orgNumber,
onNext,
onBack,
isSaving,
}: Step4Props) {
const t = useTranslations('onboarding')
const { toast } = useToast()
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: {},
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: {
vat_registered: initialData.vat_registered ?? false,
vat_number: initialData.vat_number || '',
moms_period: initialData.moms_period,
accounting_method: initialData.accounting_method ?? 'accrual',
},
})
const vatRegistered = watch('vat_registered')
const vatNumber = watch('vat_number')
const accountingMethod = watch('accounting_method')
// Auto-fill VAT number when vat_registered toggles on. Derive via the shared
// helper so a 12-digit personnummer (enskild firma) gets its century dropped:
// building SE${orgNumber}01 verbatim produced SE + 14 digits and failed
// validation on save.
useEffect(() => {
if (vatRegistered && !vatNumber && orgNumber) {
const derived = deriveSwedishVatNumber(orgNumber)
if (derived) {
setValue('vat_number', derived)
}
}
}, [vatRegistered, vatNumber, orgNumber, setValue])
const onSubmit = (data: FormData) => {
const output: Step4Output = {
vat_registered: data.vat_registered,
vat_number: data.vat_registered ? data.vat_number : undefined,
moms_period: data.vat_registered ? data.moms_period : undefined,
accounting_method: data.accounting_method,
}
onNext(output)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('step4_card_title')}</CardTitle>
<CardDescription>
{t('step4_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit, (errs) => {
const fields = Object.keys(errs).join(', ')
console.error('[onboarding] step 4 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
const firstError = Object.values(errs)[0]
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* VAT section */}
<div className="space-y-4">
<h3 className="flex items-center gap-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">{t('step4_vat_tip_title')}</p>
<p>{t('step4_vat_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step4_vat_tip_note')}</p>
</div>
}
side="right"
>
<span>{t('step4_vat_heading')}</span>
</InfoTooltip>
</h3>
<div className="flex items-start space-x-3">
<Controller
name="vat_registered"
control={control}
render={({ field }) => (
<Checkbox
id="vat_registered"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
{t('step4_vat_registered_label')}
</Label>
<p className="text-sm text-muted-foreground">
{t('step4_vat_registered_help')}
</p>
</div>
</div>
{vatRegistered && (
<div className="space-y-4 pl-0 sm:pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">{t('step4_vat_number_label')}</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
{t('step4_vat_number_format')}
</p>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">{t('step4_vat_period_tip_title')}</p>
<p>{t('step4_vat_period_tip_body')}</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>{t('step4_vat_period_bracket_low')}</li>
<li>{t('step4_vat_period_bracket_mid')}</li>
<li>{t('step4_vat_period_bracket_high')}</li>
</ul>
</div>
}
side="right"
>
<Label>{t('step4_vat_period_label')}</Label>
</InfoTooltip>
<Controller
name="moms_period"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder={t('step4_select_period')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('step4_period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('step4_period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('step4_period_yearly')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{t('step4_period_help')}
</p>
</div>
</div>
)}
</div>
{/* Accounting method */}
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content={t('step4_method_tip')}
side="right"
>
<Label>{t('step4_method_label')}</Label>
</InfoTooltip>
<Controller
name="accounting_method"
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="method_accrual"
checked={field.value === 'accrual'}
onCheckedChange={(checked) => { if (checked) field.onChange('accrual') }}
/>
<Label htmlFor="method_accrual" className="cursor-pointer">
{t('step4_method_accrual')}
</Label>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="method_cash"
checked={field.value === 'cash'}
onCheckedChange={(checked) => { if (checked) field.onChange('cash') }}
/>
<Label htmlFor="method_cash" className="cursor-pointer">
{t('step4_method_cash')}
</Label>
</div>
</div>
)}
/>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? t('step4_method_accrual') : t('step4_method_cash')}
</div>
<p className="text-sm text-muted-foreground">
{accountingMethod === 'accrual'
? t('step4_method_accrual_desc')
: t('step4_method_cash_desc')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
{t('step4_cash_limit_note')}
</p>
</div>
</div>
</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" />
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
@@ -33,8 +34,7 @@ import { InkText } from './ink'
import './journey.css'
/**
* The journey onboarding flow (replaces WelcomeOnboarding behind
* NEXT_PUBLIC_ONBOARDING_JOURNEY). Renders the reducer's current step,
* The journey onboarding flow. Renders the reducer's current step,
* performs the side effects, and collects the exact settings payload the
* wizard sends today.
*
@@ -57,7 +57,6 @@ function logError(message: string, extra?: Record<string, unknown>) {
interface OnboardingJourneyProps {
teamId: string
hasExistingCompanies?: boolean
mode?: 'first' | 'add'
initialOrgNumber?: string
initialEntityType?: EntityType
@@ -640,6 +639,11 @@ export default function OnboardingJourney({
return (
<div className="jny jny-fixed" style={{ ['--jny-dawn' as string]: String(station / 4) }}>
<div className="jny-dawn" aria-hidden="true" />
{mode === 'add' && state.step !== 'done' ? (
<Link href="/" className="jny-btn-quiet jny-escape">
&lsaquo; {t('journey_cancel_add')}
</Link>
) : null}
<div className="jny-center">
<div ref={bandRef} style={{ width: '100%' }}>
<JourneyTrack
@@ -22,6 +22,8 @@
z-index: 10;
min-height: 0;
}
/* mode='add': quiet escape back to the app, top left */
.jny-escape { position: absolute; top: 18px; left: 22px; z-index: 11; text-decoration: none; }
.jny-center {
flex: 1;
min-height: 0;
+5 -151
View File
@@ -1181,122 +1181,14 @@
"greeting_afternoon": "Good afternoon",
"greeting_evening": "Good evening",
"welcome_title": "Welcome to {appName}",
"add_first_company": "Add your first company",
"add_a_company": "Add a company",
"add_first_company_subtitle": "Add your first company to get started.",
"add_company_subtitle": "Add a company.",
"new_company": "New company",
"step1_title": "Entity type",
"step1_subtitle": "Choose your entity type to get started.",
"step2_title": "Details",
"step2_subtitle": "These details appear on invoices and documents.",
"step3_title": "F-skatt & fiscal year",
"step3_subtitle": "Enter your tax registration and fiscal year.",
"step4_title": "VAT & accounting",
"step4_subtitle": "VAT registration and accounting method.",
"period_error_end_after_start": "The end date must be after the start date.",
"period_error_start_first": "The start date must be the 1st of a month.",
"period_error_end_last_day": "The end date must be the last day of a month.",
"period_error_max_18": "The fiscal year cannot exceed 18 months (BFL 3 kap.).",
"period_error_invalid": "Invalid fiscal year. Check the dates and try again.",
"toast_invalid_fiscal_year": "Invalid fiscal year",
"toast_error_title": "Error",
"toast_create_failed": "Could not create company. Please try again.",
"toast_org_invalid_title": "Invalid organisation number",
"toast_org_invalid_description": "Check that you entered a valid 10- or 12-digit organisation number.",
"toast_welcome_title": "Welcome!",
"toast_company_ready": "Your company is ready.",
"toast_unexpected_error": "An unexpected error occurred. Please try again.",
"saving": "Saving...",
"continue": "Continue",
"back": "Back",
"coming_soon": "Coming soon",
"missing_fields": "Missing information",
"check_all_fields": "Check that all fields are filled in correctly.",
"step1_ef_description": "You run a business in your own name with an F-skatt certificate",
"step1_ab_description": "You have a registered AB with an organisation number",
"step2_card_title": "Basic information",
"step2_card_desc_tic": "Enter the organisation number and the remaining details will be fetched automatically.",
"step2_card_desc_ab": "Enter the company's registered name and organisation number.",
"step2_card_desc_ef": "Enter the name of your business.",
"step2_org_number_label": "Organisation number *",
"step2_org_number_placeholder_ef": "YYMMDD-XXXX (your personnummer for EF)",
"step2_org_help_ab": "Required for aktiebolag",
"step2_org_help_ef": "For enskild firma, the org number is the same as your personnummer",
"step2_fetching_details": "Fetching company details...",
"step2_lookup_not_found": "No company found with that organisation number.",
"step2_lookup_failed": "Could not fetch company details. You can fill them in manually.",
"step2_ceased_inline": "{companyName}: the company is deregistered",
"step2_company_exists_own": "You already have {name} with this organisation number. You can still continue.",
"step2_company_name_ab": "Company name *",
"step2_company_name_ef": "Business name (or your name for EF) *",
"step2_company_name_placeholder_ab": "AB Företaget",
"step2_company_name_placeholder_ef": "Alice's consulting",
"step2_address_heading": "Address (for invoices)",
"step2_street_address": "Street address",
"step2_street_placeholder": "Storgatan 1",
"step2_postal_code": "Postal code",
"step2_city": "City",
"step3_card_title": "F-skatt and fiscal year",
"step3_card_description": "These details are used to calculate your tax situation.",
"step3_invalid_period": "Fiscal year is not valid",
"step3_broken_year_title": "Are you sure about a non-calendar fiscal year?",
"step3_broken_year_description": "You've chosen a fiscal year that doesn't follow the calendar year (ending {endLabel}). Most Swedish companies use the calendar year (1 January to 31 December). You can change this later in settings, but only before you've posted anything.",
"step3_broken_year_confirm": "Yes, continue",
"step3_broken_year_cancel": "Change selection",
"step3_fskatt_tip_title": "What is F-skatt?",
"step3_fskatt_tip_body": "F-skatt means you are responsible for paying tax and contributions to Skatteverket each month yourself.",
"step3_fskatt_tip_note": "Most business owners have F-skatt. Without F-skatt, your customers must withhold tax from your invoices.",
"step3_fskatt_label": "I have an F-skatt certificate",
"step3_fskatt_help": "F-skatt means you are responsible for paying tax and contributions yourself.",
"step3_fy_tip_title": "Fiscal year",
"step3_fy_tip_body": "Your fiscal year determines the period you keep books for. Most companies use the calendar year (Jan-Dec).",
"step3_fy_tip_ef_note": "Enskild firma must use the calendar year per BFL 3 kap.",
"step3_fy_question": "Which fiscal year are you bookkeeping for?",
"step3_first_fy_title": "First fiscal year",
"step3_first_fy_subtitle": "Newly started company",
"step3_other_fy_title": "Other fiscal year",
"step3_other_fy_subtitle": "Ongoing business",
"step3_start_help": "The date the company was registered. The first fiscal year can start any day.",
"step3_fy_end_month_label": "Fiscal year ends (month)",
"step3_select_month": "Select month",
"step3_end_date_label": "End date",
"step3_select_end_date": "Select end date",
"step3_no_valid_end": "No valid end period found. Check the start date.",
"step3_calendar_year": "Calendar year (January to December)",
"step3_ef_calendar_required": "Enskild firma must use the calendar year per BFL 3 kap.",
"step3_when_fy_ends": "When does the company's fiscal year end?",
"step3_calendar_or_broken": "Most use the calendar year (December). A non-calendar fiscal year ends in another month.",
"step3_your_fy": "Your fiscal year",
"step3_twelve_months": "12 months",
"step4_card_title": "VAT and accounting method",
"step4_card_description": "Enter your VAT registration and choose an accounting method.",
"step4_vat_tip_title": "Do I need to register for VAT?",
"step4_vat_tip_body": "Yes, if your turnover exceeds SEK 120,000 per year. With VAT you add 25% to your invoices but can also deduct VAT on your purchases.",
"step4_vat_tip_note": "If your turnover exceeds SEK 120,000 per year you need to register for VAT.",
"step4_vat_heading": "VAT registration",
"step4_vat_registered_label": "I am VAT-registered",
"step4_vat_registered_help": "Required if your turnover exceeds SEK 120,000 per year.",
"step4_vat_number_label": "VAT registration number",
"step4_vat_number_format": "Format: SE + organisation number + 01",
"step4_vat_period_tip_title": "How often do you report VAT?",
"step4_vat_period_tip_body": "Choose the period stated on Verksamt or in your decision from Skatteverket.",
"step4_vat_period_bracket_low": "Under SEK 1 million/year = Can choose yearly",
"step4_vat_period_bracket_mid": "SEK 1-40 million = Quarterly",
"step4_vat_period_bracket_high": "Over SEK 40 million = Monthly",
"step4_vat_period_label": "VAT reporting period",
"step4_select_period": "Select period",
"step4_period_monthly": "Monthly",
"step4_period_quarterly": "Quarterly",
"step4_period_yearly": "Yearly",
"step4_period_help": "Choose the period stated in your decision from Skatteverket. Usually quarterly or yearly.",
"step4_method_tip": "Faktureringsmetoden posts revenue and expenses when the invoice is sent/received. Kontantmetoden posts at payment.",
"step4_method_label": "Accounting method",
"step4_method_accrual": "Faktureringsmetoden",
"step4_method_cash": "Kontantmetoden",
"step4_method_accrual_desc": "Revenue and expenses are posted when the invoice is sent or received, regardless of when payment occurs. This gives a more accurate view of the business's finances.",
"step4_method_cash_desc": "Revenue and expenses are posted only when payment actually occurs. Simpler to manage but gives a less precise view of the business's finances at any given moment.",
"step4_cash_limit_note": "Kontantmetoden may be used if annual net turnover is normally at most SEK 3 million (BFL 5 kap. 2 §).",
"journey_station_company": "The company",
"journey_station_fiscal": "Fiscal year",
"journey_station_vat": "VAT",
@@ -1424,7 +1316,8 @@
"journey_ans_yearly": "Yearly",
"journey_dup_note": "You already have {name} in Accounted.",
"journey_fact_vat": "VAT registered",
"journey_fact_ceased": "Deregistered"
"journey_fact_ceased": "Deregistered",
"journey_cancel_add": "Back to Accounted"
},
"select_company": {
"greeting_night": "Good night",
@@ -1433,57 +1326,18 @@
"greeting_afternoon": "Good afternoon",
"greeting_evening": "Good evening",
"subtitle": "Choose a company to open or add a new one.",
"setting_up": "Setting up your company…",
"org_nr_prefix": "Org.nr {orgNumber}",
"progress_lookup": "Fetching details from Bolagsverket",
"progress_provision": "Creating company and chart of accounts",
"enrichment_stale": "The information from BankID is more than a week old. Sign in with BankID again to refresh the list.",
"section_your_companies": "Your companies in {appName}",
"section_bankid_companies": "Companies linked to your BankID",
"already_in_app": "Already in {appName}",
"setup_manually": "Set up manually",
"no_companies_found": "No companies found. Add your first company below.",
"or_separator": "or",
"add_company_manually": "Add company manually",
"toast_lookup_failed_title": "Could not fetch company details",
"toast_lookup_failed_description": "Fill in the remaining details manually.",
"toast_company_ceased_title": "The company is deregistered",
"toast_company_ceased_description": "You cannot set up bookkeeping for a deregistered company.",
"toast_org_invalid_title": "Invalid organisation number",
"toast_org_invalid_description": "Continue with manual setup.",
"toast_create_failed_title": "Could not create company",
"toast_create_failed_description": "Try again or add it manually.",
"toast_welcome_title": "Welcome!",
"toast_company_ready": "Your company is ready.",
"search_placeholder": "Search companies …",
"opens_directly": "Opens directly",
"no_search_matches": "No company matches the search.",
"error_no_access": "You don't have access to this company.",
"error_switch_failed": "The company switch could not be saved. Please try again."
},
"companies_new": {
"step1_title": "New company",
"step1_subtitle": "Choose an entity type for the new company.",
"step1_label": "Entity type",
"step2_title": "Company details",
"step2_subtitle": "These details appear on invoices and documents.",
"step2_label": "Details",
"step3_title": "F-skatt & fiscal year",
"step3_subtitle": "Tax registration and fiscal year.",
"step3_label": "Tax",
"step4_title": "VAT & accounting",
"step4_subtitle": "VAT registration and accounting method.",
"step4_label": "VAT",
"period_error_end_after_start": "The end date must be after the start date.",
"period_error_start_first": "The start date must be the 1st of a month.",
"period_error_end_last_day": "The end date must be the last day of a month.",
"period_error_max_18": "The fiscal year cannot exceed 18 months (BFL 3 kap.).",
"period_error_invalid": "Invalid fiscal year. Check the dates and try again.",
"toast_invalid_fiscal_year": "Invalid fiscal year",
"toast_error_title": "Error",
"toast_no_team": "Could not find team. Reload the page.",
"toast_create_failed": "Could not create company. Please try again.",
"toast_company_created": "Company created!",
"toast_switched_to_new": "You have now switched to the new company.",
"toast_unexpected_error": "An unexpected error occurred. Please try again."
},
"invite": {
"header_invalid": "Invalid invitation",
"header_invited": "You've been invited",
+5 -151
View File
@@ -1181,122 +1181,14 @@
"greeting_afternoon": "God eftermiddag",
"greeting_evening": "God kväll",
"welcome_title": "Välkommen till {appName}",
"add_first_company": "Lägg till ditt första företag",
"add_a_company": "Lägg till ett företag",
"add_first_company_subtitle": "Lägg till ditt första företag för att komma igång.",
"add_company_subtitle": "Lägg till ett företag.",
"new_company": "Nytt företag",
"step1_title": "Företagsform",
"step1_subtitle": "Välj din företagsform för att komma igång.",
"step2_title": "Uppgifter",
"step2_subtitle": "Uppgifterna visas på fakturor och dokument.",
"step3_title": "F-skatt & räkenskapsår",
"step3_subtitle": "Ange din skatteregistrering och räkenskapsår.",
"step4_title": "Moms & bokföring",
"step4_subtitle": "Momsregistrering och bokföringsmetod.",
"period_error_end_after_start": "Slutdatumet måste vara efter startdatumet.",
"period_error_start_first": "Startdatumet måste vara den 1:a i en månad.",
"period_error_end_last_day": "Slutdatumet måste vara sista dagen i en månad.",
"period_error_max_18": "Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).",
"period_error_invalid": "Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.",
"toast_invalid_fiscal_year": "Ogiltigt räkenskapsår",
"toast_error_title": "Fel",
"toast_create_failed": "Kunde inte skapa företag. Försök igen.",
"toast_org_invalid_title": "Ogiltigt organisationsnummer",
"toast_org_invalid_description": "Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.",
"toast_welcome_title": "Välkommen!",
"toast_company_ready": "Ditt företag är nu redo.",
"toast_unexpected_error": "Ett oväntat fel uppstod. Försök igen.",
"saving": "Sparar...",
"continue": "Fortsätt",
"back": "Tillbaka",
"coming_soon": "Kommer snart",
"missing_fields": "Saknade uppgifter",
"check_all_fields": "Kontrollera att alla fält är korrekt ifyllda.",
"step1_ef_description": "Du driver verksamhet i eget namn med F-skattsedel",
"step1_ab_description": "Du har ett registrerat AB med organisationsnummer",
"step2_card_title": "Grunduppgifter",
"step2_card_desc_tic": "Ange organisationsnummer så hämtas övriga uppgifter automatiskt.",
"step2_card_desc_ab": "Ange bolagets registrerade namn och organisationsnummer.",
"step2_card_desc_ef": "Ange namn på din verksamhet.",
"step2_org_number_label": "Organisationsnummer *",
"step2_org_number_placeholder_ef": "ÅÅMMDD-XXXX (ditt personnummer vid EF)",
"step2_org_help_ab": "Obligatoriskt för aktiebolag",
"step2_org_help_ef": "Vid enskild firma är orgnummer samma som ditt personnummer",
"step2_fetching_details": "Hämtar företagsuppgifter...",
"step2_lookup_not_found": "Inget företag hittades med det organisationsnumret.",
"step2_lookup_failed": "Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.",
"step2_ceased_inline": "{companyName}: företaget är avregistrerat",
"step2_company_exists_own": "Du har redan företaget {name} med detta organisationsnummer. Du kan ändå fortsätta.",
"step2_company_name_ab": "Företagsnamn *",
"step2_company_name_ef": "Verksamhetsnamn (Eller ditt namn vid EF) *",
"step2_company_name_placeholder_ab": "AB Företaget",
"step2_company_name_placeholder_ef": "Alices Konsultverksamhet",
"step2_address_heading": "Adress (för fakturor)",
"step2_street_address": "Gatuadress",
"step2_street_placeholder": "Storgatan 1",
"step2_postal_code": "Postnummer",
"step2_city": "Ort",
"step3_card_title": "F-skatt och räkenskapsår",
"step3_card_description": "Dessa uppgifter används för att beräkna din skattesituation.",
"step3_invalid_period": "Räkenskapsåret är inte giltigt",
"step3_broken_year_title": "Är du säker på brutet räkenskapsår?",
"step3_broken_year_description": "Du har valt ett räkenskapsår som inte följer kalenderåret (slutar {endLabel}). De flesta svenska företag använder kalenderår (1 januari till 31 december). Du kan ändra detta senare i inställningarna, men endast innan du har bokfört något.",
"step3_broken_year_confirm": "Ja, fortsätt",
"step3_broken_year_cancel": "Ändra val",
"step3_fskatt_tip_title": "Vad är F-skatt?",
"step3_fskatt_tip_body": "F-skatt betyder att du själv ansvarar för att betala skatt och avgifter till Skatteverket varje månad.",
"step3_fskatt_tip_note": "De flesta som driver företag har F-skatt. Utan F-skatt måste dina kunder göra skatteavdrag på dina fakturor.",
"step3_fskatt_label": "Jag har F-skattsedel",
"step3_fskatt_help": "F-skatt innebär att du själv ansvarar för att betala in skatt och avgifter.",
"step3_fy_tip_title": "Räkenskapsår",
"step3_fy_tip_body": "Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).",
"step3_fy_tip_ef_note": "Enskild firma måste använda kalenderår enligt BFL 3 kap.",
"step3_fy_question": "Vilket räkenskapsår bokför du för?",
"step3_first_fy_title": "Första räkenskapsåret",
"step3_first_fy_subtitle": "Nystartat företag",
"step3_other_fy_title": "Annat räkenskapsår",
"step3_other_fy_subtitle": "Pågående verksamhet",
"step3_start_help": "Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag.",
"step3_fy_end_month_label": "Räkenskapsåret slutar (månad)",
"step3_select_month": "Välj månad",
"step3_end_date_label": "Slutdatum",
"step3_select_end_date": "Välj slutdatum",
"step3_no_valid_end": "Ingen giltig slutperiod hittades. Kontrollera startdatumet.",
"step3_calendar_year": "Kalenderår (januari-december)",
"step3_ef_calendar_required": "Enskild firma måste använda kalenderår enligt BFL 3 kap.",
"step3_when_fy_ends": "När slutar företagets räkenskapsår?",
"step3_calendar_or_broken": "De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.",
"step3_your_fy": "Ditt räkenskapsår",
"step3_twelve_months": "12 månader",
"step4_card_title": "Moms och bokföringsmetod",
"step4_card_description": "Ange din momsregistrering och välj bokföringsmetod.",
"step4_vat_tip_title": "Behöver jag momsregistrera mig?",
"step4_vat_tip_body": "Ja, om din omsättning överstiger 120 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.",
"step4_vat_tip_note": "Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.",
"step4_vat_heading": "Momsregistrering",
"step4_vat_registered_label": "Jag är momsregistrerad",
"step4_vat_registered_help": "Obligatoriskt om din omsättning överstiger 120 000 kr per år.",
"step4_vat_number_label": "Momsregistreringsnummer",
"step4_vat_number_format": "Format: SE + organisationsnummer + 01",
"step4_vat_period_tip_title": "Hur ofta rapporterar du moms?",
"step4_vat_period_tip_body": "Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.",
"step4_vat_period_bracket_low": "Under 1 miljon/år = Kan välja årsredovisning",
"step4_vat_period_bracket_mid": "1-40 miljoner = Kvartal",
"step4_vat_period_bracket_high": "Över 40 miljoner = Månad",
"step4_vat_period_label": "Momsredovisningsperiod",
"step4_select_period": "Välj period",
"step4_period_monthly": "Månad",
"step4_period_quarterly": "Kvartal",
"step4_period_yearly": "År",
"step4_period_help": "Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.",
"step4_method_tip": "Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning.",
"step4_method_label": "Bokföringsmetod",
"step4_method_accrual": "Faktureringsmetoden",
"step4_method_cash": "Kontantmetoden",
"step4_method_accrual_desc": "Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.",
"step4_method_cash_desc": "Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.",
"step4_cash_limit_note": "Kontantmetoden får användas om årlig nettoomsättning normalt är högst 3 MSEK (BFL 5 kap. 2 §).",
"journey_station_company": "Företaget",
"journey_station_fiscal": "Räkenskapsåret",
"journey_station_vat": "Momsen",
@@ -1424,7 +1316,8 @@
"journey_ans_yearly": "Årsvis",
"journey_dup_note": "Du har redan {name} i Accounted.",
"journey_fact_vat": "Momsregistrerat",
"journey_fact_ceased": "Avregistrerat"
"journey_fact_ceased": "Avregistrerat",
"journey_cancel_add": "Tillbaka till Accounted"
},
"select_company": {
"greeting_night": "God natt",
@@ -1433,57 +1326,18 @@
"greeting_afternoon": "God eftermiddag",
"greeting_evening": "God kväll",
"subtitle": "Välj ett företag att öppna eller lägg till ett nytt.",
"setting_up": "Sätter upp ditt företag…",
"org_nr_prefix": "Org.nr {orgNumber}",
"progress_lookup": "Hämtar uppgifter från Bolagsverket",
"progress_provision": "Skapar företag och kontoplan",
"enrichment_stale": "Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.",
"section_your_companies": "Dina företag i {appName}",
"section_bankid_companies": "Företag kopplade till ditt BankID",
"already_in_app": "Finns redan i {appName}",
"setup_manually": "Sätts upp manuellt",
"no_companies_found": "Inga företag hittades. Lägg till ditt första företag nedan.",
"or_separator": "eller",
"add_company_manually": "Lägg till företag manuellt",
"toast_lookup_failed_title": "Kunde inte hämta företagsuppgifter",
"toast_lookup_failed_description": "Fyll i resterande uppgifter manuellt.",
"toast_company_ceased_title": "Företaget är avregistrerat",
"toast_company_ceased_description": "Det går inte att sätta upp bokföring för ett avregistrerat företag.",
"toast_org_invalid_title": "Ogiltigt organisationsnummer",
"toast_org_invalid_description": "Fortsätt med manuell uppsättning.",
"toast_create_failed_title": "Kunde inte skapa företag",
"toast_create_failed_description": "Försök igen eller lägg till manuellt.",
"toast_welcome_title": "Välkommen!",
"toast_company_ready": "Ditt företag är nu redo.",
"search_placeholder": "Sök företag …",
"opens_directly": "Öppnas direkt",
"no_search_matches": "Inget företag matchar sökningen.",
"error_no_access": "Du har inte tillgång till detta företag.",
"error_switch_failed": "Företagsbytet kunde inte sparas. Försök igen."
},
"companies_new": {
"step1_title": "Nytt företag",
"step1_subtitle": "Välj företagsform för det nya företaget.",
"step1_label": "Företagsform",
"step2_title": "Företagsuppgifter",
"step2_subtitle": "Uppgifterna visas på fakturor och dokument.",
"step2_label": "Uppgifter",
"step3_title": "F-skatt & räkenskapsår",
"step3_subtitle": "Skatteregistrering och räkenskapsår.",
"step3_label": "Skatt",
"step4_title": "Moms & bokföring",
"step4_subtitle": "Momsregistrering och bokföringsmetod.",
"step4_label": "Moms",
"period_error_end_after_start": "Slutdatumet måste vara efter startdatumet.",
"period_error_start_first": "Startdatumet måste vara den 1:a i en månad.",
"period_error_end_last_day": "Slutdatumet måste vara sista dagen i en månad.",
"period_error_max_18": "Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).",
"period_error_invalid": "Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.",
"toast_invalid_fiscal_year": "Ogiltigt räkenskapsår",
"toast_error_title": "Fel",
"toast_no_team": "Kunde inte hitta team. Ladda om sidan.",
"toast_create_failed": "Kunde inte skapa företag. Försök igen.",
"toast_company_created": "Företag skapat!",
"toast_switched_to_new": "Du har nu bytt till det nya företaget.",
"toast_unexpected_error": "Ett oväntat fel uppstod. Försök igen."
},
"invite": {
"header_invalid": "Ogiltig inbjudan",
"header_invited": "Du har blivit inbjuden",