da859d7236
* 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>
309 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|