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:
co-authored by
Claude Fable 5
parent
53e343ee92
commit
213d611e54
@@ -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).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')} …
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
‹ {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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user