'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { AlertCircle, CheckCircle2, FileClock, Loader2, LockKeyhole, Save } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import type { AnnualReportComplianceIssue, AnnualReportProfile, AnnualReportValidationResult, AnnualReportVersionSummary, } from '@/lib/bokslut/arsredovisning/compliance-types' interface ComplianceResponse { profile: AnnualReportProfile eligibility: { k2_eligible: boolean digital_filing_eligible: boolean size_classification: 'smaller' | 'larger' | 'unknown' issues: AnnualReportComplianceIssue[] digital_issues: AnnualReportComplianceIssue[] } validation: AnnualReportValidationResult report_summary: { proposed_dividend: number distributable_equity: number } } interface AnnualReportStudioProps { periodId: string periodStart: string periodEnd: string framework: 'k2' | 'k3' hasUnsavedNarrative: boolean narrativeRevision: string | null onVersionsChanged?: (versions: AnnualReportVersionSummary[]) => void } type NullableBoolean = boolean | null function BooleanQuestion({ id, label, value, onChange, }: { id: string label: string value: NullableBoolean onChange: (value: NullableBoolean) => void }) { const t = useTranslations('annualReportStudio') return (
) } export function AnnualReportStudio({ periodId, periodStart, periodEnd, framework, hasUnsavedNarrative, narrativeRevision, onVersionsChanged, }: AnnualReportStudioProps) { const t = useTranslations('annualReportStudio') const { toast } = useToast() const [compliance, setCompliance] = useState(null) const [profile, setProfile] = useState(null) const [versions, setVersions] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [creatingVersion, setCreatingVersion] = useState<'snapshot' | 'finalize' | null>(null) const complianceUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/compliance` const versionsUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions` const load = useCallback(async () => { setLoading(true) try { const [complianceResponse, versionsResponse] = await Promise.all([ fetch(complianceUrl), fetch(versionsUrl), ]) const [complianceBody, versionsBody] = await Promise.all([ complianceResponse.json(), versionsResponse.json(), ]) if (!complianceResponse.ok) throw new Error(getUserErrorMessage(complianceBody.error)) if (!versionsResponse.ok) throw new Error(getUserErrorMessage(versionsBody.error)) setCompliance(complianceBody.data as ComplianceResponse) setProfile((complianceBody.data as ComplianceResponse).profile) const nextVersions = (versionsBody.data ?? []) as AnnualReportVersionSummary[] setVersions(nextVersions) onVersionsChanged?.(nextVersions) } catch (err) { toast({ title: t('load_error'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setLoading(false) } }, [complianceUrl, versionsUrl, onVersionsChanged, t, toast]) useEffect(() => { void load() }, [load, narrativeRevision]) const showNewK2Questions = periodStart > '2025-12-31' || (periodStart > '2025-06-30' && periodEnd >= '2026-12-31') const blockingIssues = useMemo( () => compliance?.validation.issues.filter((issue) => issue.severity === 'error') ?? [], [compliance], ) const digitalOnlyIssues = useMemo(() => { const generalCodes = new Set(compliance?.validation.issues.map((issue) => issue.code) ?? []) return ( compliance?.eligibility.digital_issues.filter((issue) => !generalCodes.has(issue.code)) ?? [] ) }, [compliance]) const updateProfile = ( key: K, value: AnnualReportProfile[K], ) => { setProfile((current) => (current ? { ...current, [key]: value } : current)) } const saveProfile = async () => { if (!profile) return setSaving(true) try { const response = await fetch(complianceUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_public_limited_company: profile.is_public_limited_company, is_in_liquidation: profile.is_in_liquidation, securities_traded_on_regulated_market: profile.securities_traded_on_regulated_market, is_parent_company: profile.is_parent_company, parent_group_size: profile.is_parent_company ? profile.parent_group_size : null, prepares_consolidated_accounts: profile.is_parent_company ? profile.prepares_consolidated_accounts : null, has_foreign_branch: profile.has_foreign_branch, has_crypto_assets: profile.has_crypto_assets, has_share_based_payments: profile.has_share_based_payments, has_convertible_debt: profile.has_convertible_debt, building_revenue_share_pct: profile.building_revenue_share_pct, has_material_deferred_tax: profile.has_material_deferred_tax, reporting_currency: profile.reporting_currency, auditor_report_required: profile.auditor_report_required, auditor_report_included: profile.auditor_report_included, dividend_prudence_confirmed: profile.dividend_prudence_confirmed, k2_assessment_confirmed: true, }), }) const body = await response.json() if (!response.ok) throw new Error(getUserErrorMessage(body.error)) const next = body.data as ComplianceResponse setCompliance(next) setProfile(next.profile) toast({ title: t('scope_saved') }) } catch (err) { toast({ title: t('save_error'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setSaving(false) } } const confirmNarrative = async () => { if (hasUnsavedNarrative) { toast({ title: t('save_content_first'), variant: 'destructive' }) return } setSaving(true) try { const response = await fetch(complianceUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ narrative_confirmed: true }), }) const body = await response.json() if (!response.ok) throw new Error(getUserErrorMessage(body.error)) const next = body.data as ComplianceResponse setCompliance(next) setProfile(next.profile) toast({ title: t('content_confirmed') }) } catch (err) { toast({ title: t('save_error'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setSaving(false) } } const confirmSignerRoster = async () => { setSaving(true) try { const response = await fetch(complianceUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signer_roster_confirmed: true }), }) const body = await response.json() if (!response.ok) throw new Error(getUserErrorMessage(body.error)) const next = body.data as ComplianceResponse setCompliance(next) setProfile(next.profile) toast({ title: t('signer_roster_confirmed') }) } catch (err) { toast({ title: t('save_error'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setSaving(false) } } const createVersion = async (action: 'snapshot' | 'finalize') => { if (hasUnsavedNarrative) { toast({ title: t('save_content_first'), variant: 'destructive' }) return } setCreatingVersion(action) try { const response = await fetch(versionsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), }) const body = await response.json() if (!response.ok) { if (body.error?.details?.issues) { setCompliance((current) => current ? { ...current, validation: body.error.details as AnnualReportValidationResult } : current, ) } throw new Error(getUserErrorMessage(body.error)) } toast({ title: action === 'finalize' ? t('version_locked') : t('snapshot_created') }) await load() } catch (err) { toast({ title: action === 'finalize' ? t('lock_error') : t('snapshot_error'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setCreatingVersion(null) } } if (loading || !profile || !compliance) { return (
{t('loading')}
) } return (

{t('title')}

{t('description')}

{blockingIssues.length === 0 ? ( {t('no_blockers')} ) : ( {t('blocker_count', { count: blockingIssues.length })} )}
{[ [ t('step_scope'), compliance.eligibility.issues.every((issue) => issue.severity !== 'error'), ], [t('step_content'), Boolean(profile.narrative_confirmed_at)], [t('step_signatures'), versions.some((version) => version.status === 'signed')], [t('step_filing'), versions.some((version) => ['filed', 'registered'].includes(version.status))], ].map(([label, complete], index) => (
{complete ? ( ) : ( {index + 1} )} {label}
))}

{t('scope_title')}

{t('scope_description')}

updateProfile('is_public_limited_company', value)} /> updateProfile('is_in_liquidation', value)} /> updateProfile('securities_traded_on_regulated_market', value)} /> updateProfile('is_parent_company', value)} /> updateProfile('auditor_report_required', value)} />
{profile.auditor_report_required && ( updateProfile('auditor_report_included', Boolean(value))} /> )}
{profile.is_parent_company && (
updateProfile('prepares_consolidated_accounts', value)} />
)} {framework === 'k2' && showNewK2Questions && (

{t('k2_2026_title')}

updateProfile('has_foreign_branch', value)} /> updateProfile('has_crypto_assets', value)} /> updateProfile('has_share_based_payments', value)} /> updateProfile('has_convertible_debt', value)} /> updateProfile('has_material_deferred_tax', value)} />
updateProfile( 'building_revenue_share_pct', event.target.value === '' ? null : Number(event.target.value), ) } />
)} {compliance.report_summary.proposed_dividend > 0 && (
updateProfile('dividend_prudence_confirmed', value)} />

{t('dividend_prudence_description', { dividend: compliance.report_summary.proposed_dividend, equity: compliance.report_summary.distributable_equity, })}

)}

{t('checks_title')}

{blockingIssues.length === 0 ? (
{t('no_blockers')}
) : (
    {blockingIssues.map((issue) => (
  • {issue.message}

    {issue.remediation &&

    {issue.remediation}

    }
  • ))}
)} {digitalOnlyIssues.length > 0 && (

{t('digital_checks_title')}

    {digitalOnlyIssues.map((issue) => (
  • {issue.message}
  • ))}
)}

{t('versions_title')}

{versions.length === 0 ? (

{t('versions_empty')}

) : (
{versions.map((version) => (

{t('version_label', { number: version.version_number })}

{version.content_hash.slice(0, 12)}

{t(`status_${version.status}`)}
))}
)}
) }