feat: one-click company setup from BankID directorships (#309)
* feat: one-click company setup from BankID directorships After BankID auth, surface Bolagsverket companies where the user is a director and provision a fully-configured gnubok company with one click instead of walking the 4-step wizard. Also exposed via CompanySwitcher's "Lägg till företag" for returning users. - New /select-company route merges gnubok memberships with TIC CompanyRoles; cards flag already-registered org numbers. - createCompanyFromTicRole server action derives entity_type, f-skatt, VAT, moms_period, and SPAR address defaults, then delegates to createCompanyFromOnboarding for consistent provisioning. - TIC /bankid/complete now requests enrichment on login too, so returning users see fresh CompanyRoles in the picker. - Middleware routes zero-membership users to /select-company when enrichment is available, /onboarding otherwise. - Inline enrichment picker removed from WelcomeOnboarding (wizard is now the manual fallback); SPAR address pre-fill preserved. - Unit tests for mapEntityType helper and createCompanyFromTicRole defaults (VAT-AB, non-VAT EF, unmappable, unauth). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review feedback on BankID company picker Greptile P1 + swedish-compliance bot findings: - Move enrichment row cleanup out of createCompanyFromOnboarding and into createCompanyFromTicRole. The manual wizard also goes through createCompanyFromOnboarding, and was wiping the enrichment row before the returning-user "Lägg till företag" flow could use it. - Refuse to provision when TIC /lookup is missing. Silently defaulting vat_registered to false for a momsregistrerat bolag would create a company that issues invoices without moms (ML 17 kap violation). The picker now routes to the manual wizard with org_number pre-filled when the lookup fails, so the user confirms VAT/F-skatt manually. - Default accounting_method by entity type: enskild firma → cash (K1/kontantmetoden per BFNAR 2013:2), aktiebolag → accrual (K2/K3). - Document that moms_period='quarterly' is a provisional middle-tier default; Skatteverket's assigned period depends on turnover and the user can correct it in /settings/tax. - Fix the misleading "re-fetch from BankID" comment — /select-company only reads the cached enrichment row; it's refreshed only on the next BankID auth. - Extend test coverage: lookup-missing refusal, EF kontantmetoden default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: tighten entity-type mapping and clarify K1 threshold Second round of PR review fixes (swedish-compliance bot): - mapEntityType now uses explicit allow-lists instead of substring matches. "Enskild stiftelse" / "Enskild näringsverksamhet utan firma" no longer false-match as enskild_firma (would have provisioned with K1/kontantmetoden — ML/BFL risk). Regression guard test added. - Publikt aktiebolag explicitly included (same K2/K3 regime as private AB); Bankaktiebolag / Försäkringsaktiebolag excluded (FFFS regime). - Remove misleading claim that onboarding UI flags moms_period as provisional — no such UI exists by design (approved one-click UX). - Expand accounting_method comment to cite the 3 MSEK K1→K3 threshold (BFNAR 2013:2 vs 2017:3) so the EF→cash default is honest about its scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
adf58a51c0
commit
f3a3d07ed3
@@ -110,7 +110,7 @@ export default function CompanySwitcher() {
|
||||
if (!company && companies.length === 0) {
|
||||
return (
|
||||
<Link
|
||||
href="/onboarding"
|
||||
href="/select-company"
|
||||
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -189,7 +189,7 @@ export default function CompanySwitcher() {
|
||||
|
||||
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
|
||||
<Link
|
||||
href="/onboarding"
|
||||
href="/select-company"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
|
||||
>
|
||||
|
||||
@@ -6,10 +6,10 @@ 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, Building2, Plus } from 'lucide-react'
|
||||
import { Loader2, Building2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
|
||||
import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
||||
@@ -24,14 +24,6 @@ const STEP_INFO = [
|
||||
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' },
|
||||
]
|
||||
|
||||
/** Map TIC legalEntityType to gnubok EntityType */
|
||||
function mapEntityType(ticType: string): EntityType | null {
|
||||
const lower = ticType.toLowerCase()
|
||||
if (lower === 'ab' || lower.includes('aktiebolag')) return 'aktiebolag'
|
||||
if (lower === 'ef' || lower.includes('enskild firma') || lower.includes('enskild')) return 'enskild_firma'
|
||||
return null
|
||||
}
|
||||
|
||||
function translatePeriodError(msg: string): string {
|
||||
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
|
||||
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
|
||||
@@ -70,17 +62,18 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
||||
const [enrichmentCompanies, setEnrichmentCompanies] = useState<EnrichmentCompanyRole[]>([])
|
||||
const [orgNumberLocked, setOrgNumberLocked] = useState(false)
|
||||
|
||||
const totalSteps = 4
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
|
||||
|
||||
// Load BankID enrichment data on mount
|
||||
// Pre-fill the user's folkbokföringsadress from BankID/SPAR enrichment when
|
||||
// available. The row is persisted (not consumed here) so /select-company and
|
||||
// repeat /onboarding visits still see it; it's deleted only once a company
|
||||
// is successfully created (see createCompanyFromOnboarding).
|
||||
useEffect(() => {
|
||||
async function loadEnrichment() {
|
||||
async function loadSparAddress() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
@@ -90,37 +83,20 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
try {
|
||||
const { data: enrichmentRow } = await supabase
|
||||
.from('extension_data')
|
||||
.select('id, value')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'tic')
|
||||
.eq('key', 'bankid_enrichment')
|
||||
.maybeSingle()
|
||||
|
||||
if (enrichmentRow?.value) {
|
||||
const enrichment = enrichmentRow.value as { spar?: Record<string, string>; companyRoles?: EnrichmentCompanyRole[] }
|
||||
|
||||
const activeCompanies = (enrichment.companyRoles ?? []).filter(
|
||||
(c: EnrichmentCompanyRole) => c.companyStatus === 'Aktivt' && c.positionEnd === null
|
||||
)
|
||||
if (activeCompanies.length > 0) {
|
||||
setEnrichmentCompanies(activeCompanies)
|
||||
}
|
||||
|
||||
if (enrichment.spar) {
|
||||
const spar = enrichment.spar
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1,
|
||||
postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code,
|
||||
city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city,
|
||||
}))
|
||||
}
|
||||
|
||||
// Delete enrichment data (one-time use)
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('id', enrichmentRow.id)
|
||||
const spar = (enrichmentRow?.value as { spar?: Record<string, string> } | null)?.spar
|
||||
if (spar) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1,
|
||||
postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code,
|
||||
city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city,
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(LOG, 'enrichment loading failed (non-blocking)', err)
|
||||
@@ -129,7 +105,7 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadEnrichment()
|
||||
loadSparAddress()
|
||||
}, [supabase, router])
|
||||
|
||||
const handleNext = async (stepData: Partial<CompanySettings>) => {
|
||||
@@ -216,21 +192,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle selecting a company from BankID enrichment */
|
||||
const handleEnrichmentSelect = (company: EnrichmentCompanyRole) => {
|
||||
const entityType = mapEntityType(company.legalEntityType)
|
||||
if (!entityType) return
|
||||
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
entity_type: entityType,
|
||||
org_number: company.companyRegistrationNumber,
|
||||
company_name: company.legalName,
|
||||
}))
|
||||
setOrgNumberLocked(true)
|
||||
setEnrichmentCompanies([])
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -324,35 +285,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
{/* Form content */}
|
||||
<div className="px-6 py-6">
|
||||
<div key={`step-${currentStep}`} className="animate-slide-up">
|
||||
{currentStep === 1 && enrichmentCompanies.length > 0 && (
|
||||
<div className="mb-6 space-y-3">
|
||||
<p className="text-sm font-medium">Vi hittade dessa företag kopplade till ditt BankID:</p>
|
||||
{enrichmentCompanies.map((company) => {
|
||||
const entityType = mapEntityType(company.legalEntityType)
|
||||
return (
|
||||
<button
|
||||
key={company.companyRegistrationNumber}
|
||||
onClick={() => handleEnrichmentSelect(company)}
|
||||
className="w-full rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/[0.02]"
|
||||
>
|
||||
<p className="font-medium text-sm">{company.legalName}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{company.companyRegistrationNumber} · {entityType === 'aktiebolag' ? 'Aktiebolag' : entityType === 'enskild_firma' ? 'Enskild firma' : company.legalEntityType}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">eller välj företagsform manuellt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Step1EntityType
|
||||
initialData={{ entity_type: settings.entity_type as EntityType }}
|
||||
@@ -377,7 +309,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
orgNumberLocked={orgNumberLocked}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { switchCompany, createCompanyFromTicRole } from '@/lib/company/actions'
|
||||
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
|
||||
export interface MemberCompany {
|
||||
id: string
|
||||
name: string
|
||||
orgNumber: string | null
|
||||
entityType: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface TicPickerCompany {
|
||||
role: EnrichmentCompanyRole
|
||||
/** 'new' = not in gnubok, can set up. 'exists' = in gnubok but user is not a member. */
|
||||
status: 'new' | 'exists'
|
||||
}
|
||||
|
||||
interface BankIdCompanyPickerProps {
|
||||
firstName: string | null
|
||||
teamId: string
|
||||
memberCompanies: MemberCompany[]
|
||||
ticCompanies: TicPickerCompany[]
|
||||
enrichmentStale: boolean
|
||||
}
|
||||
|
||||
type SetupState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'opening'; companyId: string }
|
||||
| { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function humanTicEntityType(t: string): string {
|
||||
const mapped = mapEntityType(t)
|
||||
if (mapped === 'aktiebolag') return 'Aktiebolag'
|
||||
if (mapped === 'enskild_firma') return 'Enskild firma'
|
||||
if (t.toLowerCase().includes('handelsbolag') || t.toLowerCase() === 'hb') return 'Handelsbolag'
|
||||
if (t.toLowerCase().includes('kommanditbolag') || t.toLowerCase() === 'kb') return 'Kommanditbolag'
|
||||
return t
|
||||
}
|
||||
|
||||
function positionLabel(role: EnrichmentCompanyRole): string {
|
||||
const descs = role.positionDescriptions?.filter(Boolean)
|
||||
if (descs && descs.length > 0) return descs.join(' · ')
|
||||
const types = role.positionTypes?.filter(Boolean)
|
||||
if (types && types.length > 0) return types.join(' · ')
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function BankIdCompanyPicker({
|
||||
firstName,
|
||||
teamId,
|
||||
memberCompanies,
|
||||
ticCompanies,
|
||||
enrichmentStale,
|
||||
}: BankIdCompanyPickerProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
|
||||
|
||||
const busy = setup.kind !== 'idle' || isPending
|
||||
|
||||
async function handleOpenMember(companyId: string) {
|
||||
if (busy) return
|
||||
setSetup({ kind: 'opening', companyId })
|
||||
const result = await switchCompany(companyId)
|
||||
if (result.error) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
setSetup({ kind: 'idle' })
|
||||
return
|
||||
}
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
async function handleCreateFromTic(role: EnrichmentCompanyRole) {
|
||||
if (busy) return
|
||||
const orgNumber = role.companyRegistrationNumber.replace(/[\s-]/g, '')
|
||||
const mapped = mapEntityType(role.legalEntityType)
|
||||
if (!mapped) {
|
||||
// Entity type isn't supported end-to-end — route to manual wizard with
|
||||
// the org number pre-filled.
|
||||
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSetup({ kind: 'creating', orgNumber, step: 'lookup' })
|
||||
|
||||
let lookup: CompanyLookupResult | null = null
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
lookup = (json?.data as CompanyLookupResult | undefined) ?? null
|
||||
}
|
||||
} catch {
|
||||
// Network/parse failure — fall through to the lookup-missing branch below.
|
||||
}
|
||||
|
||||
// Without a lookup we don't know the company's VAT/F-skatt status.
|
||||
// Defaulting those to false for a momsregistrerat bolag would silently
|
||||
// create a company that issues invoices without moms — ML 17 kap violation.
|
||||
// Route to the manual wizard with the known fields pre-filled instead.
|
||||
if (!lookup) {
|
||||
toast({
|
||||
title: 'Kunde inte hämta företagsuppgifter',
|
||||
description: 'Fyll i resterande uppgifter manuellt.',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSetup({ kind: 'creating', orgNumber, step: 'provision' })
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createCompanyFromTicRole({
|
||||
teamId,
|
||||
orgNumber,
|
||||
legalName: role.legalName,
|
||||
legalEntityType: role.legalEntityType,
|
||||
lookup,
|
||||
})
|
||||
|
||||
if (result.error === 'lookup_missing') {
|
||||
// Extremely unlikely (we just verified lookup above) but if it happens,
|
||||
// the same fallback applies.
|
||||
setSetup({ kind: 'idle' })
|
||||
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.error || !result.companyId) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa företag',
|
||||
description: result.error ?? 'Försök igen eller lägg till manuellt.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.' })
|
||||
window.location.assign('/')
|
||||
})
|
||||
}
|
||||
|
||||
// Progress card while creating
|
||||
if (setup.kind === 'creating') {
|
||||
const lookupDone = setup.step === 'provision'
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
<header className="mb-10">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">Sätter upp ditt företag…</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Org.nr {setup.orgNumber}</p>
|
||||
<ul className="mt-3 space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
{lookupDone
|
||||
? <Check className="h-4 w-4 text-sage" />
|
||||
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
<span className={cn(!lookupDone && 'text-muted-foreground')}>
|
||||
Hämtar uppgifter från Bolagsverket
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
{lookupDone
|
||||
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
|
||||
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
|
||||
Skapar företag och kontoplan
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
<header className="mb-10">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">
|
||||
Välj ett företag att öppna eller lägg till ett nytt.
|
||||
</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">
|
||||
Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memberCompanies.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
|
||||
Dina företag i gnubok
|
||||
</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">
|
||||
Företag kopplade till ditt BankID
|
||||
</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
|
||||
|
||||
if (status === 'exists') {
|
||||
return (
|
||||
<li key={cleaned}>
|
||||
<div className="w-full rounded-lg border bg-muted/20 p-4 text-left opacity-70">
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
|
||||
Finns redan i gnubok
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||
Be en befintlig administratör att bjuda in dig.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
Sätts upp manuellt
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga företag hittades. Lägg till ditt första företag nedan.
|
||||
</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">
|
||||
eller
|
||||
</span>
|
||||
</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" />
|
||||
Lägg till företag manuellt
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user