Files
accounted/components/settings/CompanyProfileView.tsx
T
Jakob Wennberg da859d7236 refactor(ui): design-system consistency pass over dense pages + i18n de-bloat (#961)
* refactor(ui): normalize dense pages to the locked design system

Sweep of the info-dense surfaces against .claude/rules/design.md; no
behavior changes, classNames and primitive adoption only.

- Replace hand-rolled h1s with PageHeader (import, suppliers, kpi,
  salary/employees, skattekonto, settings layout) and drop the one
  double title (SalarySettingsContent under the settings h1)
- Replace hand-rolled empty states with EmptyState (skattekonto,
  banking/api-keys/oauth/counterparty settings) and hand-rolled
  pulse divs with Skeleton (deadlines, report view loaders)
- Remove semantic colors used as chrome: amber/emerald banners in
  AGIPanel and SkatteverketPanel, success/warning tints in
  kassaflodesanalys, arsredovisning and import become neutral
  surfaces with the tint kept on the icon only
- Full-opacity borders everywhere (border-border/30-60,
  border-destructive/20-40, border-foreground/30, text-destructive/80)
- Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to
  scale values); KPI metric tiles p-6 to p-4 per the tile rule
- Remove the mobile Select that duplicated the invoices status Tabs
  (TabsList already scrolls horizontally); single Tabs now serves
  both breakpoints
- supplier-invoices: shared formatCurrency instead of a local
  formatAmount helper; skattekonto: formatDate/formatDateLong/
  formatDateTime instead of raw dates and toLocaleString
- arsredovisning flerarsoversikt converted to the Table primitive
  with right-aligned tabular-nums cells
- Settings: CardTitle text-base on section cards, one heading idiom
  in AccountSettingsContent, h3 to h2 in CompanyProfileView

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(i18n): trim text bloat and fix an untranslated sv string

- Fix invoice_credit.create_failed_fallback: sv catalog carried the
  English "Failed to create credit note"; now "Kunde inte skapa
  kreditfaktura". Translate new_user_checklist.step3_title in en
- Drop descriptions that paraphrase their own title (design.md
  forbidden pattern): invoice_detail.credited_description,
  invoice_credit.original_card_description, invoice_editor
  customer/notes card descriptions (keys deleted from both
  catalogs, zero remaining usages); the transaction booking
  DialogDescription becomes sr-only so screen readers keep it
- Trim redundant sentences from settings_salary.info_payroll_scope,
  settings_backup.intro, ext_cloud_backup_long_description,
  settings.name_description, salary_payments.open_payments_note and
  shorten invoice_credit.reason_card_description; statutory BFL/tax
  prose untouched
- Normalize toast punctuation (dimensions/self_billing
  created_description lose the trailing period like their siblings)
- common.delete "Radera" to "Ta bort" (zero live call sites; Radera
  stays reserved for irreversible account/company deletion)

Catalogs verified key-identical (4795 keys each) and JSON-parseable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:56:30 +02:00

309 lines
11 KiB
TypeScript

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatDate, formatDateLong } from '@/lib/utils'
// Read-only "Bolagsuppgifter" view of the cached TIC company profile
// (companies.tic_snapshot). 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): string[] {
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)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
{title}
</h2>
{children}
</section>
)
}
export function CompanyProfileView({
snapshot,
fetchedAt,
}: {
snapshot: SnapshotShape | null
fetchedAt: string | null
}) {
if (!snapshot) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Inga företagsuppgifter hämtade ännu. Uppgifterna hämtas automatiskt
från Bolagsverket via organisationsnumret.
</p>
</CardContent>
</Card>
)
}
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
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bolagsuppgifter</CardTitle>
{fetchedAt && (
<p className="text-xs text-muted-foreground">
Uppdaterad {formatDateLong(fetchedAt)}
</p>
)}
</CardHeader>
<CardContent className="space-y-8">
{/* Identity */}
<div>
<p className="font-display text-xl tracking-tight">
{snapshot.companyName ?? 'Okänt företag'}
</p>
<p className="text-sm text-muted-foreground tabular-nums">
{[snapshot.orgNumber, entityLabel].filter(Boolean).join(' · ')}
</p>
{snapshot.address && (
<p className="text-sm text-muted-foreground mt-2">
{[
snapshot.address.street,
[snapshot.address.postalCode, snapshot.address.city].filter(Boolean).join(' '),
]
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
{regBadges.length > 0 && (
<Section title="Registrerat för">
<div className="flex flex-wrap gap-2">
{regBadges.map((b) => (
<Badge key={b} variant="secondary" className="font-normal">{b}</Badge>
))}
</div>
</Section>
)}
{Array.isArray(snapshot.sniCodes) && snapshot.sniCodes.length > 0 && (
<Section title="SNI-koder">
<ul className="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>
</Section>
)}
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<Section title="Bankuppgifter">
<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>
</Section>
)}
{snapshot.purpose && (
<Section title="Verksamhet">
<p className="text-sm leading-6 text-muted-foreground">{snapshot.purpose}</p>
</Section>
)}
<Section title="Anställda">
<p className="text-sm text-muted-foreground">
{snapshot.employeeRange ?? 'Inga anställda'}
</p>
</Section>
<Section title="Senaste bokslut">
{snapshot.financials ? (
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<dt className="text-muted-foreground">Nettoomsättning</dt>
<dd className="text-right tabular-nums">
{snapshot.financials.netSalesK != null
? `${snapshot.financials.netSalesK.toLocaleString('sv-SE')} tkr`
: '-'}
</dd>
<dt className="text-muted-foreground">Rörelseresultat</dt>
<dd className="text-right tabular-nums">
{snapshot.financials.operatingProfitK != null
? `${snapshot.financials.operatingProfitK.toLocaleString('sv-SE')} tkr`
: '-'}
</dd>
</dl>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
)}
</Section>
{(() => {
// 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)
if (datedStatuses.length === 0) return null
return (
<Section title="Status">
<dl className="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>
</Section>
)
})()}
{fyLabel && (
<Section title="Räkenskapsår">
<p className="text-sm tabular-nums text-muted-foreground">Nuvarande: {fyLabel}</p>
</Section>
)}
{(() => {
// Flatten every signatory row, clean ">" markers, split run-on
// clauses, and dedupe: the source repeats "Firman tecknas av
// styrelsen" across rows.
const rules = Array.from(
new Set(
(snapshot.signatory ?? []).flatMap((s) => cleanSignatory(s.description)),
),
)
if (rules.length === 0) return null
return (
<Section title="Firmateckning">
<ul className="space-y-1.5">
{rules.map((rule, i) => (
<li key={i} className="text-sm leading-6 text-muted-foreground">
{rule}
</li>
))}
</ul>
</Section>
)
})()}
{Array.isArray(snapshot.representatives) && snapshot.representatives.length > 0 && (
<Section title="Företrädare">
{snapshot.board && (
<p className="text-xs text-muted-foreground mb-2">
{[
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-xs text-muted-foreground text-right">
{[r.positionType, r.positionStart ? formatDate(r.positionStart) : null]
.filter(Boolean)
.join(' · ')}
</span>
</li>
))}
</ul>
</Section>
)}
</CardContent>
</Card>
)
}