diff --git a/DECISIONS.md b/DECISIONS.md index e3d6ac8f..916b658d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -360,3 +360,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 2c2b1fae..13fa427e 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -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 ( - - ) - } - return ( - ) } diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index 2ea87d16..85784e3c 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -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 - -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 ( - - - - }> - - - ) -} - -const LOG = '[new-company]' - -function logError(message: string, extra?: Record) { - 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>({}) - const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') - const [ticLookup, setTicLookup] = useState(null) - - const totalSteps = 4 - - const [teamId, setTeamId] = useState(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) => { - 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, - 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 ( -
- -
- ) + 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 ( -
- {/* Header */} -
-
-
- - {String(currentStep).padStart(2, '0')} - -
- -
-
-
- - - - - {branding.appName.toLowerCase()} - -
-
- {STEP_INFO.map((_, i) => { - const num = i + 1 - return ( -
currentStep && 'w-4 bg-white/[0.1]', - )} - /> - ) - })} -
- - {currentStep} / {totalSteps} - -
- -
-

- {stepInfo.title} -

-

- {stepInfo.subtitle} -

-
-
-
- - {/* Form content */} -
-
-
- {currentStep === 1 && ( - handleNext(data)} - isSaving={isSaving} - /> - )} - - {currentStep === 2 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} - - {currentStep === 3 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} - - {currentStep === 4 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} -
-
-
+
+ +
) } diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx deleted file mode 100644 index 0264f6fa..00000000 --- a/components/dashboard/WelcomeOnboarding.tsx +++ /dev/null @@ -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 - -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) { - 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>(() => { - const seed: Partial = {} - 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(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) => { - // 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, - 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 ( -
-

{greeting}

-

- {t('welcome_title', { appName: branding.appName })} -

- -
- ) - } - - return ( -
- {/* Greeting header */} -
-

- {greeting}{firstName ? `, ${firstName}` : ''} -

-

- {hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')} -

-
- - {/* Onboarding card */} -
-
- {/* Card header with step info */} -
-
-
- {/* 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 ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
- ) - })()} -
- -
-
-
- - {t('new_company')} -
-
- {STEP_INFO.map((_, i) => { - const num = i + 1 - return ( -
currentStep && 'w-3 bg-white/[0.1]', - )} - /> - ) - })} - - {currentStep}/{totalSteps} - -
-
- -

- {stepInfo.title} -

-

- {stepInfo.subtitle} -

-
-
- - {/* Form content */} -
-
- {currentStep === 1 && ( - handleNext(data)} - isSaving={isSaving} - /> - )} - - {currentStep === 2 && ( - 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 ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - ) - })()} - - {currentStep === 4 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} -
-
-
-
-
- ) -} diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx index 792a4aa3..8902a7d3 100644 --- a/components/onboarding/BankIdCompanyPicker.tsx +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -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({ 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 (
-
+

{greeting}{firstName ? `, ${firstName}` : ''}

-

- {t('subtitle')} -

+

{t('subtitle')}

-
- {enrichmentStale && ( -
-
- -

- {t('enrichment_stale')} -

-
-
- )} - - {memberCompanies.length > 0 && ( -
-

- {t('section_your_companies', { appName: branding.appName.toLowerCase() })} -

-
    - {memberCompanies.map((c) => { - const isOpening = setup.kind === 'opening' && setup.companyId === c.id - return ( -
  • - -
  • - ) - })} -
-
- )} - - {ticCompanies.length > 0 && ( -
-

- {t('section_bankid_companies')} -

-
    - {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' ? ( -

    - {t('already_in_app', { appName: branding.appName.toLowerCase() })} -

    - ) : null - - if (!mappable) { - return ( -
  • - -
    -
    -

    {role.legalName}

    -

    - {cleaned} · {entityLabel} - {position ? ` · ${position}` : ''} -

    -
    - - {t('setup_manually')} - -
    - {existsNote} - -
  • - ) - } - - return ( -
  • - -
  • - ) - })} -
-
- )} - - {memberCompanies.length === 0 && ticCompanies.length === 0 && ( -

- {t('no_companies_found')} -

- )} - -
-
-
-
-
- - {t('or_separator')} - + {enrichmentStale && ( +
+
+ +

{t('enrichment_stale')}

+ )} - - - {t('add_company_manually')} +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onSearchEnter()} + /> +
+ +
+ {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 ( + + ) + })} + + {filteredMembers.length > 0 && ( +
+ {t('section_your_companies', { appName: branding.appName.toLowerCase() })} +
+ )} + {filteredMembers.map((c) => { + const isOpening = setup.kind === 'opening' && setup.companyId === c.id + return ( + + ) + })} + + {filteredTic.length === 0 && filteredMembers.length === 0 && ( +
+ {ticCompanies.length + memberCompanies.length === 0 + ? t('no_companies_found') + : t('no_search_matches')} +
+ )} +
+ +
+ + {t('add_company_manually')} …
diff --git a/components/onboarding/Step1EntityType.tsx b/components/onboarding/Step1EntityType.tsx deleted file mode 100644 index 4848a917..00000000 --- a/components/onboarding/Step1EntityType.tsx +++ /dev/null @@ -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(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 ( -
-
- {entityOptions.map((option) => { - const Icon = option.icon - const isSelected = selected === option.value - return ( - - ) - })} -
- -
- -
-
- ) -} diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx deleted file mode 100644 index 77b75b0f..00000000 --- a/components/onboarding/Step2CompanyDetails.tsx +++ /dev/null @@ -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 - -interface Step2Props { - initialData: Partial - 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({ - 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(null) - const [lookupDone, setLookupDone] = useState(null) - const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null) - const abortRef = useRef(null) - const dupAbortRef = useRef(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( - 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 ( -
- - - {t('step2_card_title')} - - {ticEnabled - ? t('step2_card_desc_tic') - : isAB - ? t('step2_card_desc_ab') - : t('step2_card_desc_ef')} - - - -
{ - 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"> -
- - - {errors.org_number && ( -

{errors.org_number.message}

- )} -

- {isAB - ? t('step2_org_help_ab') - : t('step2_org_help_ef')} -

- {ticEnabled && isLooking && ( -
- - {t('step2_fetching_details')} -
- )} - {ticEnabled && lookupDone && !lookupDone.isCeased && ( -
- - {lookupDone.companyName} -
- )} - {ticEnabled && lookupDone?.isCeased && ( -
- - {t('step2_ceased_inline', { companyName: lookupDone.companyName })} -
- )} - {ticEnabled && lookupError && ( -

{lookupError}

- )} - {existingOwn && ( -
- - {t('step2_company_exists_own', { name: existingOwn.name })} -
- )} -
- -
- - - {errors.company_name && ( -

{errors.company_name.message}

- )} -
- -
-

{t('step2_address_heading')}

- -
-
- - -
- -
-
- - -
-
- - -
-
-
-
- -
- - -
-
-
-
-
- ) -} diff --git a/components/onboarding/Step2SectorSelection.tsx b/components/onboarding/Step2SectorSelection.tsx deleted file mode 100644 index 0f9c904c..00000000 --- a/components/onboarding/Step2SectorSelection.tsx +++ /dev/null @@ -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(null) - - const industrySectors = SECTORS.filter(s => s.slug !== 'general') - - return ( -
-
-

Vilken bransch verkar du inom?

-

- Vi anpassar verktyg och tillägg baserat på din bransch. Du kan alltid ändra detta senare. -

-
- -
- {industrySectors.map(sector => { - const Icon = resolveIcon(sector.icon) - const isSelected = selected === sector.slug - return ( - setSelected(sector.slug)} - > - -
-
- -
-
-

{sector.name}

-

{sector.description}

-

- {sector.extensions.length} branschverktyg -

-
-
-
-
- ) - })} - - {/* "Other" option */} - setSelected('other')} - > - -
-
- -
-
-

Annan bransch

-

- Generella verktyg utan branschspecifika tillägg -

-
-
-
-
-
- -
-
- - -
- -
-
- ) -} diff --git a/components/onboarding/Step3ExtensionSuggestions.tsx b/components/onboarding/Step3ExtensionSuggestions.tsx deleted file mode 100644 index a02be461..00000000 --- a/components/onboarding/Step3ExtensionSuggestions.tsx +++ /dev/null @@ -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 = { - 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>({}) - - 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 ( -
-
-

Välj dina tillägg

-

- Aktivera de verktyg du vill använda. Du kan alltid ändra detta senare under Tillägg. -

-
- - {sections.map(section => ( -
-

- {section.label} -

-
- {section.extensions.map(ext => { - const Icon = resolveIcon(ext.icon) - const key = `${ext.sector}/${ext.slug}` - const isEnabled = toggles[key] ?? false - return ( -
-
-
- -
-
-
-

{ext.name}

- - {tExt(CATEGORY_LABEL_KEY[ext.category])} - -
-

{ext.description}

-
-
- handleToggle(ext)} - /> -
- ) - })} -
-
- ))} - -
-
- - -
- -
-
- ) -} diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx deleted file mode 100644 index b8109579..00000000 --- a/components/onboarding/Step3TaxRegistration.tsx +++ /dev/null @@ -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 - -// 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 - 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({ - 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( - 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 ( -
- - - - {t('step3_card_title')} - - {t('step3_card_description')} - - - -
{ - 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 */} -
- ( - - )} - /> -
- -

{t('step3_fskatt_tip_title')}

-

{t('step3_fskatt_tip_body')}

-

{t('step3_fskatt_tip_note')}

-
- } - side="right" - > - - -

- {t('step3_fskatt_help')} -

-
-
- - {/* Fiscal year section */} -
- -

{t('step3_fy_tip_title')}

-

{t('step3_fy_tip_body')}

- {isEF && ( -

{t('step3_fy_tip_ef_note')}

- )} -
- } - side="right" - > - - - - {/* Toggle: First year vs Ongoing */} - ( -
- - -
- )} - /> - - {/* First fiscal year options */} - {isFirstYear && ( -
- ( - ( - { - 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 && ( -
- - -
- )} - - {/* End date selector (options depend on entity type + start) */} - {parsedStart && firstYearEndOptions.length > 0 && ( -
- - - {errors.first_year_end && ( -

{errors.first_year_end.message}

- )} -
- )} - - {parsedStart && firstYearEndOptions.length === 0 && ( -

- {t('step3_no_valid_end')} -

- )} - - {errors.first_year_start && ( -

{errors.first_year_start.message}

- )} - - } - /> - )} - /> - )} - /> -
- )} - - {/* Ongoing fiscal year options */} - {!isFirstYear && ( -
- {isEF ? ( -
-

{t('step3_calendar_year')}

-

- {t('step3_ef_calendar_required')} -

-
- ) : ( -
- - ( - - )} - /> -

- {t('step3_calendar_or_broken')} -

- - {fiscalYearEndMonth && ( -
-
- - {t('step3_your_fy')} -
-

- {fiscalYearEndMonth === 12 - ? `1 januari \u2013 31 december (kalenderår)` - : `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`} -

-

{t('step3_twelve_months')}

-
- )} -
- )} -
- )} -
- -
- - -
- - - -
- ) -} diff --git a/components/onboarding/Step4PreliminaryTax.tsx b/components/onboarding/Step4PreliminaryTax.tsx deleted file mode 100644 index 6c7c4211..00000000 --- a/components/onboarding/Step4PreliminaryTax.tsx +++ /dev/null @@ -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 - -interface Step4Props { - initialData: Partial - 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({ - 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 ( -
- - - - - Månatlig F-skatt - - - Detta är frivilligt men hjälper oss visa varningar om du betalar för lite skatt. - - - -
{ - 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"> -
- - - {errors.preliminary_tax_monthly && ( -

- {errors.preliminary_tax_monthly.message} -

- )} -

- Beloppet hittar du på ditt F-skattsedelsbeslut från Skatteverket. -

-
- -
-

Varför är detta användbart?

-
    -
  • • Vi jämför din inbetalda skatt mot beräknad skatt
  • -
  • • Du får varningar om du riskerar restskatt
  • -
  • • Lättare att planera för slutskattebeskedet
  • -
-
- -
- -
- - -
-
-
-
-
-
- ) -} diff --git a/components/onboarding/Step4VatAccounting.tsx b/components/onboarding/Step4VatAccounting.tsx deleted file mode 100644 index 601ded11..00000000 --- a/components/onboarding/Step4VatAccounting.tsx +++ /dev/null @@ -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 - -interface Step4Output { - vat_registered: boolean - vat_number?: string - moms_period?: MomsPeriod - accounting_method: 'accrual' | 'cash' -} - -interface Step4Props { - initialData: Partial - 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({ - 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 ( -
- - - {t('step4_card_title')} - - {t('step4_card_description')} - - - -
{ - 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 */} -
-

- -

{t('step4_vat_tip_title')}

-

{t('step4_vat_tip_body')}

-

{t('step4_vat_tip_note')}

-

- } - side="right" - > - {t('step4_vat_heading')} - - - -
- ( - - )} - /> -
- -

- {t('step4_vat_registered_help')} -

-
-
- - {vatRegistered && ( -
-
- - -

- {t('step4_vat_number_format')} -

-
- -
- -

{t('step4_vat_period_tip_title')}

-

{t('step4_vat_period_tip_body')}

-
    -
  • {t('step4_vat_period_bracket_low')}
  • -
  • {t('step4_vat_period_bracket_mid')}
  • -
  • {t('step4_vat_period_bracket_high')}
  • -
-
- } - side="right" - > - - - ( - - )} - /> -

- {t('step4_period_help')} -

-
-
- )} -
- - {/* Accounting method */} -
-
- - - - ( -
-
- { if (checked) field.onChange('accrual') }} - /> - -
-
- { if (checked) field.onChange('cash') }} - /> - -
-
- )} - /> -
-
- - {accountingMethod === 'accrual' ? t('step4_method_accrual') : t('step4_method_cash')} -
-

- {accountingMethod === 'accrual' - ? t('step4_method_accrual_desc') - : t('step4_method_cash_desc')} -

-

- {t('step4_cash_limit_note')} -

-
-
-
- -
- - -
- - - -
- ) -} diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index 91fbd386..1a23c484 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -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) { interface OnboardingJourneyProps { teamId: string - hasExistingCompanies?: boolean mode?: 'first' | 'add' initialOrgNumber?: string initialEntityType?: EntityType @@ -640,6 +639,11 @@ export default function OnboardingJourney({ return (