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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
397a3b9bca
commit
04898c3178
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user