'use client' import { useState, useCallback, useEffect } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { useExtensionData } from '@/lib/extensions/use-extension-data' import { useToast } from '@/components/ui/use-toast' import { Building2, CheckCircle, XCircle, MapPin, Mail, Phone, Settings, AlertTriangle, CalendarRange, ShieldCheck, Users, Receipt, } from 'lucide-react' import { Badge } from '@/components/ui/badge' import Link from 'next/link' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types' /** * The profile is hydrated from a persisted `extension_data` jsonb blob, so its * shape is whatever the TIC schema looked like when it was cached, not what * TICCompanyProfile promises today. A blob written before the v2 upgrade (#584) * has no `statuses` key at all, and `profile.statuses.length` on a rendered * blob like that dropped the whole workspace into the error boundary. * * Normalising once at the hydration boundary keeps every list read below * honest, including the ones a future schema change would otherwise break. */ function normalizeProfile(raw: unknown): TICCompanyProfile { const p = (raw ?? {}) as Partial const list = (value: T[] | undefined | null): T[] => (Array.isArray(value) ? value : []) return { ...(p as TICCompanyProfile), registration: p.registration ?? { fTax: false, vat: false, payroll: false }, sniCodes: list(p.sniCodes), bankAccounts: list(p.bankAccounts), beneficialOwners: list(p.beneficialOwners), financialReports: list(p.financialReports), fiscalYearHistory: list(p.fiscalYearHistory), signatory: list(p.signatory), representatives: list(p.representatives), payrolls: list(p.payrolls), statuses: list(p.statuses), // Declared nullable, so undefined would already read as falsy at the // render guards; normalised anyway so the object matches its own type. fiscalYear: p.fiscalYear ?? null, board: p.board ?? null, financials: p.financials ?? null, } } function formatKSEK(value: number | null): string { if (value === null) return '-' return `${(value * 1000).toLocaleString('sv-SE')} kr` } function formatPercent(value: number | null): string { if (value === null) return '-' return `${value.toFixed(1)} %` } function formatSek(value: number | null): string { if (value === null || value === undefined) return '-' return `${value.toLocaleString('sv-SE')} kr` } function formatIsoDate(iso: string | null): string { if (!iso) return '-' return iso.slice(0, 10) } function statusColorToVariant( color: 'red' | 'yellow' | 'green' | 'neutral' | null ): 'destructive' | 'warning' | 'success' | 'secondary' { switch (color) { case 'red': return 'destructive' case 'yellow': return 'warning' case 'green': return 'success' default: return 'secondary' } } function toMs(epoch: number): number { // TIC returns epoch seconds; Date() expects milliseconds return epoch < 1e12 ? epoch * 1000 : epoch } function formatPeriod(start: number, end: number): string { const s = new Date(toMs(start)) const e = new Date(toMs(end)) const fmt = (d: Date) => d.toLocaleDateString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit' }) return `${fmt(s)} to ${fmt(e)}` } function timeAgo(isoDate: string, t: (key: string, values?: Record) => string): string { const diff = Date.now() - new Date(isoDate).getTime() const minutes = Math.floor(diff / 60000) if (minutes < 1) return t('time_just_now') if (minutes < 60) return t('time_minutes_ago', { n: minutes }) const hours = Math.floor(minutes / 60) if (hours < 24) return t('time_hours_ago', { n: hours }) const days = Math.floor(hours / 24) return t('time_days_ago', { n: days }) } // Mirrors the live layout (two cards: company info + financials) so the // transition from the route-level loading.tsx to data-loaded content has no // visible reflow. Keep in sync with app/(dashboard)/e/[sector]/[slug]/loading.tsx. function ProfileSkeleton() { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
) } export default function TicWorkspace({ userId }: WorkspaceComponentProps) { const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic') const { toast } = useToast() const t = useTranslations('tic_workspace') const [profile, setProfile] = useState(null) const [isFetching, setIsFetching] = useState(false) const [noOrgNumber, setNoOrgNumber] = useState(false) const [fetchFailed, setFetchFailed] = useState(false) const [initialLoad, setInitialLoad] = useState(true) // Load cached profile from extension data useEffect(() => { if (isDataLoading) return const cached = getByKey('company_profile') // Blobs written before the TIC v2 upgrade (#584) predate the statuses / // board / payroll sections entirely. Rendering one normalized would show // a permanently section-less profile, because the success path has no // refresh button to repair it: leaving `profile` null instead lets the // auto-fetch effect below pull the current shape and re-save it. if (cached?.value && 'statuses' in cached.value) { setProfile(normalizeProfile(cached.value)) } setInitialLoad(false) }, [isDataLoading, getByKey]) const fetchProfile = useCallback(async () => { setIsFetching(true) setNoOrgNumber(false) setFetchFailed(false) try { // Get org_number from company settings const settingsRes = await fetch('/api/settings') if (!settingsRes.ok) { toast({ title: t('toast_settings_failed'), variant: 'destructive' }) return } const { data: settings } = await settingsRes.json() const orgNumber = settings?.org_number if (!orgNumber) { setNoOrgNumber(true) return } const res = await fetch( `/api/extensions/ext/tic/profile?org_number=${encodeURIComponent(orgNumber)}` ) if (!res.ok) { // `error` is the canonical envelope object, not a string: as a bare // toast title it would render an object as a React child. const body = await res.json().catch(() => null) toast({ title: body?.error ? getErrorMessage(body, { statusCode: res.status }) : t('toast_profile_failed'), variant: 'destructive', }) setFetchFailed(true) return } const { data } = await res.json() setProfile(normalizeProfile(data)) await save('company_profile', data) } catch { toast({ title: t('toast_unexpected_error'), variant: 'destructive' }) setFetchFailed(true) } finally { setIsFetching(false) } }, [save, toast, t]) // Auto-fetch on first visit when no cached data useEffect(() => { if (!initialLoad && !profile && !noOrgNumber && !isFetching && !fetchFailed) { fetchProfile() } }, [initialLoad, profile, noOrgNumber, isFetching, fetchFailed, fetchProfile]) if (initialLoad || isDataLoading) { return } if (noOrgNumber) { return (

{t('no_org_number_title')}

{t('no_org_number_description')}

) } if (isFetching && !profile) { return } if (fetchFailed && !profile) { return (

{t('fetch_failed_title')}

{t('fetch_failed_description')}

) } if (!profile) return null const isActive = profile.activityStatus !== 'ceased' const registrations = [ profile.registration.fTax && t('reg_f_tax'), profile.registration.vat && t('reg_vat'), profile.registration.payroll && t('reg_employer'), ].filter((label): label is string => Boolean(label)) return (
{/* Company info card */} {profile.companyName} {profile.orgNumber} · {profile.legalEntityType} {!isActive && ( · {t('deregistered')} )} {profile.address && (
{[profile.address.street, `${profile.address.postalCode} ${profile.address.city}`] .filter(Boolean) .join(', ')}
)} {profile.email && (
{profile.email}
)} {profile.phone && (
{profile.phone}
)} {registrations.length > 0 && (

{t('registered_for')}

{registrations.join(' ยท ')}

)} {profile.sniCodes.length > 0 && (

{t('sni_codes')}

{profile.sniCodes .filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i) .map((sni) => (

{sni.code}{' '} {sni.name}

))}
)} {profile.bankAccounts.length > 0 && (

{t('bank_accounts')}

{profile.bankAccounts.map((ba, i) => (

{ba.type}:{' '} {ba.accountNumber}

))}
)} {profile.purpose && (

{t('purpose')}

{profile.purpose}

)} {(profile.employeeRange || profile.turnoverRange) && (
{profile.employeeRange && (

{t('employees_range', { range: profile.employeeRange })}

)} {profile.turnoverRange && (

{t('turnover_range', { range: profile.turnoverRange })}

)}
)}

{t('updated_ago', { ago: timeAgo(profile.fetchedAt, t) })}

{/* Financials card */} {t('latest_closing')} {profile.financials && ( {formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)} )} {profile.financials ? (
) : (

{t('no_financials')}

)}
{/* Status entries: most recent first; usually 1-3 rows */} {profile.statuses.length > 0 && ( {t('status_section')}
    {profile.statuses.slice(0, 6).map((status, i) => (
  • {status.description ?? status.code ?? '-'} {status.isCeased && ( {t('deregistered')} )}
    {formatIsoDate(status.statusDate)}
  • ))}
)} {/* Fiscal year + signatory side-by-side */} {(profile.fiscalYear || profile.signatory.length > 0) && (
{profile.fiscalYear && ( {t('fiscal_year_section')}

{t('fiscal_year_current', { start: profile.fiscalYear.startMonthDay ?? '-', end: profile.fiscalYear.endMonthDay ?? '-', })}

{profile.fiscalYearHistory.length > 1 && (

{t('fiscal_year_changed', { n: profile.fiscalYearHistory.length - 1 })}

)}
)} {profile.signatory.length > 0 && ( {t('signatory_section')} {profile.signatory.map((s, i) => (

{s.description}

))}
)}
)} {/* Board summary + representatives */} {(profile.board || profile.representatives.length > 0) && ( {t('board_section')} {profile.board && ( {profile.board.numberOfBoardMembers !== null && ( {t('board_summary_members', { n: profile.board.numberOfBoardMembers })} )} {profile.board.numberOfDeputyBoardMembers !== null && profile.board.numberOfDeputyBoardMembers > 0 && ( {t('board_summary_deputies', { n: profile.board.numberOfDeputyBoardMembers })} )} {profile.board.hasVacancy && ( {t('board_vacancy')} )} {profile.board.missingCEODate && ( {t('board_missing_ceo', { date: formatIsoDate(profile.board.missingCEODate) })} )} {profile.board.missingAuditor && ( {t('board_missing_auditor', { date: formatIsoDate(profile.board.missingAuditor) })} )} )} {profile.representatives.length > 0 ? ( {t('col_name')} {t('col_position')} {t('col_since')} {profile.representatives.slice(0, 12).map((p, i) => ( {p.name ?? '-'} {p.positionDescription ?? p.positionType ?? '-'} {formatIsoDate(p.positionStart)} ))}
) : (

{t('board_no_representatives')}

)}
)} {/* Payroll history: payroll2 array, newest first */} {profile.payrolls.length > 0 && ( {t('payroll_section')} {t('col_payroll_period')} {t('col_payroll_employees')} {t('col_payroll_tax')} {t('col_payroll_personnel_costs')} {t('col_payroll_deviation')} {t('col_payroll_late_fees')} {profile.payrolls.slice(0, 10).map((p, i) => ( {p.periodStart && p.periodEnd ? `${formatIsoDate(p.periodStart)} to ${formatIsoDate(p.periodEnd)}` : '-'} {p.numberOfEmployees !== null ? p.numberOfEmployees.toFixed(0) : '-'} {formatSek(p.sumPayrollTax)} {formatSek(p.calculatedPersonnelCosts)} 0.1 ? 'text-warning' : 'text-muted-foreground' }`} > {p.deviation !== null ? `${(p.deviation * 100).toFixed(1)} %` : '-'} 0 ? 'text-destructive' : 'text-muted-foreground' }`} > {p.numberOfLateFeesForPeriod ?? 0} ))}
)} {/* Financial reports table */} {profile.financialReports.length > 0 && ( {t('annual_reports')} {t('col_period')} {t('col_title')} {t('col_filed')} {t('col_audited')} {t('col_audit_opinion')} {profile.financialReports .filter((r) => !r.isInterimReport) .sort((a, b) => { const aEnd = a.periodEnd ? new Date(a.periodEnd).getTime() : 0 const bEnd = b.periodEnd ? new Date(b.periodEnd).getTime() : 0 return bEnd - aEnd }) .slice(0, 10) .map((report, i) => ( {report.periodStart && report.periodEnd ? `${report.periodStart.slice(0, 10)} to ${report.periodEnd.slice(0, 10)}` : '-'} {report.title ?? '-'} {report.arrivalDate ? new Date(report.arrivalDate).toLocaleDateString('sv-SE') : '-'} {report.isAudited === true ? ( ) : report.isAudited === false ? ( ) : ( - )} {report.auditOpinion ?? '-'} ))}
)}
) } function FinancialCell({ label, value, negative = false, }: { label: string value: string negative?: boolean }) { return (

{label}

{value}

) }