'use client' import { useEffect, useState } from 'react' import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number' import { useTranslations } from 'next-intl' import { MoreHorizontal } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { VTD_CLASS, VTH_CLASS } from '@/components/ui/dry-table' import { Skeleton } from '@/components/ui/skeleton' import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/components/ui/slide-over' import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register' import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils' import { AccountNub } from './AccountNub' import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format' import type { MergeCandidate } from './MergeDialog' function SectionTitle({ children }: { children: React.ReactNode }) { return

{children}

} 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 if (typeof v.label === 'string') { const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label return v.warning ? {label} : 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 ( {label}
{value}
{note ?
{note}
: null} ) } /** * Moment 3: the dossier. Money and booking knowledge come from the ledger; * "Vad Accounted vet" lists every fact with its source; promotion and * merge are one action each and always confirm up front. */ export function PartyDossier({ partyId, period, canWrite, busy, onClose, onPromote, onDismiss, onMerge, onFetchRegistry, onPickRegistry, fetching = false, reloadKey, }: { partyId: string | null period: RegisterPeriod canWrite: boolean busy: boolean onClose: () => void onPromote: (id: string, roles: PartyRole[]) => void onDismiss: (id: string) => void onMerge: (subject: MergeCandidate, suggested: MergeCandidate[]) => void /** Fetch registry facts from SCB for this party; undefined hides the item. */ onFetchRegistry?: (id: string) => void /** Open the SCB picker for a party without an org number. */ onPickRegistry?: (id: string, name: string) => void fetching?: boolean reloadKey: number }) { const t = useTranslations('parties') // { partyId, reloadKey } stamps the loaded dossier, so "loading" and // "failed" are derived instead of set from inside the effect. const [loaded, setLoaded] = useState<{ partyId: string; reloadKey: number; dossier: Dossier | null } | null>(null) const current = loaded && loaded.partyId === partyId && loaded.reloadKey === reloadKey ? loaded : null const dossier = partyId ? (current?.dossier ?? (loaded?.partyId === partyId ? loaded.dossier : null)) : null const loading = Boolean(partyId) && current === null const failed = Boolean(partyId) && current !== null && current.dossier === null useEffect(() => { if (!partyId) return let cancelled = false const key = reloadKey fetch(`/api/parties/${partyId}`) .then(async (res) => { if (!res.ok) throw new Error(String(res.status)) const json = (await res.json()) as { data: Dossier } if (!cancelled) setLoaded({ partyId, reloadKey: key, dossier: json.data }) }) .catch(() => { if (!cancelled) setLoaded({ partyId, reloadKey: key, dossier: null }) }) return () => { cancelled = true } }, [partyId, reloadKey]) const p = dossier?.party const stats = p?.stats ?? null const suggested = p?.status === 'suggested' const kicker = p ? (suggested ? t('dossier_kicker_suggested') : roleLabel(t, p.roles)) : '' const subtitle = stats ? [ t('dossier_seen', { count: stats.occurrences }), rhythmLabel(t, stats.rhythm), stats.lastSeen ? t('dossier_last', { date: formatDate(stats.lastSeen) }) : '', stats.variants.length > 1 ? t('dossier_variants', { count: stats.variants.length }) : '', ] .filter(Boolean) .join(' · ') : '' const legalName = p?.legalName ?? (dossier?.facts.find((f) => f.field === 'legal_name')?.value as string | undefined) ?? null const orgFact = dossier?.facts.find((f) => f.field === 'org_number') const docsFor = (field: string) => { const f = dossier?.facts.find((x) => x.field === field) if (!f) return '' if (f.source === 'registry_scb') return t('source_scb') const n = (f.reference as { docs?: number } | null)?.docs return n ? t('fact_from_documents', { count: n }) : f.source === 'ledger' ? t('fact_from_ledger') : f.source === 'user' ? t('fact_from_user') : '' } const dominant = dossier?.facts.find((f) => f.field === 'dominant_account')?.value as { account?: string; count?: number } | undefined const registryVat = dossier?.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value // One primary action: the role the ledger suggests and the party does not // have yet. The other role and everything else live behind the menu. const missingRoles: PartyRole[] = p ? (['supplier', 'customer'] as PartyRole[]).filter((r) => (r === 'supplier' ? !p.roles.supplierId : !p.roles.customerId)) : [] const primaryRole: PartyRole | null = p ? (missingRoles.find((r) => p.defaultRoles.includes(r)) ?? missingRoles[0] ?? null) : null const secondaryRole: PartyRole | null = missingRoles.find((r) => r !== primaryRole) ?? null const scbFetchedAt = dossier?.facts.filter((f) => f.source === 'registry_scb').map((f) => f.fetchedAt ?? f.recordedAt).sort().at(-1) ?? null return ( (!open ? onClose() : undefined)}> {loading && !dossier ? (
) : failed || !dossier || !p ? (

{t('load_failed')}

) : (
{subtitle ?

{subtitle}

: null}
{primaryRole ? ( ) : null} {secondaryRole ? ( onPromote(p.id, [secondaryRole])}> {secondaryRole === 'supplier' ? t('promote_supplier') : t('promote_customer')} ) : null} {onFetchRegistry && isLegalPersonOrgNumber(p.orgNumber) ? ( onFetchRegistry(p.id)} disabled={fetching}> {fetching ? t('fetching_registry') : t('fetch_registry')} ) : onPickRegistry && !p.orgNumber && p.kind !== 'person' ? ( onPickRegistry(p.id, p.legalName ?? p.displayName)} disabled={fetching}> {t('pick_registry')} ) : null} onMerge( { id: p.id, displayName: p.displayName, orgNumber: p.orgNumber, status: p.status }, dossier.similar.map((s) => ({ id: s.id, displayName: s.displayName, orgNumber: s.orgNumber, status: s.status })), ) } > {t('merge')} {suggested ? onDismiss(p.id)}>{t('dismiss')} : null}
{t('section_money')} {stats?.expenseSek || !stats?.revenueSek ? ( ) : null} {stats?.revenueSek ? ( ) : null} {stats?.firstSeen ? : null} {stats?.lastSeen ? : null}
{t('section_bookkeeping')} } note={ stats?.dominantAccount && stats.occurrences ? t('bk_account_share', { count: Math.round((stats.dominantShare ?? 0) * (stats.occurrences + 2) - 1) || dominant?.count || 0, total: stats.occurrences, }) : undefined } />
{t('section_facts')} 1 ? stats.variants.slice(0, 3).join(', ') : undefined} /> {t('fact_missing')}} note={legalName ? docsFor('legal_name') : undefined} /> {t('fact_missing')}} note={p.orgNumber && orgFact ? docsFor('org_number') : undefined} /> {t('fact_missing')})} note={p.vatNumber ? docsFor('vat_number') || undefined : registryVat ? docsFor('vat_number') : undefined} /> {dossier.identities.map((i) => ( ))} {scbFetchedAt && registryFacts(dossier.facts).length > 0 ? ( ) : null} {registryFacts(dossier.facts).map((f) => ( ))}
{t('registry_group', { date: formatDate(scbFetchedAt) })}
{t('section_vouchers')} {dossier.vouchers.length === 0 ? (

{t('vouchers_none')}

) : ( {dossier.vouchers.map((v) => ( ))}
{formatDate(v.entryDate)} {v.voucher ? {v.voucher} · : null} {v.description} {v.amount ? formatCurrency(v.amount) : ''}
)}
{dossier.decisions.length > 0 ? (
{t('section_history')} {dossier.decisions.map((d) => ( ))}
{formatDate(d.createdAt)} {['confirm', 'dismiss', 'merge', 'split', 'undo'].includes(d.kind) ? t(`decision_${d.kind}`) : d.kind} {d.note ? · {d.note} : null}
) : null}
)}
) }