'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_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 && (
)}
{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,
})}
)}
{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}
))}
)}
{versions.length === 0 ? (
{t('versions_empty')}
) : (
{versions.map((version) => (
{t('version_label', { number: version.version_number })}
{version.content_hash.slice(0, 12)}
))}
)}
)
}