d0640e0968
* fix(settings): stop registry bank data masquerading as a setting, offer IBAN from bank connection User report: the Foretag tab shows a bankgiro from the Bolagsverket snapshot, which reads as a configured setting while the field payment files and invoices actually use (Fakturering) was empty. - Note on the Foretag Bankuppgifter row: data is from Bolagsverket; the editable fields live under Installningar -> Fakturering. - One-click IBAN prefill on the SEK payment account, sourced from the connected bank accounts (cash_accounts.iban). Deterministic: only offered when every connected account agrees on a single IBAN. - Delete dead BankDetailsForm.tsx (unmounted since the settings restructure); its bank fields are edited via InvoicePaymentAccountsSettings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): only suggest IBAN from enabled, still-connected SEK accounts Skeptic refutation on the initial PR state: cash_accounts keeps rows after disconnect (bank_connection_id nulled) and the connect picker mirrors deselected accounts with enabled=false, so an unfiltered read could offer a closed or third-party IBAN as the invoice payee / pain.001 sender. Filter on enabled=true, currency=SEK and a non-null bank_connection_id, matching the enable-banking session-sharing invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { Badge } from '@/components/ui/badge'
|
|
import { formatDate } from '@/lib/utils'
|
|
import { SettingsRow, SettingsRowNote } from '@/components/settings/SettingsRows'
|
|
|
|
// Read-only "Bolagsuppgifter" view of the cached TIC company profile
|
|
// (companies.tic_snapshot), rendered as flat settings rows inside the
|
|
// section's SettingsGroup. Lives in core: reads the snapshot as plain
|
|
// JSON rather than importing the TIC extension's types, so the
|
|
// core-build CI boundary (no core → @/extensions/) stays intact.
|
|
//
|
|
// The snapshot is written by the TIC /profile endpoint; shape mirrors
|
|
// TICCompanyProfile. We type only the fields we render and treat
|
|
// everything as optional/defensive since older snapshots predate some
|
|
// sections.
|
|
|
|
interface SnapshotShape {
|
|
companyName?: string | null
|
|
orgNumber?: string | null
|
|
legalEntityType?: string | null
|
|
address?: { street?: string | null; postalCode?: string | null; city?: string | null } | null
|
|
registration?: { fTax?: boolean; vat?: boolean; payroll?: boolean } | null
|
|
sniCodes?: { code: string; name: string }[] | null
|
|
bankAccounts?: { type: string; accountNumber: string; bic?: string | null }[] | null
|
|
purpose?: string | null
|
|
employeeRange?: string | null
|
|
financials?: {
|
|
periodStart?: number
|
|
periodEnd?: number
|
|
netSalesK?: number | null
|
|
operatingProfitK?: number | null
|
|
} | null
|
|
statuses?: {
|
|
code?: string | null
|
|
description?: string | null
|
|
color?: 'red' | 'yellow' | 'green' | 'neutral' | string | null
|
|
statusDate?: string | null
|
|
isCeased?: boolean | null
|
|
}[] | null
|
|
fiscalYear?: { startMonthDay?: string | null; endMonthDay?: string | null } | null
|
|
signatory?: { description: string }[] | null
|
|
board?: {
|
|
numberOfBoardMembers?: number | null
|
|
numberOfDeputyBoardMembers?: number | null
|
|
} | null
|
|
representatives?: {
|
|
name?: string | null
|
|
positionType?: string | null
|
|
positionStart?: string | null
|
|
}[] | null
|
|
}
|
|
|
|
// Clean Bolagsverket signatory text: the source carries ">" list markers
|
|
// and collapses several rules onto one line. Strip the markers, normalise
|
|
// whitespace, and split run-on "Firman tecknas …" clauses onto their own
|
|
// lines so each rule reads as a sentence.
|
|
function cleanSignatory(raw: string | null | undefined): string[] {
|
|
// The snapshot is unvalidated registry JSON: `description` is declared
|
|
// required inside an interface whose every other field is optional, so a
|
|
// signatory row without one would throw here and blank the settings panel.
|
|
if (!raw) return []
|
|
const normalised = raw
|
|
.replace(/>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
// Each firmateckningsregel starts with "Firman tecknas". Split on the
|
|
// boundary before subsequent occurrences so they stack vertically.
|
|
return normalised
|
|
.split(/(?=Firman tecknas)/g)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0)
|
|
}
|
|
|
|
export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | null }) {
|
|
if (!snapshot) {
|
|
// Dynamic status (nothing fetched yet): stays visible as a quiet line.
|
|
return (
|
|
<p className="border-b border-border px-1 py-3 text-sm text-muted-foreground">
|
|
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
|
|
från Bolagsverket via organisationsnumret.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
const entityLabel =
|
|
snapshot.legalEntityType === 'AB'
|
|
? 'Aktiebolag'
|
|
: snapshot.legalEntityType === 'EF'
|
|
? 'Enskild firma'
|
|
: snapshot.legalEntityType ?? null
|
|
|
|
const reg = snapshot.registration
|
|
const regBadges = [
|
|
reg?.fTax ? 'F-skatt' : null,
|
|
reg?.vat ? 'Moms' : null,
|
|
reg?.payroll ? 'Arbetsgivare' : null,
|
|
].filter(Boolean) as string[]
|
|
|
|
const fyLabel =
|
|
snapshot.fiscalYear?.startMonthDay && snapshot.fiscalYear?.endMonthDay
|
|
? `${snapshot.fiscalYear.startMonthDay} till ${snapshot.fiscalYear.endMonthDay}`
|
|
: null
|
|
|
|
// Only show dated status entries: Bolagsverket emits informational
|
|
// flags like "Har aldrig varit verksam" with no date that read as
|
|
// noise next to the real ones. Plain text, no colour: per the
|
|
// design system, semantic colour is data-only and never chrome.
|
|
const datedStatuses = (snapshot.statuses ?? []).filter((s) => s.statusDate)
|
|
|
|
// Flatten every signatory row, clean ">" markers, split run-on
|
|
// clauses, and dedupe: the source repeats "Firman tecknas av
|
|
// styrelsen" across rows.
|
|
const signatoryRules = Array.from(
|
|
new Set(
|
|
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
|
|
),
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<SettingsRow label="Företag">
|
|
<span className="text-foreground">{snapshot.companyName ?? 'Okänt företag'}</span>
|
|
{(snapshot.orgNumber || entityLabel) && (
|
|
<SettingsRowNote className="tabular-nums">
|
|
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
|
|
</SettingsRowNote>
|
|
)}
|
|
</SettingsRow>
|
|
|
|
{snapshot.address && (
|
|
<SettingsRow label="Adress">
|
|
<span className="text-muted-foreground">
|
|
{[
|
|
snapshot.address.street,
|
|
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ')}
|
|
</span>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{regBadges.length > 0 && (
|
|
<SettingsRow label="Registrerat för">
|
|
{regBadges.map((b) => (
|
|
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
|
|
))}
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
|
|
<SettingsRow label="SNI-koder" align="baseline">
|
|
<ul className="w-full space-y-1">
|
|
{snapshot.sniCodes.map((s) => (
|
|
<li key={s.code} className="text-sm tabular-nums">
|
|
<span className="text-foreground">{s.code}</span>{' '}
|
|
<span className="text-muted-foreground">{s.name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
|
|
<SettingsRow label="Bankuppgifter" align="baseline">
|
|
{/* Registry read-out, not a setting: without the note this row makes
|
|
the bankgiro look configured while payment files and invoices
|
|
read the editable fields under Fakturering. */}
|
|
<div className="w-full space-y-1">
|
|
<ul className="space-y-1">
|
|
{snapshot.bankAccounts.map((b, i) => (
|
|
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
|
|
<span className="text-muted-foreground">{b.type}:</span>{' '}
|
|
<span className="text-foreground">{b.accountNumber}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<SettingsRowNote className="block">
|
|
Enligt Bolagsverket. Fakturor och betalfiler använder bankuppgifterna
|
|
under Inställningar → Fakturering.
|
|
</SettingsRowNote>
|
|
</div>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{snapshot.purpose && (
|
|
<SettingsRow label="Verksamhet" align="baseline">
|
|
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
<SettingsRow label="Anställda">
|
|
<span className="text-muted-foreground">
|
|
{snapshot.employeeRange ?? 'Inga anställda'}
|
|
</span>
|
|
</SettingsRow>
|
|
|
|
<SettingsRow label="Senaste bokslut">
|
|
{snapshot.financials ? (
|
|
<>
|
|
<span>
|
|
<span className="text-muted-foreground">Nettoomsättning </span>
|
|
<span className="tabular-nums">
|
|
{snapshot.financials.netSalesK != null
|
|
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
|
|
: '-'}
|
|
</span>
|
|
</span>
|
|
<span>
|
|
<span className="text-muted-foreground">Rörelseresultat </span>
|
|
<span className="tabular-nums">
|
|
{snapshot.financials.operatingProfitK != null
|
|
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
|
|
: '-'}
|
|
</span>
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Inga finansiella uppgifter tillgängliga.</span>
|
|
)}
|
|
</SettingsRow>
|
|
|
|
{datedStatuses.length > 0 && (
|
|
<SettingsRow label="Status" align="baseline">
|
|
<dl className="w-full space-y-1">
|
|
{datedStatuses.map((s, i) => (
|
|
<div key={`${s.code}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
|
<dt className={s.isCeased ? 'text-destructive' : 'text-foreground'}>
|
|
{s.description ?? s.code ?? '-'}
|
|
</dt>
|
|
<dd className="text-xs text-muted-foreground tabular-nums">
|
|
{formatDate(s.statusDate!)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{fyLabel && (
|
|
<SettingsRow label="Räkenskapsår">
|
|
<span className="tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</span>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{signatoryRules.length > 0 && (
|
|
<SettingsRow label="Firmateckning" align="baseline">
|
|
<ul className="w-full space-y-1">
|
|
{signatoryRules.map((rule, i) => (
|
|
<li key={i} className="text-sm leading-6 text-muted-foreground">
|
|
{rule}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</SettingsRow>
|
|
)}
|
|
|
|
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
|
|
<SettingsRow label="Företrädare" align="baseline">
|
|
<div className="w-full">
|
|
{snapshot.board && (
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
{[
|
|
snapshot.board.numberOfBoardMembers != null
|
|
? `${snapshot.board.numberOfBoardMembers} styrelseledamot/-ledamöter`
|
|
: null,
|
|
snapshot.board.numberOfDeputyBoardMembers != null
|
|
? `${snapshot.board.numberOfDeputyBoardMembers} suppleant(er)`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</p>
|
|
)}
|
|
<ul className="space-y-1">
|
|
{snapshot.representatives.map((r, i) => (
|
|
<li key={`${r.name}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
|
<span className="text-foreground">{r.name ?? '-'}</span>
|
|
<span className="text-right text-xs text-muted-foreground">
|
|
{[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</SettingsRow>
|
|
)}
|
|
</>
|
|
)
|
|
}
|