feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)
* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party Founder feedback on the first Företagsuppgifter (2026-09-05): the org number twice, the VAT number twice, the legal name repeating the heading, and Kontaktuppgifter showing dashes while the block above had the phone, e-mail and address from SCB. - After a fetch the register's contact details land on the supplier and customer rows that point at the party: an empty field, or one still carrying what the register said last time, takes the new value; a value a person typed stays. Shown as "från SCB" on the row (by equality with the registry fact, no source column). - Företagsuppgifter becomes one status line (legal form, active or not, registrations, a Bolagsverket warning when there is one), industry, seat with registration date, and size. Identity stays in the header (org number now formatted) and Kontaktuppgifter. The legal name shows only when it differs from the row's name. - lib/parties/registry-summary.ts reads the coded SCB facts once for the page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape. - v1: party_id on supplier and customer list rows and detail; ?expand=party on detail embeds identity, the register summary, what the ledger has seen and payment identities. MCP: party_id on gnubok_list_suppliers/customers rows and gnubok_get_party (by party, supplier or customer id). Read-only; the parties resource follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): regenerate the API skill for the party expansion; tighten the get_party description Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- 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
Claude Fable 5.1
Jakob Wennberg
parent
f33628f005
commit
0ad83b8d71
@@ -1,63 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { 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 { DetailSection, DefRow } from '@/components/ui/detail-section'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { Dossier } from '@/lib/parties/register'
|
||||
import type { RegistrySummary } from '@/lib/parties/registry-summary'
|
||||
import { sameName } from '@/lib/parties/registry-name'
|
||||
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 { formatDate } from '@/lib/utils'
|
||||
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.
|
||||
* "Företagsuppgifter" on a supplier or customer page: what only the register
|
||||
* knows, in a few lines. Identity (org number, VAT number) lives in the
|
||||
* page header and the contact section, and contact details the register
|
||||
* gave land on the row itself, so this block does not repeat them. It
|
||||
* carries the status line (legal form, active or not, registrations, and a
|
||||
* Bolagsverket warning when there is one), industry, seat, size, and one
|
||||
* action: fetch by org number, or find the company in the register.
|
||||
*/
|
||||
export function PartyFactsSection({
|
||||
partyId,
|
||||
rowName,
|
||||
canWrite,
|
||||
dossier,
|
||||
registry,
|
||||
scbEnabled,
|
||||
onChanged,
|
||||
}: {
|
||||
partyId: string
|
||||
/** The supplier's or customer's own name, so the legal name shows only when it differs. */
|
||||
rowName: string
|
||||
canWrite: boolean
|
||||
/** The party was enriched or renamed; the owning row may have changed too. */
|
||||
onChanged?: () => void
|
||||
dossier: Dossier
|
||||
registry: RegistrySummary | null
|
||||
scbEnabled: boolean
|
||||
/** The party was fetched, renamed or filled in; the owning row may have changed too. */
|
||||
onChanged: () => Promise<void> | 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 {
|
||||
@@ -67,7 +57,7 @@ export function PartyFactsSection({
|
||||
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 }
|
||||
data?: { found: boolean; orgNumber: string; inserted: number; superseded: number; refreshed: number; renamedTo?: string | null; filled?: Record<string, string[]> }
|
||||
error?: { details?: { reason?: string; displayName?: string } }
|
||||
}
|
||||
if (!res.ok || !json.data) {
|
||||
@@ -83,12 +73,15 @@ export function PartyFactsSection({
|
||||
toast({ title: t('registry_not_found_title'), description: t('registry_not_found_description', { org: json.data.orgNumber }) })
|
||||
return
|
||||
}
|
||||
const filledFields = [...new Set(Object.values(json.data.filled ?? {}).flat())]
|
||||
const filledText = filledFields.length
|
||||
? t('facts_filled_description', { fields: filledFields.map((f) => fieldLabel(t, f)).join(', ') })
|
||||
: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed })
|
||||
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 }),
|
||||
title: json.data.renamedTo ? t('facts_renamed_title', { name: json.data.renamedTo }) : filledFields.length ? t('facts_filled_title') : t('registry_fetched_title'),
|
||||
description: filledText,
|
||||
})
|
||||
await load()
|
||||
onChanged?.()
|
||||
await onChanged()
|
||||
} catch {
|
||||
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
|
||||
} finally {
|
||||
@@ -96,15 +89,27 @@ export function PartyFactsSection({
|
||||
}
|
||||
}
|
||||
|
||||
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 country = p.country
|
||||
const foreign = !!country && country !== 'SE'
|
||||
const canFetch = scbEnabled && canWrite && isLegalPersonOrgNumber(p.orgNumber)
|
||||
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && (!country || country === 'SE')
|
||||
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && !foreign
|
||||
const legalDiffers = !!p.legalName && !sameName(p.legalName, rowName)
|
||||
const registrations = registry
|
||||
? (
|
||||
[
|
||||
[registry.registrations.f_tax, t('facts_reg_f_tax')],
|
||||
[registry.registrations.vat, t('facts_reg_vat')],
|
||||
[registry.registrations.employer, t('facts_reg_employer')],
|
||||
] as const
|
||||
)
|
||||
.filter(([on]) => on === true)
|
||||
.map(([, label]) => label)
|
||||
: []
|
||||
const statusLine = registry
|
||||
? [registry.legal_form, registry.status?.label].filter(Boolean).join(' · ')
|
||||
: null
|
||||
const attention = !!registry && (registry.warning !== null || registry.status?.active === false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,7 +118,7 @@ export function PartyFactsSection({
|
||||
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')}
|
||||
{busy ? t('fetching_registry') : registry ? t('facts_refresh') : t('fetch_registry')}
|
||||
</Button>
|
||||
) : canFind ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setPicker(true)} disabled={busy}>
|
||||
@@ -122,20 +127,46 @@ export function PartyFactsSection({
|
||||
) : 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>
|
||||
))}
|
||||
{legalDiffers ? <DefRow label={t('fact_legal_name')}>{p.legalName}</DefRow> : null}
|
||||
{country && (foreign || !registry) ? <DefRow label={t('fact_country')}>{regionName(country, locale)}</DefRow> : null}
|
||||
{registry ? (
|
||||
<>
|
||||
<DefRow label={t('facts_status')}>
|
||||
<span className={attention ? 'text-warning' : undefined}>
|
||||
{registry.warning ? [statusLine, registry.warning].filter(Boolean).join(' · ') : statusLine}
|
||||
</span>
|
||||
{registrations.length > 0 ? (
|
||||
<span className="block text-xs text-muted-foreground">{t('facts_registered_for', { items: registrations.join(', ') })}</span>
|
||||
) : registry.registrations.f_tax === false && registry.registrations.vat === false ? (
|
||||
<span className="block text-xs text-warning">{t('facts_not_registered')}</span>
|
||||
) : null}
|
||||
</DefRow>
|
||||
{registry.industry ? <DefRow label={t('facts_industry')}>{registry.industry.label}</DefRow> : null}
|
||||
{registry.seat || registry.registered_at ? (
|
||||
<DefRow label={t('facts_seat')}>
|
||||
{registry.seat && registry.registered_at
|
||||
? t('facts_seat_registered', { seat: registry.seat, date: formatDate(registry.registered_at) })
|
||||
: (registry.seat ?? formatDate(registry.registered_at as string))}
|
||||
</DefRow>
|
||||
) : null}
|
||||
{registry.employees_band || registry.turnover || registry.workplaces ? (
|
||||
<DefRow label={t('facts_size')}>
|
||||
{[
|
||||
registry.employees_band,
|
||||
registry.turnover ? (registry.turnover.year ? `${registry.turnover.band} (${registry.turnover.year})` : registry.turnover.band) : null,
|
||||
registry.workplaces && registry.workplaces > 1 ? t('facts_workplaces', { count: registry.workplaces }) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</DefRow>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<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) })
|
||||
{registry?.fetched_at
|
||||
? t('registry_group', { date: formatDate(registry.fetched_at) })
|
||||
: foreign
|
||||
? t('facts_foreign', { country: regionName(country as string, locale) })
|
||||
: p.orgNumber
|
||||
? t('facts_none_org')
|
||||
: t('facts_none')}
|
||||
@@ -157,3 +188,19 @@ export function PartyFactsSection({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function fieldLabel(t: (k: string) => string, field: string): string {
|
||||
switch (field) {
|
||||
case 'email':
|
||||
return t('fact_email')
|
||||
case 'phone':
|
||||
return t('fact_phone')
|
||||
case 'address_line1':
|
||||
case 'address_line2':
|
||||
case 'postal_code':
|
||||
case 'city':
|
||||
return t('fact_postal_address')
|
||||
default:
|
||||
return field
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Dossier } from '@/lib/parties/register'
|
||||
import { registrySummary, type RegistrySummary } from '@/lib/parties/registry-summary'
|
||||
|
||||
/**
|
||||
* The party behind a supplier or customer row, for the row's own page. One
|
||||
* fetch feeds the Företagsuppgifter block and the "från SCB" notes on the
|
||||
* contact rows, so the two never disagree about what the register said.
|
||||
*/
|
||||
export function usePartyDossier(partyId: string | null | undefined): {
|
||||
dossier: Dossier | null
|
||||
registry: RegistrySummary | null
|
||||
scbEnabled: boolean
|
||||
loaded: boolean
|
||||
reload: () => Promise<void>
|
||||
} {
|
||||
const [dossier, setDossier] = useState<Dossier | null>(null)
|
||||
const [scbEnabled, setScbEnabled] = useState(false)
|
||||
const [loaded, setLoaded] = useState(!partyId)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!partyId) return
|
||||
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 reload()
|
||||
}, [reload])
|
||||
|
||||
const registry = useMemo(() => (dossier ? registrySummary(dossier.facts) : null), [dossier])
|
||||
return { dossier, registry, scbEnabled, loaded, reload }
|
||||
}
|
||||
Reference in New Issue
Block a user