'use client' import { useEffect, useState } from 'react' import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number' import { useLocale, 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 { registryFacts, registryLabel, registryValue } from './RegistryFacts' import { regionName } from './SuggestionQueue' import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format' import type { MergeCandidate } from './MergeDialog' function SectionTitle({ children }: { children: React.ReactNode }) { return

{children}

} 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') const locale = useLocale() // { 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 const countryRaw = dossier?.facts.find((f) => f.field === 'country')?.value const countryCode = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null // 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} /> {countryCode ? : null} {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}
)}
) }