feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)

Founder test on a real company (2026-09-05): a supplier created from
"Webhallen Oktober · Dataskärmar till kontoret" kept that text as its
name, and the SCB facts fetched for the party were nowhere on the
supplier page.

- Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name,
  org number, VAT number, country, then the SCB facts under one source
  line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one
  action. The registry helpers move out of the dossier into
  RegistryFacts so the three surfaces share them.
- The enrich route makes the registry's legal name the displayed name
  of the party and of supplier and customer rows that still carry the
  party's old name; all-capitals names are set in title case
  (lib/parties/registry-name.ts). Names a person set stay.
- legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps
  its vouchers, so a rebuild attaches the new key instead of offering
  the same company again.
- GET /api/parties/[id] reports whether SCB is configured.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 13:43:34 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 397a3b9bca
commit 04898c3178
16 changed files with 424 additions and 61 deletions
+1 -52
View File
@@ -12,6 +12,7 @@ import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/c
import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register'
import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { registryFacts, registryLabel, registryValue } from './RegistryFacts'
import { regionName } from './SuggestionQueue'
import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format'
import type { MergeCandidate } from './MergeDialog'
@@ -20,58 +21,6 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">{children}</h2>
}
const REGISTRY_FIELDS = [
'f_tax',
'vat_registration',
'employer_registration',
'company_status',
'legal_form',
'bolagsverket_status',
'employees_band',
'turnover_band',
'industry',
'postal_address',
'seat',
'registered_at',
'active_since',
'active_until',
'phone',
'email',
'workplaces',
'trade_name',
] as const
/** Live registry facts, in the order the dossier shows them; the VAT number sits in its own row above. */
function registryFacts(facts: Dossier['facts']): Dossier['facts'] {
const scb = facts.filter((f) => f.source === 'registry_scb')
return REGISTRY_FIELDS.flatMap((field) => scb.filter((f) => f.field === field))
}
function registryLabel(t: (k: string) => string, field: string): string {
return REGISTRY_FIELDS.includes(field as (typeof REGISTRY_FIELDS)[number]) ? t(`fact_${field}`) : field
}
/** Coded facts show their label; address and seat compose; the rest print. */
function registryValue(value: unknown): React.ReactNode {
if (value === null || value === undefined) return '·'
if (typeof value === 'string' || typeof value === 'number') return String(value)
const v = value as Record<string, unknown>
if (typeof v.label === 'string') {
const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label
return v.warning ? <span className="text-warning">{label}</span> : label
}
if ('street' in v || 'city' in v) {
return [v.co, v.street, [v.postal_code, v.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')
}
if ('municipality' in v || 'municipality_code' in v) {
const parts = [v.municipality ?? v.municipality_code, v.county ?? v.county_code].filter(Boolean) as string[]
// "Stockholm, Stockholm": the county adds nothing when it repeats the municipality.
return parts.filter((x, i) => i === 0 || x !== parts[0]).join(', ')
}
if ('code' in v) return String(v.code)
return JSON.stringify(v)
}
function Row({ label, value, note }: { label: string; value: React.ReactNode; note?: React.ReactNode }) {
return (
<tr>
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { useToast } from '@/components/ui/use-toast'
import type { Dossier } from '@/lib/parties/register'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { formatDate, formatOrgNumber } from '@/lib/utils'
import { registryFacts, registryLabel, registryValue } from './RegistryFacts'
import { ScbPickerDialog } from './ScbPickerDialog'
import { regionName } from './SuggestionQueue'
/**
* "Företagsuppgifter" on a supplier or customer page: what the register
* knows about the company behind the row. Legal name, org number and VAT
* number first, then the facts SCB gave under one source line, and one
* action: fetch by org number when there is one, find the company in the
* register when there is not. The same facts the party dossier shows; this
* is where people look for them.
*/
export function PartyFactsSection({
partyId,
canWrite,
onChanged,
}: {
partyId: string
canWrite: boolean
/** The party was enriched or renamed; the owning row may have changed too. */
onChanged?: () => void
}) {
const t = useTranslations('parties')
const locale = useLocale()
const { toast } = useToast()
const [dossier, setDossier] = useState<Dossier | null>(null)
const [scbEnabled, setScbEnabled] = useState(false)
const [loaded, setLoaded] = useState(false)
const [busy, setBusy] = useState(false)
const [picker, setPicker] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch(`/api/parties/${partyId}`)
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Dossier | null; scbConfigured?: boolean }
setDossier(json.data)
setScbEnabled(!!json.scbConfigured)
} catch {
setDossier(null)
} finally {
setLoaded(true)
}
}, [partyId])
useEffect(() => {
void load()
}, [load])
async function fetchRegistry(orgNumber?: string) {
setBusy(true)
try {
const res = await fetch(`/api/parties/${partyId}/enrich`, {
method: 'POST',
headers: orgNumber ? { 'Content-Type': 'application/json' } : undefined,
body: orgNumber ? JSON.stringify({ orgNumber }) : undefined,
})
const json = (await res.json()) as {
data?: { found: boolean; orgNumber: string; inserted: number; superseded: number; refreshed: number; renamedTo?: string | null }
error?: { details?: { reason?: string; displayName?: string } }
}
if (!res.ok || !json.data) {
if (json.error?.details?.reason === 'org_number_taken') {
toast({ title: t('picker_taken_title', { name: json.error.details.displayName ?? '' }), description: t('picker_taken_description') })
return
}
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
return
}
setPicker(false)
if (!json.data.found) {
toast({ title: t('registry_not_found_title'), description: t('registry_not_found_description', { org: json.data.orgNumber }) })
return
}
toast({
title: json.data.renamedTo ? t('facts_renamed_title', { name: json.data.renamedTo }) : t('registry_fetched_title'),
description: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed }),
})
await load()
onChanged?.()
} catch {
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
} finally {
setBusy(false)
}
}
if (!loaded || !dossier) return null
const p = dossier.party
const registry = registryFacts(dossier.facts)
const fetchedAt = dossier.facts.find((f) => f.source === 'registry_scb')?.fetchedAt ?? null
const registryVat = dossier.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value
const countryRaw = dossier.facts.find((f) => f.field === 'country')?.value
const country = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null
const canFetch = scbEnabled && canWrite && isLegalPersonOrgNumber(p.orgNumber)
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && (!country || country === 'SE')
return (
<>
<DetailSection
kicker={t('facts_section_title')}
aside={
canFetch ? (
<Button type="button" size="sm" variant="outline" onClick={() => void fetchRegistry()} disabled={busy}>
{busy ? t('fetching_registry') : registry.length > 0 ? t('facts_refresh') : t('fetch_registry')}
</Button>
) : canFind ? (
<Button type="button" size="sm" variant="outline" onClick={() => setPicker(true)} disabled={busy}>
{t('pick_registry')}
</Button>
) : undefined
}
>
<DefRow label={t('fact_legal_name')}>{p.legalName ?? <DefEmpty />}</DefRow>
<DefRow label={t('fact_org')}>{p.orgNumber ? <span className="tabular-nums">{formatOrgNumber(p.orgNumber)}</span> : <DefEmpty />}</DefRow>
<DefRow label={t('fact_vat')}>{p.vatNumber ?? (registryVat ? String(registryVat) : <DefEmpty />)}</DefRow>
{country ? <DefRow label={t('fact_country')}>{regionName(country, locale)}</DefRow> : null}
{registry.map((f) => (
<DefRow key={f.id} label={f.field === 'postal_address' && !(f.value as { street?: string | null })?.street ? t('fact_postal_code_city') : registryLabel(t, f.field)}>
{registryValue(f.value)}
</DefRow>
))}
<p className="pt-2 text-xs text-muted-foreground">
{fetchedAt
? t('registry_group', { date: formatDate(fetchedAt) })
: country && country !== 'SE'
? t('facts_foreign', { country: regionName(country, locale) })
: p.orgNumber
? t('facts_none_org')
: t('facts_none')}
</p>
</DetailSection>
{picker ? (
<ScbPickerDialog
open
onOpenChange={(open) => (!open ? setPicker(false) : undefined)}
partyId={partyId}
partyName={p.legalName ?? p.displayName}
busy={busy}
onPick={async (c: ScbCandidate) => {
await fetchRegistry(c.orgNumber)
}}
/>
) : null}
</>
)
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Registry facts as the dossier and the supplier and customer pages show
* them: which SCB fields, in what order, with what label, and how a coded
* or composite value prints. One module so the three surfaces cannot drift.
*/
import type { ReactNode } from 'react'
import type { Dossier } from '@/lib/parties/register'
export const REGISTRY_FIELDS = [
'f_tax',
'vat_registration',
'employer_registration',
'company_status',
'legal_form',
'bolagsverket_status',
'employees_band',
'turnover_band',
'industry',
'postal_address',
'seat',
'registered_at',
'active_since',
'active_until',
'phone',
'email',
'workplaces',
'trade_name',
] as const
/** Live registry facts, in the order the dossier shows them; the VAT number sits in its own row above. */
export function registryFacts(facts: Dossier['facts']): Dossier['facts'] {
const scb = facts.filter((f) => f.source === 'registry_scb')
return REGISTRY_FIELDS.flatMap((field) => scb.filter((f) => f.field === field))
}
export function registryLabel(t: (k: string) => string, field: string): string {
return REGISTRY_FIELDS.includes(field as (typeof REGISTRY_FIELDS)[number]) ? t(`fact_${field}`) : field
}
/** Coded facts show their label; address and seat compose; the rest print. */
export function registryValue(value: unknown): ReactNode {
if (value === null || value === undefined) return '·'
if (typeof value === 'string' || typeof value === 'number') return String(value)
const v = value as Record<string, unknown>
if (typeof v.label === 'string') {
const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label
return v.warning ? <span className="text-warning">{label}</span> : label
}
if ('street' in v || 'city' in v) {
return [v.co, v.street, [v.postal_code, v.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')
}
if ('municipality' in v || 'municipality_code' in v) {
const parts = [v.municipality ?? v.municipality_code, v.county ?? v.county_code].filter(Boolean) as string[]
// "Stockholm, Stockholm": the county adds nothing when it repeats the municipality.
return parts.filter((x, i) => i === 0 || x !== parts[0]).join(', ')
}
if ('code' in v) return String(v.code)
return JSON.stringify(v)
}