Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
+3 -2
View File
@@ -18,6 +18,7 @@ import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Markdown parser loads on the first assistant message instead of with the
// chat surface itself; react-markdown + remark-gfm pull in the whole
@@ -259,7 +260,7 @@ export default function AgentChat({
})
} catch (err) {
if (signal.aborted) return
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte nå assistenten.')
setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte nå assistenten.')
setStreaming(false)
activeControllerRef.current = null
return
@@ -332,7 +333,7 @@ export default function AgentChat({
}
} catch (err) {
if (!signal.aborted) {
setErrorMessage(err instanceof Error ? err.message : 'Streamen avbröts.')
setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Streamen avbröts.')
}
} finally {
try {
+3 -1
View File
@@ -1,7 +1,9 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import AgentSheet from './AgentSheet'
import dynamic from 'next/dynamic'
const AgentSheet = dynamic(() => import('./AgentSheet'))
export interface AgentIdentity {
displayName: string | null
+4 -3
View File
@@ -11,6 +11,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import type { PendingOperationRejectionCategory } from '@/types'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Inline approval card for an agent-staged pending_operation.
//
@@ -144,7 +145,7 @@ export default function ApprovalCard({
setState('committed')
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte godkänna.')
setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte godkänna.')
}
}
@@ -169,7 +170,7 @@ export default function ApprovalCard({
await handleCommit()
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte aktivera kontona.')
setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte aktivera kontona.')
}
}
@@ -210,7 +211,7 @@ export default function ApprovalCard({
}
} catch (err) {
setState('error')
setErrorMessage(err instanceof Error ? err.message : 'Kunde inte avslå.')
setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte avslå.')
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Skeleton } from '@/components/ui/skeleton'
export function AuthPageSkeleton() {
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4" aria-busy="true">
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-6">
<div className="space-y-2">
<Skeleton className="mx-auto h-8 w-40" />
<Skeleton className="mx-auto h-4 w-56" />
</div>
<div className="space-y-4">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
</div>
</main>
)
}
+589
View File
@@ -0,0 +1,589 @@
'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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<select
id={id}
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={value === null ? '' : value ? 'yes' : 'no'}
onChange={(event) =>
onChange(event.target.value === '' ? null : event.target.value === 'yes')
}
>
<option value="">{t('choose')}</option>
<option value="no">{t('no')}</option>
<option value="yes">{t('yes')}</option>
</select>
</div>
)
}
export function AnnualReportStudio({
periodId,
periodStart,
periodEnd,
framework,
hasUnsavedNarrative,
narrativeRevision,
onVersionsChanged,
}: AnnualReportStudioProps) {
const t = useTranslations('annualReportStudio')
const { toast } = useToast()
const [compliance, setCompliance] = useState<ComplianceResponse | null>(null)
const [profile, setProfile] = useState<AnnualReportProfile | null>(null)
const [versions, setVersions] = useState<AnnualReportVersionSummary[]>([])
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 = <K extends keyof AnnualReportProfile>(
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 (
<Card>
<CardContent className="flex min-h-24 items-center justify-center p-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loading')}
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
<Card>
<CardHeader className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-lg">{t('title')}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<Badge variant={blockingIssues.length === 0 ? 'success' : 'warning'}>
{blockingIssues.length === 0
? t('no_blockers')
: t('blocker_count', { count: blockingIssues.length })}
</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-4">
{[
[
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) => (
<div key={String(label)} className="flex min-h-11 items-center gap-2 border-b border-border px-1 py-2 text-sm">
{complete ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
) : (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border text-xs">
{index + 1}
</span>
)}
<span>{label}</span>
</div>
))}
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('scope_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('scope_description')}</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<BooleanQuestion
id="ar-public"
label={t('public_company')}
value={profile.is_public_limited_company}
onChange={(value) => updateProfile('is_public_limited_company', value)}
/>
<BooleanQuestion
id="ar-liquidation"
label={t('in_liquidation')}
value={profile.is_in_liquidation}
onChange={(value) => updateProfile('is_in_liquidation', value)}
/>
<BooleanQuestion
id="ar-listed"
label={t('listed_securities')}
value={profile.securities_traded_on_regulated_market}
onChange={(value) => updateProfile('securities_traded_on_regulated_market', value)}
/>
<BooleanQuestion
id="ar-parent"
label={t('parent_company')}
value={profile.is_parent_company}
onChange={(value) => updateProfile('is_parent_company', value)}
/>
<BooleanQuestion
id="ar-audit"
label={t('audit_required')}
value={profile.auditor_report_required}
onChange={(value) => updateProfile('auditor_report_required', value)}
/>
<div className="space-y-2">
<Label htmlFor="ar-reporting-currency">{t('reporting_currency')}</Label>
<select
id="ar-reporting-currency"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={profile.reporting_currency}
onChange={(event) =>
updateProfile(
'reporting_currency',
event.target.value as AnnualReportProfile['reporting_currency'],
)
}
>
<option value="SEK">SEK</option>
<option value="EUR">EUR</option>
</select>
</div>
{profile.auditor_report_required && (
<BooleanQuestion
id="ar-auditor-report-included"
label={t('auditor_report_included')}
value={profile.auditor_report_included}
onChange={(value) => updateProfile('auditor_report_included', Boolean(value))}
/>
)}
</div>
{profile.is_parent_company && (
<div className="grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ar-group-size">{t('group_size')}</Label>
<select
id="ar-group-size"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={profile.parent_group_size ?? ''}
onChange={(event) =>
updateProfile(
'parent_group_size',
(event.target.value || null) as AnnualReportProfile['parent_group_size'],
)
}
>
<option value="">{t('choose')}</option>
<option value="small">{t('group_small')}</option>
<option value="large">{t('group_large')}</option>
</select>
</div>
<BooleanQuestion
id="ar-consolidated"
label={t('consolidated_accounts')}
value={profile.prepares_consolidated_accounts}
onChange={(value) => updateProfile('prepares_consolidated_accounts', value)}
/>
</div>
)}
{framework === 'k2' && showNewK2Questions && (
<div className="space-y-4 border-t border-border pt-4">
<p className="text-sm font-medium">{t('k2_2026_title')}</p>
<div className="grid gap-4 md:grid-cols-2">
<BooleanQuestion id="ar-foreign-branch" label={t('foreign_branch')} value={profile.has_foreign_branch} onChange={(value) => updateProfile('has_foreign_branch', value)} />
<BooleanQuestion id="ar-crypto" label={t('crypto_assets')} value={profile.has_crypto_assets} onChange={(value) => updateProfile('has_crypto_assets', value)} />
<BooleanQuestion id="ar-share-payments" label={t('share_payments')} value={profile.has_share_based_payments} onChange={(value) => updateProfile('has_share_based_payments', value)} />
<BooleanQuestion id="ar-convertible" label={t('convertible_debt')} value={profile.has_convertible_debt} onChange={(value) => updateProfile('has_convertible_debt', value)} />
<BooleanQuestion id="ar-deferred-tax" label={t('material_deferred_tax')} value={profile.has_material_deferred_tax} onChange={(value) => updateProfile('has_material_deferred_tax', value)} />
<div className="space-y-2">
<Label htmlFor="ar-building-revenue">{t('building_revenue')}</Label>
<Input
id="ar-building-revenue"
type="number"
min={0}
max={100}
className="min-h-11"
value={profile.building_revenue_share_pct ?? ''}
onChange={(event) =>
updateProfile(
'building_revenue_share_pct',
event.target.value === '' ? null : Number(event.target.value),
)
}
/>
</div>
</div>
</div>
)}
{compliance.report_summary.proposed_dividend > 0 && (
<div className="space-y-3 border-t border-border pt-4">
<BooleanQuestion
id="ar-dividend-prudence"
label={t('dividend_prudence')}
value={profile.dividend_prudence_confirmed}
onChange={(value) => updateProfile('dividend_prudence_confirmed', value)}
/>
<p className="text-xs text-muted-foreground">
{t('dividend_prudence_description', {
dividend: compliance.report_summary.proposed_dividend,
equity: compliance.report_summary.distributable_equity,
})}
</p>
</div>
)}
<div className="flex justify-end">
<Button className="min-h-11" onClick={() => void saveProfile()} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('save_scope')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('checks_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{blockingIssues.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4" /> {t('no_blockers')}
</div>
) : (
<ul className="space-y-3">
{blockingIssues.map((issue) => (
<li key={issue.code} className="flex gap-3 text-sm">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div>
<p>{issue.message}</p>
{issue.remediation && <p className="mt-1 text-xs text-muted-foreground">{issue.remediation}</p>}
</div>
</li>
))}
</ul>
)}
{digitalOnlyIssues.length > 0 && (
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/5 p-3">
<p className="text-sm font-medium">{t('digital_checks_title')}</p>
<ul className="space-y-2">
{digitalOnlyIssues.map((issue) => (
<li key={issue.code} className="text-sm text-muted-foreground">
{issue.message}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap justify-end gap-3 border-t border-border pt-4">
<Button
variant="outline"
className="min-h-11"
onClick={() => void confirmSignerRoster()}
disabled={saving || Boolean(profile.signer_roster_confirmed_at)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{profile.signer_roster_confirmed_at
? t('signer_roster_confirmed')
: t('confirm_signer_roster')}
</Button>
<Button
variant="outline"
className="min-h-11"
onClick={() => void confirmNarrative()}
disabled={saving || hasUnsavedNarrative || Boolean(profile.narrative_confirmed_at)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{profile.narrative_confirmed_at ? t('content_confirmed') : t('confirm_content')}
</Button>
<Button variant="outline" className="min-h-11" onClick={() => void createVersion('snapshot')} disabled={creatingVersion !== null}>
{creatingVersion === 'snapshot' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileClock className="mr-2 h-4 w-4" />}
{t('create_snapshot')}
</Button>
<Button className="min-h-11" onClick={() => void createVersion('finalize')} disabled={creatingVersion !== null || blockingIssues.length > 0 || hasUnsavedNarrative}>
{creatingVersion === 'finalize' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LockKeyhole className="mr-2 h-4 w-4" />}
{t('lock_version')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('versions_title')}</CardTitle>
</CardHeader>
<CardContent>
{versions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('versions_empty')}</p>
) : (
<div className="divide-y divide-border">
{versions.map((version) => (
<div key={version.id} className="flex min-h-14 flex-wrap items-center justify-between gap-3 py-3 text-sm">
<div>
<p className="font-medium">{t('version_label', { number: version.version_number })}</p>
<p className="text-xs text-muted-foreground">{version.content_hash.slice(0, 12)}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={version.status === 'registered' ? 'success' : version.status === 'draft' ? 'outline' : 'secondary'}>
{t(`status_${version.status}`)}
</Badge>
<Button className="min-h-11" variant="outline" size="sm" asChild>
<a href={`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/pdf?version=${version.id}`} target="_blank" rel="noopener noreferrer">
{t('open_pdf')}
</a>
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
+230 -60
View File
@@ -6,7 +6,7 @@
* Three steps below the year-end ÅR editors:
* 1. Granska: the generated iXBRL rendered in an iframe (the XHTML *is*
* the filed presentation) + pre-flight validation results + download
* for manual filing (the self-hosted/no-extension path).
* for technical inspection and support diagnostics.
* 2. Skicka in: only when the bolagsverket extension responds: avtalstext
* acceptance → kontrollera-utfall → upload till eget utrymme → kvittens
* with "signera hos Bolagsverket"-link. The fastställelseintyg is signed
@@ -17,6 +17,7 @@
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -33,13 +34,16 @@ import {
Send,
ShieldCheck,
} from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { CONNECTED_FILING_PUBLIC_RELEASED } from '@/lib/bokslut/arsredovisning/capabilities'
import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types'
/** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat
* (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en
* "Kommer snart"-skylt: endast PDF-nedladdningen på ÅR-sidan är användbar.
* Flippa till false när integrationen är godkänd. Importeras också av
* ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */
export const INLAMNING_COMING_SOON = true
export const INLAMNING_COMING_SOON = !CONNECTED_FILING_PUBLIC_RELEASED
interface PreflightIssue {
code: string
@@ -66,7 +70,6 @@ interface SubmissionRow {
id: string
status: string
environment: string
idnummer: string | null
kontrollsumma: string | null
sha256_checksumma: string | null
bolagsverket_url: string | null
@@ -76,13 +79,33 @@ interface SubmissionRow {
uploaded_at: string | null
registered_at: string | null
created_at: string
annual_report_version_id: string | null
archive_status: 'pending' | 'stored' | 'failed'
}
interface RegistryInformation {
namn: string
status: Array<{ kod?: string; text?: string }>
rakenskapsperioder: Array<{
from: string
tom: string
kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas'
revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas'
}>
}
interface RegistryCaseStatus {
typ: string
arendenummer: string | null
rakenskapsperiod: { from: string; tom: string } | null
}
type SubmitOutcome =
| { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string }
| { outcome: 'preflight_failed'; issues: PreflightIssue[] }
| { outcome: 'kontrollera_stopped'; submissionId: string; utfall: KontrolleraUtfall[] }
| { outcome: 'uploaded'; submissionId: string; idnummer: string; url: string; utfall: KontrolleraUtfall[] }
| { outcome: 'uploaded'; submissionId: string; url: string; utfall: KontrolleraUtfall[] }
| { outcome: 'state_unknown'; submissionId: string; url: string | null; message: string }
/**
* Normalize a Swedish personnummer to the 12-digit ÅÅÅÅMMDDNNNN form the
@@ -108,7 +131,9 @@ function normalizePnr(raw: string): string | null {
const STATUS_BADGES: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline' }> = {
draft: { label: 'Utkast', variant: 'outline' },
kontrollerad: { label: 'Kontrollerad', variant: 'secondary' },
sending: { label: 'Skickar: avvakta', variant: 'warning' },
uploaded: { label: 'Uppladdad: väntar på signering', variant: 'warning' },
unknown: { label: 'Okänd extern status: skicka inte igen', variant: 'destructive' },
inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' },
forelagd: { label: 'Föreläggande: åtgärd krävs', variant: 'destructive' },
komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' },
@@ -119,6 +144,7 @@ const STATUS_BADGES: Record<string, { label: string; variant: 'default' | 'secon
export function DigitalInlamning({ periodId }: { periodId: string }) {
const { toast } = useToast()
const tStudio = useTranslations('annualReportStudio')
const ixbrlUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/ixbrl`
const [showPreview, setShowPreview] = useState(false)
@@ -127,6 +153,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
// Extension availability: probe the status route; 404 = not enabled.
const [extensionActive, setExtensionActive] = useState<boolean | null>(null)
const [filingEnabled, setFilingEnabled] = useState(false)
const [environment, setEnvironment] = useState<string>('test')
// Submission form
@@ -139,23 +166,32 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
const [submitting, setSubmitting] = useState(false)
const [avtal, setAvtal] = useState<{ text: string; andrad: string; accepted: boolean } | null>(null)
const [utfall, setUtfall] = useState<KontrolleraUtfall[] | null>(null)
const [kvittens, setKvittens] = useState<{ idnummer: string; url: string } | null>(null)
const [kvittens, setKvittens] = useState<{ url: string } | null>(null)
// Proposed dividend (utdelning) for the resultatdisposition. There is no
// persisted dividend proposal in the year-end flow yet, so the value is
// entered here and forwarded to the preview, the download and the
// submission so all three render the same disposition.
const [utdelning, setUtdelning] = useState('')
const parsedUtdelning = Math.round(Number(utdelning.replace(/\s/g, '').replace(',', '.')))
const utdelningValue = Number.isFinite(parsedUtdelning) && parsedUtdelning > 0 ? parsedUtdelning : 0
const previewUrl = utdelningValue > 0 ? `${ixbrlUrl}?utdelning=${utdelningValue}` : ixbrlUrl
const downloadUrl =
utdelningValue > 0 ? `${ixbrlUrl}?download=1&utdelning=${utdelningValue}` : `${ixbrlUrl}?download=1`
const [versions, setVersions] = useState<AnnualReportVersionSummary[]>([])
const [selectedVersionId, setSelectedVersionId] = useState('')
const [registryInformation, setRegistryInformation] = useState<RegistryInformation | null>(null)
const [registryCase, setRegistryCase] = useState<RegistryCaseStatus | null>(null)
const versionQuery = selectedVersionId
? `version=${encodeURIComponent(selectedVersionId)}`
: ''
const previewUrl = versionQuery ? `${ixbrlUrl}?${versionQuery}` : ixbrlUrl
const downloadUrl = `${ixbrlUrl}?download=1${versionQuery ? `&${versionQuery}` : ''}`
const [submissions, setSubmissions] = useState<SubmissionRow[]>([])
const [loadingSubmissions, setLoadingSubmissions] = useState(false)
const [submissionsError, setSubmissionsError] = useState<string | null>(null)
useEffect(() => {
const signer = versions.find((version) => version.id === selectedVersionId)
?.certificate_signer
if (!signer) return
setFornamn(signer.first_name)
setEfternamn(signer.last_name)
setRoll(signer.role)
}, [selectedVersionId, versions])
const loadSubmissions = useCallback(async () => {
setLoadingSubmissions(true)
try {
@@ -179,6 +215,25 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
}
}, [periodId])
const loadVersions = useCallback(async () => {
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`,
)
if (!res.ok) return
const body = await res.json()
const rows = (body.data ?? []) as AnnualReportVersionSummary[]
setVersions(rows)
const latestSigned = rows.find(
(version) => version.status === 'signed' && version.digital_filing_eligible,
)
if (latestSigned) setSelectedVersionId((current) => current || latestSigned.id)
} catch {
// The version list is also visible in the studio. Submission stays
// disabled here until a signed version can be selected.
}
}, [periodId])
useEffect(() => {
let cancelled = false
fetch('/api/extensions/ext/bolagsverket/status')
@@ -190,26 +245,43 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
}
const body = await res.json()
setExtensionActive(true)
setFilingEnabled(body.data?.filing_enabled === true)
setEnvironment(body.data?.environment ?? 'test')
void loadSubmissions()
void Promise.all([
fetch('/api/extensions/ext/bolagsverket/grunduppgifter'),
fetch('/api/extensions/ext/bolagsverket/arendestatus'),
])
.then(async ([informationResponse, caseResponse]) => {
if (informationResponse.ok) {
const informationBody = await informationResponse.json()
if (!cancelled) setRegistryInformation(informationBody.data as RegistryInformation)
}
if (caseResponse.ok) {
const caseBody = await caseResponse.json()
if (!cancelled) setRegistryCase(caseBody.data as RegistryCaseStatus)
}
})
.catch(() => undefined)
})
.catch(() => {
if (!cancelled) setExtensionActive(false)
})
void loadVersions()
return () => {
cancelled = true
}
}, [loadSubmissions])
}, [loadSubmissions, loadVersions])
const handleValidate = async () => {
setValidating(true)
try {
const res = await fetch(
`${ixbrlUrl}/validate${utdelningValue > 0 ? `?utdelning=${utdelningValue}` : ''}`,
`${ixbrlUrl}/validate${versionQuery ? `?${versionQuery}` : ''}`,
)
const body = await res.json()
if (body?.error) {
toast({ title: 'Kunde inte validera', description: body.error.message, variant: 'destructive' })
toast({ title: 'Kunde inte validera', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
setValidation(body.data as ValidateResponse)
@@ -233,6 +305,10 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
toast({ title: 'Fyll i undertecknarens namn och e-post', variant: 'destructive' })
return
}
if (!selectedVersionId) {
toast({ title: 'Lås och underteckna en version före inlämning', variant: 'destructive' })
return
}
setSubmitting(true)
setUtfall(null)
try {
@@ -241,6 +317,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: periodId,
annual_report_version_id: selectedVersionId,
avsandare_pnr: normalizedAvsandare,
undertecknare: {
pnr: normalizedPnr,
@@ -249,14 +326,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
roll,
epost: epost.trim(),
},
...(utdelningValue > 0 ? { utdelning: utdelningValue } : {}),
...(avtal?.accepted ? { accepted_avtalstext_andrad: avtal.andrad } : {}),
...(opts.ignoreWarnings ? { ignore_warnings: true } : {}),
}),
})
const body = await res.json()
if (body?.error) {
toast({ title: 'Inlämningen misslyckades', description: body.error.message, variant: 'destructive' })
toast({ title: 'Inlämningen misslyckades', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
const result = body.data as SubmitOutcome
@@ -285,7 +361,17 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
void loadSubmissions()
return
}
setKvittens({ idnummer: result.idnummer, url: result.url })
if (result.outcome === 'state_unknown') {
setKvittens(result.url ? { url: result.url } : null)
void loadSubmissions()
toast({
title: 'Okänd status hos Bolagsverket',
description: result.message,
variant: 'destructive',
})
return
}
setKvittens({ url: result.url })
setUtfall(result.utfall.length > 0 ? result.utfall : null)
setAvtal(null)
void loadSubmissions()
@@ -306,7 +392,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
})
const body = await res.json()
if (body?.error) {
toast({ title: 'Kunde inte hämta händelser', description: body.error.message, variant: 'destructive' })
toast({ title: 'Kunde inte hämta händelser', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
void loadSubmissions()
@@ -340,31 +426,16 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="space-y-1.5 max-w-xs">
<Label htmlFor="di-utdelning">Föreslagen utdelning (kr)</Label>
<Input
id="di-utdelning"
inputMode="numeric"
placeholder="0"
value={utdelning}
onChange={(event) => setUtdelning(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Ingår i resultatdispositionen i dokumentet: 0 betyder att allt
balanseras i ny räkning. Beloppet följer med förhandsgranskning,
nedladdning och inlämning.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={() => setShowPreview((value) => !value)}>
<Button className="min-h-11" variant="outline" onClick={() => setShowPreview((value) => !value)}>
{showPreview ? 'Dölj förhandsgranskning' : 'Förhandsgranska iXBRL'}
</Button>
<Button variant="outline" asChild>
<Button className="min-h-11" variant="outline" asChild>
<a href={downloadUrl}>
<FileDown className="mr-2 h-4 w-4" /> Ladda ner iXBRL (.xhtml)
<FileDown className="mr-2 h-4 w-4" /> Ladda ner tekniskt iXBRL-underlag
</a>
</Button>
<Button onClick={() => void handleValidate()} disabled={validating}>
<Button className="min-h-11" onClick={() => void handleValidate()} disabled={validating}>
{validating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -436,13 +507,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<div className="space-y-3">
<p className="text-muted-foreground">
Bolagsverket-integrationen är inte aktiverad i den här installationen.
Ladda ner iXBRL-filen ovan och lämna in den manuellt via Bolagsverkets
e-tjänst, eller aktivera integrationen (kräver avtal med Bolagsverket och
organisationscertifikat).
Digital inlämning kräver en ansluten programvara. Använd PDF-flödet för
pappersinlämning per post, eller aktivera integrationen efter avtal,
organisationscertifikat och godkänt acceptanstest.
</p>
<Button variant="outline" asChild>
<Button className="min-h-11" variant="outline" asChild>
<a
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
href="https://bolagsverket.se/foretag/aktiebolag/arsredovisningforaktiebolag.759.html"
target="_blank"
rel="noopener noreferrer"
>
@@ -452,7 +523,14 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</div>
)}
{extensionActive === true && (
{extensionActive === true && !filingEnabled && (
<p className="text-muted-foreground">
Anslutningen finns installerad men säkerhetsgrinden för inlämning är stängd.
Den öppnas först efter genomförd acceptanstest och produktionsgodkännande.
</p>
)}
{extensionActive === true && filingEnabled && (
<>
{environment !== 'prod' && (
<Badge variant="warning">
@@ -460,11 +538,58 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</Badge>
)}
<div className="space-y-1.5">
<Label htmlFor="di-version">Undertecknad årsredovisningsversion</Label>
<select
id="di-version"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={selectedVersionId}
onChange={(event) => {
const versionId = event.target.value
setSelectedVersionId(versionId)
const signer = versions.find((version) => version.id === versionId)
?.certificate_signer
if (signer) {
setFornamn(signer.first_name)
setEfternamn(signer.last_name)
setRoll(signer.role)
}
}}
>
<option value="">Välj undertecknad version</option>
{versions
.filter(
(version) =>
version.status === 'signed' && version.digital_filing_eligible,
)
.map((version) => (
<option key={version.id} value={version.id}>
Version {version.version_number}: skapad {formatDate(version.created_at)}
</option>
))}
</select>
{versions.every(
(version) =>
version.status !== 'signed' || !version.digital_filing_eligible,
) && (
<p className="text-xs text-muted-foreground">
{tStudio('no_digital_version')}
</p>
)}
{selectedVersionId && (
<p className="text-xs text-muted-foreground">
Namn och roll för fastställelseintyget kommer från den låsta versionen och
måste vara oförändrade vid inlämning.
</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="di-avsandare-pnr">Ditt personnummer (avsändare)</Label>
<Input
id="di-avsandare-pnr"
className="min-h-11"
inputMode="numeric"
placeholder="ÅÅÅÅMMDDNNNN eller ÅÅMMDD-NNNN"
value={avsandarePnr}
@@ -475,6 +600,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<Label htmlFor="di-pnr">Undertecknarens personnummer</Label>
<Input
id="di-pnr"
className="min-h-11"
inputMode="numeric"
placeholder="ÅÅÅÅMMDDNNNN eller ÅÅMMDD-NNNN"
value={pnr}
@@ -483,19 +609,32 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</div>
<div className="space-y-1.5">
<Label htmlFor="di-fornamn">Undertecknarens förnamn</Label>
<Input id="di-fornamn" value={fornamn} onChange={(event) => setFornamn(event.target.value)} />
<Input
id="di-fornamn"
className="min-h-11"
value={fornamn}
onChange={(event) => setFornamn(event.target.value)}
readOnly={Boolean(selectedVersionId)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="di-efternamn">Undertecknarens efternamn</Label>
<Input id="di-efternamn" value={efternamn} onChange={(event) => setEfternamn(event.target.value)} />
<Input
id="di-efternamn"
className="min-h-11"
value={efternamn}
onChange={(event) => setEfternamn(event.target.value)}
readOnly={Boolean(selectedVersionId)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="di-roll">Roll</Label>
<select
id="di-roll"
className="border border-border rounded-md h-9 text-sm px-2 bg-background w-full"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={roll}
onChange={(event) => setRoll(event.target.value)}
disabled={Boolean(selectedVersionId)}
>
<option>Styrelseledamot</option>
<option>Styrelseordförande</option>
@@ -506,6 +645,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<Label htmlFor="di-epost">Undertecknarens e-post</Label>
<Input
id="di-epost"
className="min-h-11"
type="email"
placeholder="namn@foretag.se"
value={epost}
@@ -515,17 +655,42 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</div>
<p className="text-xs text-muted-foreground">
Personnumren skickas till Bolagsverket för att skapa eget utrymme och bjuda
in undertecknaren. De sparas inte i Accounted: endast en teknisk
referens (hash) lagras.
in undertecknaren. De sparas inte i Accounted och skrivs inte till loggar.
</p>
{registryInformation && (
<div className="rounded-md border border-border bg-muted/20 p-4 text-xs">
<p className="font-medium">Grunduppgifter från Bolagsverket</p>
<p className="mt-1 text-muted-foreground">
{registryInformation.namn}
{registryInformation.status[0]?.text
? `: ${registryInformation.status[0].text}`
: ''}
</p>
{registryInformation.rakenskapsperioder[0] && (
<p className="mt-1 text-muted-foreground">
Senaste period: {registryInformation.rakenskapsperioder[0].from} till{' '}
{registryInformation.rakenskapsperioder[0].tom}. Krav på
revisionsberättelse:{' '}
{registryInformation.rakenskapsperioder[0].kravPaRevisionsberattelse}.
</p>
)}
{registryCase && (
<p className="mt-1 text-muted-foreground">
Senaste ärendestatus: {registryCase.typ}
{registryCase.arendenummer ? `, ärende ${registryCase.arendenummer}` : ''}.
</p>
)}
</div>
)}
{avtal && (
<div className="rounded-lg border border-border p-4 space-y-3">
<p className="font-medium">Villkor för eget utrymme hos Bolagsverket</p>
<p className="text-muted-foreground whitespace-pre-wrap text-xs max-h-48 overflow-y-auto">
{avtal.text}
</p>
<label className="flex items-start gap-2 text-sm">
<label className="flex min-h-11 cursor-pointer items-start gap-3 text-sm">
<input
type="checkbox"
className="mt-0.5"
@@ -568,8 +733,14 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<div className="flex flex-wrap gap-3">
<Button
className="min-h-11"
onClick={() => void handleSubmit()}
disabled={submitting || blockingErrors || (avtal !== null && !avtal.accepted)}
disabled={
submitting ||
blockingErrors ||
!selectedVersionId ||
(avtal !== null && !avtal.accepted)
}
>
{submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -580,6 +751,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</Button>
{utfall && utfall.length > 0 && !utfallHasErrors && (
<Button
className="min-h-11"
variant="outline"
onClick={() => void handleSubmit({ ignoreWarnings: true })}
disabled={submitting}
@@ -596,11 +768,10 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
<p className="font-medium">Uppladdad till eget utrymme</p>
</div>
<p className="text-muted-foreground text-xs">
Dokument-id: <span className="tabular-nums">{kvittens.idnummer}</span>.
Undertecknaren har fått e-post från Bolagsverket och signerar
fastställelseintyget där. Ärendet startar först efter signering.
</p>
<Button variant="outline" size="sm" asChild>
<Button className="min-h-11" variant="outline" size="sm" asChild>
<a href={kvittens.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" /> Signera hos Bolagsverket
</a>
@@ -623,7 +794,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => void handlePollEvents()}>
<Button className="min-h-11" variant="outline" size="sm" onClick={() => void handlePollEvents()}>
<RefreshCcw className="mr-2 h-4 w-4" /> Uppdatera status
</Button>
</div>
@@ -653,14 +824,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
return (
<div
key={submission.id}
className="flex items-start justify-between gap-4 border-b border-border last:border-b-0 pb-3 last:pb-0"
className="flex flex-col gap-3 border-b border-border pb-3 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
>
<div className="space-y-1">
<Badge variant={badge.variant}>{badge.label}</Badge>
<p className="text-xs text-muted-foreground tabular-nums">
{envLabel ? `${envLabel} · ` : ''}
{formatDate(submission.created_at)}
{submission.idnummer ? ` · id ${submission.idnummer}` : ''}
{submission.undertecknare_namn ? ` · ${submission.undertecknare_namn}` : ''}
</p>
{submission.status === 'forelagd' && (
@@ -674,7 +844,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
)}
</div>
{submission.bolagsverket_url && submission.status === 'uploaded' && (
<Button variant="outline" size="sm" asChild>
<Button className="min-h-11" variant="outline" size="sm" asChild>
<a href={submission.bolagsverket_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1 h-3.5 w-3.5" /> Signera
</a>
+2 -1
View File
@@ -18,6 +18,7 @@ import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AddAccountDialogProps {
open: boolean
@@ -107,7 +108,7 @@ export function AddAccountDialog({
onCreated(createdAccount)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Något gick fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel')
} finally {
setIsSaving(false)
}
@@ -29,6 +29,7 @@ import {
} from 'lucide-react'
import type { BASAccount } from '@/types'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ---------------------------------------------------------------------------
// Types
@@ -243,7 +244,7 @@ export default function ChartOfAccountsManager() {
await refreshAll()
} catch (err) {
toast({
title: err instanceof Error ? err.message : t('toast_delete_failed'),
title: err instanceof Error ? getUserErrorMessage(err) : t('toast_delete_failed'),
variant: 'destructive',
})
} finally {
+1 -1
View File
@@ -1233,7 +1233,7 @@ export default function JournalEntryList() {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium">
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium sensitive-field">
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
</span>
<div className="flex shrink-0 items-center gap-1">
@@ -19,6 +19,7 @@ import {
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Search, Trash2 } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface PruneCandidate {
account_number: string
@@ -188,7 +189,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
onOpenChange(false)
} catch (err) {
toast({
title: err instanceof Error && err.message ? err.message : t('toast_prune_failed'),
title: err instanceof Error && getUserErrorMessage(err) ? getUserErrorMessage(err) : t('toast_prune_failed'),
variant: 'destructive',
})
} finally {
@@ -23,6 +23,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { AssetCategory, DepreciationMethod, K3Component } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface CreateAssetDialogProps {
open: boolean
@@ -247,7 +248,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte spara tillgången')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara tillgången')
return
}
toast({ title: 'Tillgång sparad', description: name.trim() })
@@ -260,7 +261,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
setComponentRows([])
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setSubmitting(false)
}
@@ -12,6 +12,7 @@ import { ArrowRight, Loader2, Plus, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { AccrualsProposal } from '@/lib/bokslut/accruals/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AccrualsStepProps {
periodId: string
@@ -56,7 +57,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte ladda periodiseringar')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
setProposal(body.data as AccrualsProposal)
@@ -140,7 +141,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
@@ -152,7 +153,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
})
onContinue()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
@@ -18,6 +18,7 @@ import {
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { Asset } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface ProposalItem {
asset: Asset
@@ -55,12 +56,12 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`)
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte ladda avskrivningar')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda avskrivningar')
return
}
setProposal(body.data as Proposal)
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setLoading(false)
}
@@ -80,7 +81,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte bokföra avskrivningar')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra avskrivningar')
return
}
const posted = body.data?.posted?.length ?? 0
@@ -92,7 +93,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
onPosted()
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
@@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowRight, AlertTriangle, Loader2 } from 'lucide-react'
import { ArrowRight, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { DepreciationPanel } from './DepreciationPanel'
@@ -17,7 +17,9 @@ import type {
DispositionsProposal,
ProposedDisposition,
DispositionKind,
TaxAdjustmentSnapshot,
} from '@/lib/bokslut/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface DispositionsStepProps {
periodId: string
@@ -30,6 +32,12 @@ interface UiState {
selections: Record<string, { accept: boolean; overrideAmount?: number; lockedSkip: boolean }>
}
interface TaxAdjustmentDraft {
nonDeductibleExpenses: string
nonTaxableIncome: string
detectedAccounts: { '6992': boolean; '8423': boolean }
}
/**
* Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions
* API, lets the user adjust amounts (or skip) per proposal, then POSTs the
@@ -47,6 +55,9 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const [ui, setUi] = useState<UiState>({ selections: {} })
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [savingAdjustments, setSavingAdjustments] = useState(false)
const [adjustmentError, setAdjustmentError] = useState<string | null>(null)
const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState<TaxAdjustmentDraft>(emptyTaxDraft)
// ---- Fetch proposals ----
const loadProposals = useCallback(async () => {
@@ -58,24 +69,13 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
const body = await res.json()
if (!res.ok) {
setFetchError(body?.error?.message ?? 'Kunde inte ladda dispositioner')
setFetchError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda dispositioner')
return
}
const data = body.data as DispositionsProposal
setProposal(data)
const selections: UiState['selections'] = {}
data.proposals.forEach((p, index) => {
// Key must match the render loop and buildPostItems, which both pass
// the array index: omitting it here defaulted every non-ateforing
// key to ":0", so only the first proposal card ever rendered.
const key = proposalKey(p, index)
selections[key] = {
accept: true,
overrideAmount: p.amount,
lockedSkip: Boolean(p.required),
}
})
setUi({ selections })
setUi(createUiState(data))
setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
} catch {
setFetchError('Kunde inte ladda dispositioner')
} finally {
@@ -83,6 +83,52 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
}
}, [periodId])
const handleSaveAdjustments = useCallback(async () => {
setSavingAdjustments(true)
setAdjustmentError(null)
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
manualAdjustments: {
nonDeductibleExpenses: parseNonNegativeAmount(
taxAdjustmentDraft.nonDeductibleExpenses,
),
nonTaxableIncome: parseNonNegativeAmount(taxAdjustmentDraft.nonTaxableIncome),
},
detectedAccounts: taxAdjustmentDraft.detectedAccounts,
}),
},
)
const body = await res.json()
if (!res.ok) {
setAdjustmentError(
getUserErrorMessage(body?.error) ?? 'Kunde inte spara skattemässiga justeringar',
)
return
}
const data = body.data as DispositionsProposal
setProposal(data)
setUi(createUiState(data))
setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
toast({
title: 'Skatteunderlaget är uppdaterat',
description: 'Bolagsskatten har räknats om utan att ändra periodiseringsfonden.',
})
} catch (err) {
setAdjustmentError(
err instanceof Error
? getUserErrorMessage(err)
: 'Kunde inte spara skattemässiga justeringar',
)
} finally {
setSavingAdjustments(false)
}
}, [periodId, taxAdjustmentDraft, toast])
useEffect(() => {
void loadProposals()
}, [loadProposals])
@@ -90,6 +136,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
// ---- POST accepted dispositions ----
const handleCommit = useCallback(async () => {
if (!proposal) return
if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) {
setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.')
return
}
setPosting(true)
setPostError(null)
try {
@@ -106,7 +156,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
})
const body = await res.json()
if (!res.ok) {
setPostError(body?.error?.message ?? 'Kunde inte bokföra dispositioner')
setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra dispositioner')
return
}
const created = body.data?.created ?? []
@@ -118,7 +168,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
})
onContinue()
} catch (err) {
setPostError(err instanceof Error ? err.message : 'Okänt fel')
setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
@@ -149,6 +199,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
if (!proposal) return null
const hasCorrectionRequired = proposal.completedDispositions?.some(
(item) => item.status === 'needs_correction',
) ?? false
// EF: depreciation can apply (skattemässig hanteras separat); replace the
// AB-only dispositioner with a NE-bilaga declaration section.
if (proposal.entityType !== 'aktiebolag') {
@@ -171,29 +225,6 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
}
if (proposal.proposals.length === 0) {
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<Card>
<CardHeader>
<CardTitle className="text-base">Inga dispositioner föreslagna</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Bolaget har varken pensionskostnader, periodiseringsfonder att hantera, vinst att skatta
på, eller skattemässiga avskrivningar att boka.
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>Tillbaka</Button>
<Button onClick={onContinue}>
Fortsätt <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
@@ -210,6 +241,60 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
</CardHeader>
</Card>
{proposal.taxAdjustments && (
<TaxAdjustmentsCard
snapshot={proposal.taxAdjustments}
draft={taxAdjustmentDraft}
taxProposal={proposal.proposals.find((item) => item.kind === 'bolagsskatt')}
saving={savingAdjustments}
error={adjustmentError}
onChange={setTaxAdjustmentDraft}
onSave={() => void handleSaveAdjustments()}
/>
)}
{(proposal.completedDispositions ?? []).map((completed) => (
<Card key={`completed-${completed.kind}`}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{completed.label}</CardTitle>
<Badge
variant={completed.status === 'booked' ? 'success' : 'warning'}
className="mt-2 gap-1"
>
{completed.status === 'booked' ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{completed.status === 'booked' ? 'Redan bokförd' : 'Behöver rättas'}
</Badge>
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(completed.amount)}
</p>
</div>
</CardHeader>
{completed.warnings.length > 0 && (
<CardContent className="space-y-2">
{completed.warnings.map((warning) => (
<p key={warning} className="text-sm text-warning-foreground">{warning}</p>
))}
</CardContent>
)}
</Card>
))}
{proposal.proposals.length === 0
&& (proposal.completedDispositions ?? []).length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
Inga bokslutsdispositioner behöver bokföras utifrån det aktuella underlaget.
</CardContent>
</Card>
)}
{proposal.proposals.map((p, i) => {
const key = proposalKey(p, i)
const sel = ui.selections[key]
@@ -240,7 +325,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
<Button variant="outline" onClick={onBack} disabled={posting}>
Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting}>
<Button onClick={handleCommit} disabled={posting || hasCorrectionRequired}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
@@ -256,6 +341,136 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
}
function TaxAdjustmentsCard({
snapshot,
draft,
taxProposal,
saving,
error,
onChange,
onSave,
}: {
snapshot: TaxAdjustmentSnapshot
draft: TaxAdjustmentDraft
taxProposal?: ProposedDisposition
saving: boolean
error: string | null
onChange: (draft: TaxAdjustmentDraft) => void
onSave: () => void
}) {
const detected = snapshot.items.filter(
(item) => item.source === 'detected' && item.accountNumber && item.amount > 0,
)
const computation = taxProposal?.computation
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Skattemässiga justeringar</CardTitle>
<p className="text-sm text-muted-foreground">
Återläggningar påverkar skatten och INK2, men skapar ingen egen verifikation och ändrar
inte en redan bokförd periodiseringsfond.
</p>
</CardHeader>
<CardContent className="space-y-5">
{detected.length > 0 && (
<div className="space-y-3">
<Label>Upptäckt i bokföringen</Label>
{detected.map((item) => {
const account = item.accountNumber as '6992' | '8423'
return (
<div key={item.sourceKey} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Checkbox
id={`tax-adjustment-${account}`}
checked={draft.detectedAccounts[account]}
onCheckedChange={(checked) =>
onChange({
...draft,
detectedAccounts: {
...draft.detectedAccounts,
[account]: Boolean(checked),
},
})
}
/>
<Label htmlFor={`tax-adjustment-${account}`} className="cursor-pointer">
Konto {account}: {item.description}
</Label>
</div>
<span className="tabular-nums font-medium">{formatCurrency(item.amount)}</span>
</div>
)
})}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="manual-non-deductible">Ytterligare ej avdragsgilla kostnader</Label>
<Input
id="manual-non-deductible"
type="number"
min="0"
step="0.01"
value={draft.nonDeductibleExpenses}
onChange={(event) =>
onChange({ ...draft, nonDeductibleExpenses: event.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-non-taxable">Ej skattepliktiga intäkter</Label>
<Input
id="manual-non-taxable"
type="number"
min="0"
step="0.01"
value={draft.nonTaxableIncome}
onChange={(event) => onChange({ ...draft, nonTaxableIncome: event.target.value })}
/>
</div>
</div>
{computation && (
<div className="rounded-lg bg-muted/50 p-4 text-sm space-y-1">
<CalculationRow label="Resultat efter dispositioner" amount={numberValue(computation.resultBeforeTax)} />
<CalculationRow label="Ej avdragsgilla kostnader" amount={numberValue(computation.nonDeductibleExpenses)} prefix="+" />
<CalculationRow label="Ej skattepliktiga intäkter" amount={numberValue(computation.nonTaxableIncome)} prefix="-" />
<CalculationRow label="Skattemässigt resultat" amount={numberValue(computation.taxableResultClamped)} strong />
<CalculationRow label="Bolagsskatt 20,6 %" amount={numberValue(computation.taxAmount)} strong />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="button" variant="outline" onClick={onSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara och räkna om
</Button>
</CardContent>
</Card>
)
}
function CalculationRow({
label,
amount,
prefix = '',
strong = false,
}: {
label: string
amount: number
prefix?: string
strong?: boolean
}) {
return (
<div className={`flex justify-between gap-4 ${strong ? 'font-medium' : ''}`}>
<span>{label}</span>
<span className="tabular-nums">{prefix}{formatCurrency(amount)}</span>
</div>
)
}
function ProposalCard({
proposal,
accept,
@@ -372,7 +587,7 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
switch (p.kind) {
case 'bolagsskatt':
items.push({ kind: 'bolagsskatt', manualAdjustments: {} })
items.push({ kind: 'bolagsskatt' })
break
case 'sarskild_loneskatt':
items.push({ kind: 'sarskild_loneskatt' })
@@ -405,3 +620,53 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
}
return items
}
const emptyTaxDraft: TaxAdjustmentDraft = {
nonDeductibleExpenses: '0',
nonTaxableIncome: '0',
detectedAccounts: { '6992': false, '8423': false },
}
function createUiState(proposal: DispositionsProposal): UiState {
const selections: UiState['selections'] = {}
proposal.proposals.forEach((item, index) => {
const key = proposalKey(item, index)
selections[key] = {
accept: true,
overrideAmount: item.amount,
lockedSkip: Boolean(item.required),
}
})
return { selections }
}
function createTaxAdjustmentDraft(
snapshot: TaxAdjustmentSnapshot | undefined,
): TaxAdjustmentDraft {
if (!snapshot) return emptyTaxDraft
const manualNonDeductible = snapshot.items.find(
(item) => item.sourceKey === 'manual:non_deductible_expenses',
)
const manualNonTaxable = snapshot.items.find(
(item) => item.sourceKey === 'manual:non_taxable_income',
)
const account6992 = snapshot.items.find((item) => item.sourceKey === 'account:6992')
const account8423 = snapshot.items.find((item) => item.sourceKey === 'account:8423')
return {
nonDeductibleExpenses: String(manualNonDeductible?.amount ?? 0),
nonTaxableIncome: String(manualNonTaxable?.amount ?? 0),
detectedAccounts: {
'6992': Boolean(account6992?.included),
'8423': Boolean(account8423?.included),
},
}
}
function parseNonNegativeAmount(value: string): number {
const parsed = Number(value.replace(',', '.'))
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
function numberValue(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
@@ -11,6 +11,7 @@ import { AlertTriangle, FileDown, Info } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { EgenavgiftCategory } from '@/lib/bokslut/enskild-firma/egenavgifter-calculator'
import type { EfDeclarationItem } from '@/lib/bokslut/enskild-firma/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface EfDeclarationSectionProps {
fiscalPeriodId: string
@@ -129,7 +130,7 @@ export function EfDeclarationSection({
)
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte ladda EF-deklaration')
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda EF-deklaration')
setPreview(null)
return
}
+31 -17
View File
@@ -51,6 +51,16 @@ interface Props {
*/
onReady?: () => void
className?: string
/** Server-loaded periods for the first render, scoped to initialCompanyId. */
initialPeriods?: FiscalPeriod[]
initialCompanyId?: string | null
}
function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
const today = new Date().toISOString().split('T')[0]
return periods
.filter((period) => !hideFuturePeriods || period.period_start <= today)
.sort((a, b) => b.period_start.localeCompare(a.period_start))
}
/**
@@ -71,11 +81,16 @@ export function FiscalYearSelector({
hideFuturePeriods = false,
onReady,
className,
initialPeriods,
initialCompanyId,
}: Props) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [loaded, setLoaded] = useState(false)
const canUseInitialPeriods = initialCompanyId === company?.id && initialPeriods !== undefined
const [periods, setPeriods] = useState<FiscalPeriod[]>(() =>
canUseInitialPeriods ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
)
const [loaded, setLoaded] = useState(canUseInitialPeriods)
const effectiveLabel = label === null ? null : (label ?? t('label'))
useEffect(() => {
@@ -87,24 +102,23 @@ export function FiscalYearSelector({
}
let cancelled = false
;(async () => {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) {
setLoaded(true)
onReady?.()
let fetched: FiscalPeriod[]
if (initialCompanyId === company.id && initialPeriods !== undefined) {
fetched = preparePeriods(initialPeriods, hideFuturePeriods)
} else {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) {
setLoaded(true)
onReady?.()
}
return
}
return
const { data } = await res.json()
fetched = preparePeriods(data || [], hideFuturePeriods)
}
const { data } = await res.json()
if (cancelled) return
let fetched: FiscalPeriod[] = data || []
if (hideFuturePeriods) {
const today = new Date().toISOString().split('T')[0]
fetched = fetched.filter((p) => p.period_start <= today)
}
// Newest first: most migrations list recent years at the top
fetched.sort((a, b) => b.period_start.localeCompare(a.period_start))
setPeriods(fetched)
setLoaded(true)
@@ -131,7 +145,7 @@ export function FiscalYearSelector({
// onReady is intentionally excluded from deps: it's a lifecycle callback that
// should fire once per load, not re-trigger if the parent re-creates it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id, hideFuturePeriods, includeAllOption])
}, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods])
const handleChange = (next: string) => {
const nextPeriodId = next === ALL_YEARS_VALUE ? null : next
+10 -51
View File
@@ -1,6 +1,5 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
@@ -20,12 +19,10 @@ import {
ArrowRight,
MessageCircle,
} from 'lucide-react'
import type { Deadline, OnboardingProgress } from '@/types'
import type { Deadline, InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
interface DashboardContentProps {
companyId: string
summary: {
@@ -45,6 +42,7 @@ interface DashboardContentProps {
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
suggestedMatches: SuggestedMatch[]
onboardingProgress?: OnboardingProgress
initialSetup: InitialSetupState
/**
* False until the company has a verified agent_profile. When false the hero
* slot shows a build-assistant prompt instead of the next-best-action card,
@@ -54,56 +52,10 @@ interface DashboardContentProps {
agentBuilt?: boolean
}
export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, initialSetup, agentBuilt = true }: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
// The setup gate exists to nudge brand-new users into a data-import step
// before they hit the dashboard. Once the assistant is built we treat the
// user as past that phase (they've already committed to using the tool)
// and let the dashboard render normally. This also keeps the sandbox
// (which ships with a pre-built assistant + seeded data but no bank
// connection / SIE import) from showing a checklist that re-links to
// /onboarding/agent.
const needsSetup =
!agentBuilt &&
onboardingProgress &&
!onboardingProgress.hasBankConnected &&
!onboardingProgress.hasSIEImport
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
useEffect(() => {
if (!needsSetup) {
setSetupGateActive(false)
return
}
const scopedKey = setupFreshStartKey(companyId)
const freshStart = localStorage.getItem(scopedKey) === 'true'
const legacyFreshStart = localStorage.getItem('erp_setup_fresh_start') === 'true'
const legacyDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true'
if (freshStart || legacyFreshStart || legacyDismissed) {
if (!freshStart) {
localStorage.setItem(scopedKey, 'true')
}
setSetupGateActive(false)
}
}, [needsSetup, companyId])
if (setupGateActive) {
return (
<NewUserChecklist
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
hasBankConnected={!!onboardingProgress?.hasBankConnected}
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
hasAgentBuilt={agentBuilt}
onFreshStart={() => {
localStorage.setItem(setupFreshStartKey(companyId), 'true')
setSetupGateActive(false)
}}
/>
)
}
const formatLargeNumber = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'decimal',
@@ -124,6 +76,13 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
return (
<div className="stagger-enter space-y-8">
<BackupHealthBanner />
<NewUserChecklist
initialState={initialSetup}
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
hasBankConnected={!!onboardingProgress?.hasBankConnected}
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
hasAgentBuilt={agentBuilt}
/>
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
of a full-screen onboarding takeover. Once the assistant is built the
+1
View File
@@ -120,6 +120,7 @@ export function DeadlineForm({
linked_report_type: null,
linked_report_period: null,
dismissed_at: null,
tax_assessment_notice_id: null,
})
} finally {
setIsLoading(false)
+2 -1
View File
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus, TAX_DEADLINE_TYPE_LABELS, DEADLINE_STATUS_LABELS } from '@/types'
import { getReportUrl } from '@/lib/tax/deadline-config'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Use the labels from types
const STATUS_LABELS = DEADLINE_STATUS_LABELS
@@ -95,7 +96,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -13,6 +13,7 @@ import {
STATUS_LABELS,
} from '@/lib/calendar/utils'
import { Calendar, ChevronRight, AlertTriangle, Clock, Check, Send, Loader2 } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface UpcomingDeadlinesWidgetProps {
deadlines: Deadline[]
@@ -85,7 +86,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -13,6 +13,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import Link from 'next/link'
import { FallbackPrompt } from '@/components/ui/fallback-prompt'
import { getBranding } from '@/lib/branding/service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
import {
@@ -1827,7 +1828,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte hämta förhandsgranskning')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta förhandsgranskning')
} finally {
setIsLoading(false)
}
@@ -1866,7 +1867,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
}
// Token-based providers stay on connect step for credential input
} catch (err) {
setError(err instanceof Error ? err.message : 'Anslutning misslyckades')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Anslutning misslyckades')
} finally {
setIsLoading(false)
}
@@ -1925,7 +1926,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('connect')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte återansluta')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta')
setAuthExpired(true)
} finally {
setIsLoading(false)
@@ -1947,7 +1948,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
} catch (err) {
toast({ title: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
toast({ title: err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel', variant: 'destructive' })
}
}, [toast, fetchStatus])
@@ -1978,7 +1979,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
// Token stored: consent is now accepted, proceed to preview
await loadPreview(consentId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte ansluta')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte ansluta')
} finally {
setIsLoading(false)
}
@@ -2079,7 +2080,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('options')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte hämta SIE-data')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta SIE-data')
} finally {
setIsLoading(false)
}
@@ -2229,7 +2230,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
description: 'Din bokföringsdata har importerats.',
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Migrering misslyckades'
const msg = getUserErrorMessage(err)
setError(msg)
setStep('result')
}
@@ -17,6 +17,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { InvoiceExtractionResult, VatTreatment } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Minimal shape the dialog needs from the workspace's inbox items.
interface BulkBookInboxItem {
@@ -145,7 +146,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
} catch (err) {
toast({
title: 'Bokföringen misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
} finally {
@@ -16,6 +16,7 @@ import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import type { CompanyInboundDomain, InboundDomainDnsRecord } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
@@ -83,7 +84,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Kunde inte lägga till domänen',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -106,7 +107,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Kontrollen misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -127,7 +128,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Borttagningen misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -48,6 +48,7 @@ import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDi
// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts.
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type AccountingMethod = 'accrual' | 'cash'
@@ -458,7 +459,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Kunde inte ladda dokumentet',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
}
@@ -534,7 +535,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
toast({
title: 'Uppladdning misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -597,7 +598,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Kunde inte ta bort',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -697,7 +698,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Rotation misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -2049,7 +2050,7 @@ export function EditableFieldsList({
toast({
variant: 'destructive',
title: 'Nätverksfel',
description: err instanceof Error ? err.message : 'Kunde inte spara',
description: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte spara',
})
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
}
@@ -16,6 +16,7 @@ import {
Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ── Types ─────────────────────────────────────────────────────
@@ -192,7 +193,7 @@ export default function MockDataImportDialog<T>({
await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length })
handleOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Import misslyckades')
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('map-csv')
}
}, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange])
@@ -206,7 +207,7 @@ export default function MockDataImportDialog<T>({
await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 })
handleOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Import misslyckades')
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('preview-json')
}
}, [jsonReport, onImport, fileName, handleOpenChange])
+2 -1
View File
@@ -23,6 +23,7 @@ import {
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { InvoiceExtractionResult } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// TransactionMatchPicker
//
@@ -227,7 +228,7 @@ export default function TransactionMatchPicker({
if (error) {
toast({
title: 'Kunde inte hämta transaktioner',
description: error.message,
description: getUserErrorMessage(error),
variant: 'destructive',
})
setRawRows([])
@@ -14,6 +14,7 @@ import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface BankDetailsSetupDialogProps {
open: boolean
@@ -148,7 +149,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
} catch (error) {
toast({
title: t('save_failed_title'),
description: error instanceof Error ? error.message : t('save_failed_fallback'),
description: error instanceof Error ? getUserErrorMessage(error) : t('save_failed_fallback'),
variant: 'destructive',
})
} finally {
@@ -31,6 +31,7 @@ import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { Plus, Trash2 } from 'lucide-react'
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -202,7 +203,7 @@ function NewRecurringScheduleForm({
} catch (err) {
toast({
title: schedule ? t('update_failed_title') : t('create_failed_title'),
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
+1 -1
View File
@@ -168,7 +168,7 @@ export default function PaymentBookingDialog({
if (cancelled) return
toast({
title: t('load_dialog_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
description: err instanceof Error ? getErrorMessage(err) : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
+3 -2
View File
@@ -29,6 +29,7 @@ import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -151,7 +152,7 @@ export default function SendInvoiceDialog({
if (cancelled) return
toast({
title: t('load_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
description: err instanceof Error ? getUserErrorMessage(err) : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -345,7 +346,7 @@ export default function SendInvoiceDialog({
} catch (error) {
toast({
title: t(isCreditNote ? 'credit_send_failed_title' : 'send_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('try_again'),
variant: 'destructive',
})
}
+154 -328
View File
@@ -1,365 +1,191 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
ArrowRight,
CheckCircle2,
FileCheck,
FileText,
Landmark,
ArrowRightLeft,
FileCheck,
Landmark,
MessageCircle,
ShieldCheck,
Sparkles,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getBranding } from '@/lib/branding/service'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
const branding = getBranding()
import type { InitialSetupPath, InitialSetupState } from '@/types'
interface NewUserChecklistProps {
onFreshStart: () => void
initialState: InitialSetupState
className?: string
/**
* Per-step completion flags. The render flips each step from CTA to a
* compact "done" card when its corresponding flag is true so the user
* sees their progress without having to remember what they finished.
*/
hasBookkeepingImported?: boolean
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasAgentBuilt?: boolean
}
const pathIcons = {
migration: ArrowRightLeft,
bank: Landmark,
fresh: Sparkles,
} as const
export default function NewUserChecklist({
onFreshStart,
initialState,
className,
hasBookkeepingImported,
hasBankConnected,
hasSkatteverketConnected,
hasAgentBuilt,
hasBookkeepingImported = false,
hasBankConnected = false,
hasSkatteverketConnected = false,
hasAgentBuilt = false,
}: NewUserChecklistProps) {
const t = useTranslations('new_user_checklist')
const t = useTranslations('initial_setup')
const router = useRouter()
const showError = useErrorToast()
const hasAi = useCapability(CAPABILITY.ai)
const [state, setState] = useState(initialState)
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
const hasAi = useCapability(CAPABILITY.ai)
const persist = async (
body: Record<string, unknown>,
pending: InitialSetupPath | 'dismiss' | 'complete',
): Promise<InitialSetupState | null> => {
setSaving(pending)
try {
const response = await fetch('/api/onboarding/state', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
await showError(response, { context: 'settings' })
return null
}
const payload = await response.json() as { data: InitialSetupState }
setState(payload.data)
return payload.data
} catch (error) {
await showError(error, { context: 'settings' })
return null
} finally {
setSaving(null)
}
}
useEffect(() => {
const selectedPathComplete =
(state.path === 'migration' && hasBookkeepingImported) ||
(state.path === 'bank' && hasBankConnected)
if (!state.completedAt && selectedPathComplete && saving === null) {
void persist({ completed: true }, 'complete')
}
// persist intentionally stays out: its identity follows the toast hook and
// would retrigger this completion sync after every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasBankConnected, hasBookkeepingImported, saving, state.completedAt, state.path])
if (state.dismissedAt || state.completedAt) return null
const choosePath = async (path: InitialSetupPath) => {
const updated = await persist({ path }, path)
if (!updated || path === 'fresh') return
if (path === 'migration') {
router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
} else {
router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
}
}
if (!state.path) {
return (
<Card className={cn('border-foreground/20', className)}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{(['migration', 'bank', 'fresh'] as const).map((path) => {
const Icon = pathIcons[path]
return (
<button
key={path}
type="button"
disabled={saving !== null}
onClick={() => void choosePath(path)}
className="group min-h-28 rounded-lg border border-border p-4 text-left transition-colors hover:border-foreground/40 hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<div className="flex items-center justify-between gap-3">
<Icon className="h-5 w-5" />
<ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<p className="mt-4 text-sm font-medium">{t(`${path}_title`)}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{t(`${path}_description`)}
</p>
</button>
)
})}
</CardContent>
</Card>
)
}
const primaryHref = state.path === 'migration'
? (hasMigration ? '/import?mode=migration' : '/import?mode=sie')
: (hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
const PrimaryIcon = pathIcons[state.path]
return (
<div className={cn('min-h-[75vh] flex flex-col items-center justify-center px-4 sm:px-0 stagger-enter', className)}>
<div className="w-full max-w-2xl">
{/* Header: centered welcome. Data-import steps lead; building the
assistant is the last step so a user coming from another system
brings their books in first. */}
<div className="text-center mb-8 md:mb-12">
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
{t('welcome', { appName: branding.appName.toLowerCase() })}
</h1>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto mt-3">
{t('intro')}
</p>
<Card className={cn('border-foreground/20', className)}>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-lg">{t(`${state.path}_selected_title`)}</CardTitle>
<CardDescription className="mt-1">{t(`${state.path}_selected_description`)}</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
disabled={saving !== null}
onClick={() => void persist({ dismissed: true }, 'dismiss')}
>
{t('dismiss')}
</Button>
</div>
{/* Step 1: Migrate bookkeeping */}
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasBookkeepingImported
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasBookkeepingImported ? <CheckCircle2 className="h-4 w-4" /> : '1'}
</span>
<h2 className="font-display text-base tracking-tight">
{t('step1_title')}
</h2>
</div>
{hasBookkeepingImported ? (
<div className="ml-0 sm:ml-10 p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
<p className="text-sm text-foreground font-medium">{t('step1_done_title')}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t('step1_done_description')}</p>
</div>
) : (
<div className="space-y-3 ml-0 sm:ml-10">
{hasMigration && (
<Link
href="/import?mode=migration"
className="group block p-4 sm:p-6 rounded-lg border border-border bg-secondary/40 hover:bg-secondary/60 hover:border-primary/40 transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-primary/[0.08] group-hover:bg-primary/[0.12] transition-colors flex-shrink-0">
<ArrowRightLeft className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
{t('migrate_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed underline decoration-foreground/20 underline-offset-2">
{t('migrate_description')}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{([
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
{ name: 'Visma', logo: '/logos/visma.jpeg' },
{ name: 'Bokio', logo: '/logos/bokio.png' },
{ name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
{ name: 'SIE4-fil', logo: null },
] as const).map(provider => (
<div key={provider.name} className="flex items-center gap-1 rounded border border-border bg-muted/30 px-2 py-1">
{provider.logo ? (
<img src={provider.logo} alt={provider.name} className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 rounded-sm object-contain" />
) : (
<FileText className="h-3.5 w-3.5 sm:h-4 sm:w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-[10px] sm:text-[11px] font-medium text-muted-foreground">{provider.name}</span>
</div>
))}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</Link>
)}
<Link
href="/import?mode=sie"
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<FileText className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
{t('sie_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
{t('sie_description')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Button asChild>
<Link href={primaryHref}>
<PrimaryIcon className="mr-2 h-4 w-4" />
{t(`${state.path}_action`)}
</Link>
</Button>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
{hasSkatteverket && !hasSkatteverketConnected && (
// The authorize endpoint redirects off-site to Skatteverket.
// eslint-disable-next-line @next/next/no-html-link-for-pages
<a href="/api/extensions/ext/skatteverket/authorize?return_to=/" className="inline-flex min-h-10 items-center hover:text-foreground">
<FileCheck className="mr-2 h-4 w-4" />
{t('optional_skatteverket')}
</a>
)}
{!hasAgentBuilt && (
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="inline-flex min-h-10 items-center hover:text-foreground">
<MessageCircle className="mr-2 h-4 w-4" />
{t('optional_assistant')}
</Link>
</div>
)}
</div>
{/* Step 2: Connect bank */}
<div className="mb-6 md:mb-8">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasBankConnected
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasBankConnected ? <CheckCircle2 className="h-4 w-4" /> : '2'}
</span>
<h2 className="font-display text-base tracking-tight">
{t('step2_title')}
</h2>
</div>
<div className="ml-0 sm:ml-10">
{hasBankConnected ? (
<div className="p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
<p className="text-sm text-foreground font-medium">Bank kopplad</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Transaktioner synkas automatiskt.</p>
</div>
) : (
<Link
href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<Landmark className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
{t('bank_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
{hasBanking
? t('bank_description_psd2')
: t('bank_description_file')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</Link>
)}
</div>
</div>
{/* Step 3: Connect Skatteverket, only when the extension is enabled.
Optional: connecting here lets Accounted submit moms + AGI and read
skattekonto saldo, but the user can skip and do it later from
/settings/skatteverket. The OAuth flow returns to the dashboard
via return_to=/, which clears the gate via the same path the
user would take naturally. */}
{hasSkatteverket && (
<div className="mb-8 md:mb-12">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasSkatteverketConnected
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasSkatteverketConnected
? <CheckCircle2 className="h-4 w-4" />
: '3'}
</span>
<h2 className="font-display text-base tracking-tight">
{t('step3_title')}
</h2>
<span className="text-xs text-muted-foreground">{t('optional_suffix')}</span>
</div>
<div className="ml-0 sm:ml-10">
{hasSkatteverketConnected ? (
<div className="block p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-background flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-foreground">
{t('skatteverket_connected_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
{t('skatteverket_connected_description')}
</p>
</div>
</div>
</div>
) : (
// eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page
<a
// Plain anchor: the authorize endpoint 302-redirects to
// skatteverket.se; <Link> would route via Next's client
// router which doesn't follow cross-origin redirects.
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<FileCheck className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
{t('skatteverket_connect_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
{t('skatteverket_connect_description', { appName: branding.appName.toLowerCase() })}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</a>
)}
</div>
</div>
)}
{/* Build the assistant: always the last step, so a user migrating
from another system brings their books in first. */}
<div className="mb-8 md:mb-12">
<div className="flex items-center gap-3 mb-4">
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 tabular-nums',
hasAgentBuilt
? 'bg-secondary text-foreground'
: 'bg-foreground text-background',
)}>
{hasAgentBuilt ? <CheckCircle2 className="h-4 w-4" /> : (hasSkatteverket ? '4' : '3')}
</span>
<h2 className="font-display text-base tracking-tight">
Skapa din assistent
</h2>
</div>
<div className="ml-0 sm:ml-10">
{hasAgentBuilt ? (
<div className="p-4 sm:p-6 rounded-lg border border-border bg-secondary/40">
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-background flex-shrink-0">
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-foreground">
Assistenten är klar
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
Du kan börja chatta direkt. Justera tonalitet och kunskap i Inställningar &gt; Assistentens minne.
</p>
</div>
</div>
</div>
) : (
<Link
href={hasAi ? '/onboarding/agent' : '/settings/billing'}
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
<div className="p-2 sm:p-3 rounded-lg bg-muted/60 group-hover:bg-primary/[0.08] transition-colors flex-shrink-0">
<MessageCircle className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Bygg din bokföringsassistent
</p>
<Badge variant="secondary" className="uppercase tracking-wider">
{hasAi ? 'Beta' : 'Abonnemang'}
</Badge>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
{hasAi
? 'Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.'
: 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig. Uppgradera för att komma igång.'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
</div>
</Link>
)}
</div>
</div>
{/* Escape hatch: a visible secondary action, not a muted ghost link,
so users with nothing to import can clearly choose to start fresh. */}
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">{t('or_separator')}</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex justify-center">
<Button variant="outline" onClick={onFreshStart} className="group">
{t('fresh_start')}
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-0.5 transition-transform" />
</Button>
</div>
<div className="flex items-center justify-center gap-2 pt-2">
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground/50">
{t('security_note')}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
-34
View File
@@ -1,34 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import NewUserChecklist from './NewUserChecklist'
interface Props {
companyId: string
hasBookkeepingImported: boolean
hasBankConnected: boolean
hasSkatteverketConnected: boolean
}
// Thin client wrapper around NewUserChecklist, shown only to a genuinely empty
// company (no data, no assistant). The data-import steps lead and building the
// assistant is the last step; the "I'm starting fresh" escape hatch forwards
// straight to /onboarding/agent for users with no books to bring in.
export default function WelcomeGate({
companyId: _companyId,
hasBookkeepingImported,
hasBankConnected,
hasSkatteverketConnected,
}: Props) {
const router = useRouter()
return (
<NewUserChecklist
hasBookkeepingImported={hasBookkeepingImported}
hasBankConnected={hasBankConnected}
hasSkatteverketConnected={hasSkatteverketConnected}
hasAgentBuilt={false}
onFreshStart={() => router.push('/onboarding/agent')}
/>
)
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { AVATAR_OPTIONS } from '@/components/agent/avatars'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InitialFields {
entity_type_label: string
@@ -177,7 +178,7 @@ export default function ReviewCard({
onVerified()
} catch (err) {
setVerifyError(err instanceof Error ? err.message : 'Kunde inte verifiera.')
setVerifyError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte verifiera.')
} finally {
setVerifying(false)
}
@@ -31,6 +31,7 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -761,7 +762,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
toast({
variant: 'destructive',
title: 'Kunde inte bokföra transaktionen',
description: result.error?.message || (typeof result.error === 'string' ? result.error : undefined),
description: getUserErrorMessage(result.error) || (typeof result.error === 'string' ? result.error : undefined),
})
return
}
+66 -20
View File
@@ -1,6 +1,7 @@
'use client'
import { Suspense, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -14,23 +15,46 @@ import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import {
TrialBalanceView,
IncomeStatementView,
BalanceSheetView,
ResultatrapportView,
BalansrapportView,
VatDeclarationView,
SupplierLedgerView,
GeneralLedgerView,
JournalRegisterView,
ARLedgerView,
DimensionPnlView,
} from '@/components/reports/views'
import type { FiscalPeriod } from '@/types'
function ReportViewLoading() {
return (
<Card>
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
const TrialBalanceView = dynamic(() => import('./lazy-views/TrialBalanceView'), { loading: ReportViewLoading })
const IncomeStatementView = dynamic(() => import('./lazy-views/IncomeStatementView'), { loading: ReportViewLoading })
const BalanceSheetView = dynamic(() => import('./lazy-views/BalanceSheetView'), { loading: ReportViewLoading })
const ResultatrapportView = dynamic(() => import('./lazy-views/ResultatrapportView'), { loading: ReportViewLoading })
const BalansrapportView = dynamic(() => import('./lazy-views/BalansrapportView'), { loading: ReportViewLoading })
const VatDeclarationView = dynamic(() => import('./lazy-views/VatDeclarationView'), { loading: ReportViewLoading })
const SupplierLedgerView = dynamic(() => import('./lazy-views/SupplierLedgerView'), { loading: ReportViewLoading })
const GeneralLedgerView = dynamic(() => import('./lazy-views/GeneralLedgerView'), { loading: ReportViewLoading })
const JournalRegisterView = dynamic(() => import('./lazy-views/JournalRegisterView'), { loading: ReportViewLoading })
const ARLedgerView = dynamic(() => import('./lazy-views/ARLedgerView'), { loading: ReportViewLoading })
const DimensionPnlView = dynamic(() => import('./lazy-views/DimensionPnlView'), { loading: ReportViewLoading })
const NEDeclarationView = dynamic(() =>
import('./NEDeclarationView').then((module) => ({ default: module.NEDeclarationView })),
{ loading: ReportViewLoading },
)
const PeriodiskSammanstallningView = dynamic(() =>
import('./PeriodiskSammanstallningView').then((module) => ({ default: module.PeriodiskSammanstallningView })),
{ loading: ReportViewLoading },
)
const INK2DeclarationView = dynamic(() =>
import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
{ loading: ReportViewLoading },
)
const BankReconciliationView = dynamic(() =>
import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
{ loading: ReportViewLoading },
)
/**
* The focused single-report experience at /reports/[slug]. Carries one report:
@@ -39,7 +63,15 @@ import {
* optional date-range control, and the report body. Drilling into an account
* navigates to /reports/huvudbok?account=…: drill state lives in the URL.
*/
function FocusedReportInner({ slug }: { slug: string }) {
function FocusedReportInner({
slug,
initialPeriods,
initialCompanyId,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
}) {
const router = useRouter()
const searchParams = useSearchParams()
const { company } = useCompany()
@@ -97,6 +129,8 @@ function FocusedReportInner({ slug }: { slug: string }) {
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
/>
)
}
@@ -203,10 +237,22 @@ function FocusedView({
}
}
export function FocusedReport({ slug }: { slug: string }) {
export function FocusedReport({
slug,
initialPeriods,
initialCompanyId,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
}) {
return (
<Suspense fallback={<div className="space-y-8" />}>
<FocusedReportInner slug={slug} />
<FocusedReportInner
slug={slug}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
/>
</Suspense>
)
}
+12 -2
View File
@@ -324,8 +324,8 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din
Grundläggande justeringar och de återläggningar som sparats i bokslutet
beräknas automatiskt. Kontrollera övriga deklarationsjusteringar med din
redovisningskonsult.
</p>
<Table>
@@ -356,6 +356,16 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
label="Skatt på årets resultat (ej avdragsgill)"
amount={data.ink2s['7651']}
/>
<DeclarationRutaRow
code="4.3c"
label="Andra ej avdragsgilla kostnader"
amount={data.ink2s['7653']}
/>
<DeclarationRutaRow
code="4.5c"
label="Andra ej skattepliktiga intäkter"
amount={data.ink2s['7754']}
/>
</TableBody>
<tfoot>
<tr className="border-t font-medium">
@@ -0,0 +1,3 @@
'use client'
export { ARLedgerView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { BalanceSheetView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { BalansrapportView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { DimensionPnlView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { GeneralLedgerView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { IncomeStatementView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { JournalRegisterView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { ResultatrapportView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { SupplierLedgerView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { TrialBalanceView as default } from '../views'
@@ -0,0 +1,3 @@
'use client'
export { VatDeclarationView as default } from '../views'
+8 -7
View File
@@ -26,6 +26,7 @@ import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AGIPanelProps {
salaryRunId: string
@@ -433,7 +434,7 @@ export function AGIPanel(props: AGIPanelProps) {
await fetchStatus()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('disconnect_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
@@ -677,7 +678,7 @@ export function AGIPanel(props: AGIPanelProps) {
signingTab?.close()
setChain(prev => (prev ? { ...prev, failed: true } : prev))
if (!(e instanceof ChainFailed)) {
setError(e instanceof Error ? e.message : t('submit_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed'))
}
onRefreshSubmission()
} finally {
@@ -708,7 +709,7 @@ export function AGIPanel(props: AGIPanelProps) {
URL.revokeObjectURL(url)
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('xml_download_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('xml_download_failed'))
} finally {
setActionLoading(null)
}
@@ -726,7 +727,7 @@ export function AGIPanel(props: AGIPanelProps) {
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('submit_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed'))
} finally {
setActionLoading(null)
}
@@ -742,7 +743,7 @@ export function AGIPanel(props: AGIPanelProps) {
if (link !== null) setSuccess(t('signing_link_ready'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('signing_link_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('signing_link_failed'))
} finally {
setActionLoading(null)
}
@@ -765,7 +766,7 @@ export function AGIPanel(props: AGIPanelProps) {
setSuccess(t('unlock_success'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('unlock_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unlock_failed'))
} finally {
setActionLoading(null)
}
@@ -799,7 +800,7 @@ export function AGIPanel(props: AGIPanelProps) {
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('check_status_failed'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('check_status_failed'))
} finally {
setActionLoading(null)
}
+7 -6
View File
@@ -44,6 +44,7 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { SalaryType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ─── Types ─────────────────────────────────────────────────────────
@@ -169,7 +170,7 @@ export function SalaryCalendar({
setWorked(wJson.data ?? [])
}
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -318,7 +319,7 @@ export function SalaryCalendar({
await load()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setDeleting(false)
}
@@ -645,7 +646,7 @@ function BulkWorkedDialog({
if (!res.ok) throw new Error(json.error || t('error_save_hours'))
onSaved([])
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -791,7 +792,7 @@ function BulkAbsenceDialog({
}
onSaved([])
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -918,7 +919,7 @@ function DayInspectorDialog({
onChanged()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setBusy(null)
}
@@ -940,7 +941,7 @@ function DayInspectorDialog({
// Stay open if there's other content; close if this was the last entry.
if (absences.length === 1 && !worked) onClose()
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setBusy(null)
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
import { Settings2, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SalaryOverridePanelProps {
runId: string
@@ -84,7 +85,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
} catch (err) {
toast({
title: t('save_failed'),
description: err instanceof Error ? err.message : t('unknown_error'),
description: err instanceof Error ? getUserErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
+2 -1
View File
@@ -19,6 +19,7 @@ import {
import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface Blocker {
id: string
@@ -92,7 +93,7 @@ export function AccountDangerZone() {
router.push('/login')
} catch (err) {
setError(err instanceof Error ? err.message : t('delete_failed_default'))
setError(err instanceof Error ? getUserErrorMessage(err) : t('delete_failed_default'))
setIsDeleting(false)
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { Cloud, Download, Info, Loader2 } from 'lucide-react'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const CloudBackupPanel = getSettingsPanel('cloud-backup')
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
@@ -170,7 +171,7 @@ export function BackupDownloadForm() {
} catch (err) {
toast({
title: t('toast_backup_failed'),
description: err instanceof Error ? err.message : t('toast_try_again'),
description: err instanceof Error ? getUserErrorMessage(err) : t('toast_try_again'),
variant: 'destructive',
})
} finally {
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatDateLong } from '@/lib/utils'
import type { BillingPlan } from '@/lib/stripe/client'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
monthly: {
@@ -62,7 +63,7 @@ export function BillingActions({
} catch (e) {
toast({
title: 'Kunde inte öppna betalningen',
description: e instanceof Error ? e.message : undefined,
description: e instanceof Error ? getUserErrorMessage(e) : undefined,
variant: 'destructive',
})
setLoading(false)
+2 -1
View File
@@ -19,6 +19,7 @@ import { RetentionNotice } from '@/components/ui/retention-notice'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
@@ -72,7 +73,7 @@ export function CompanyDangerZone() {
} catch (err) {
toast({
title: t('danger_delete_failed_title'),
description: err instanceof Error ? err.message : t('danger_try_again'),
description: err instanceof Error ? getUserErrorMessage(err) : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
+3 -2
View File
@@ -18,6 +18,7 @@ import {
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatSwedishDate(dateStr: string): string {
const months = [
@@ -84,7 +85,7 @@ export function FiscalPeriodEditor() {
setEndDate(first.period_end)
} catch (err) {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
setLoadError(err instanceof Error ? getUserErrorMessage(err) : t('fp_load_error_unknown'))
}
} finally {
if (!cancelled) setIsLoading(false)
@@ -158,7 +159,7 @@ export function FiscalPeriodEditor() {
} catch (err) {
toast({
title: t('fp_update_failed_title'),
description: err instanceof Error ? err.message : t('fp_try_again'),
description: err instanceof Error ? getUserErrorMessage(err) : t('fp_try_again'),
variant: 'destructive',
})
} finally {
+2 -1
View File
@@ -16,6 +16,7 @@ import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** Status of a fiscal period, in legal precedence: closed > locked > open. */
function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
@@ -81,7 +82,7 @@ export function FiscalYearsManager() {
} catch (err) {
toast({
title: t('fy_action_error'),
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
+4 -2
View File
@@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Upload, Trash2 } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
interface LogoUploadProps {
logoUrl: string | null
@@ -28,7 +30,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
return
}
if (file.size > 2 * 1024 * 1024) {
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
@@ -58,7 +60,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
} catch (error) {
toast({
title: t('logo_upload_failed_title'),
description: error instanceof Error ? error.message : t('logo_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('logo_try_again'),
variant: 'destructive',
})
}
+161 -2
View File
@@ -1,12 +1,17 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef, type ChangeEvent } from 'react'
import { Loader2, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import type { CompanySettings } from '@/types'
import { INVOICE_FONT_FAMILIES } from '@/lib/invoices/branding-constants'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, InvoiceFontFamily } from '@/types'
interface PdfPrintSettingsProps {
settings: CompanySettings
@@ -18,6 +23,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
const [isSavingFont, setIsSavingFont] = useState(false)
const [isUploadingFont, setIsUploadingFont] = useState(false)
const [isDeletingFont, setIsDeletingFont] = useState(false)
const fontInputRef = useRef<HTMLInputElement>(null)
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
@@ -61,12 +70,162 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
}
}, [onUpdate, toast, t])
const saveFont = useCallback(async (fontFamily: InvoiceFontFamily) => {
setIsSavingFont(true)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_font_family: fontFamily }),
})
const result = await response.json()
if (!response.ok) throw new Error(result.error || t('toast_save_failed'))
onUpdate({ invoice_font_family: fontFamily })
} catch (error) {
toast({
title: t('toast_save_failed'),
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
variant: 'destructive',
})
} finally {
setIsSavingFont(false)
}
}, [onUpdate, toast, t])
async function uploadFont(file: File) {
setIsUploadingFont(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/settings/invoice-font', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok) throw new Error(result.error || t('font_upload_failed'))
onUpdate(result.data as Partial<CompanySettings>)
} catch (error) {
toast({
title: t('font_upload_failed'),
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
variant: 'destructive',
})
} finally {
setIsUploadingFont(false)
if (fontInputRef.current) fontInputRef.current.value = ''
}
}
async function deleteFont() {
setIsDeletingFont(true)
try {
const response = await fetch('/api/settings/invoice-font', { method: 'DELETE' })
const result = await response.json()
if (!response.ok) throw new Error(result.error || t('font_delete_failed'))
onUpdate(result.data as Partial<CompanySettings>)
} catch (error) {
toast({
title: t('font_delete_failed'),
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
variant: 'destructive',
})
} finally {
setIsDeletingFont(false)
}
}
function handleFontChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (file) void uploadFont(file)
}
const fontLabels: Record<InvoiceFontFamily, string> = {
Helvetica: t('font_helvetica'),
'Times-Roman': t('font_times'),
Courier: t('font_courier'),
'Source Sans 3': t('font_source_sans'),
'Source Serif 4': t('font_source_serif'),
Custom: t('font_custom'),
}
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="invoice_font_family">{t('font_label')}</Label>
<p className="text-xs text-muted-foreground">{t('font_help')}</p>
</div>
<Select
value={settings.invoice_font_family ?? 'Helvetica'}
onValueChange={(value) => {
if (value) void saveFont(value as InvoiceFontFamily)
}}
disabled={isSavingFont || isUploadingFont || isDeletingFont}
>
<SelectTrigger id="invoice_font_family" className="max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INVOICE_FONT_FAMILIES
.filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
.map((family) => (
<SelectItem key={family} value={family}>
{fontLabels[family]}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => fontInputRef.current?.click()}
disabled={isUploadingFont || isDeletingFont}
>
{isUploadingFont ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{settings.invoice_custom_font_path ? t('font_replace') : t('font_upload')}
</Button>
{settings.invoice_custom_font_path && (
<Button
type="button"
variant="ghost"
onClick={() => void deleteFont()}
disabled={isUploadingFont || isDeletingFont}
className="text-muted-foreground hover:text-destructive"
>
{isDeletingFont ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{t('font_remove')}
</Button>
)}
</div>
{settings.invoice_custom_font_name && (
<p className="text-xs text-muted-foreground">
{t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
</p>
)}
<p className="text-xs text-muted-foreground">{t('font_file_help')}</p>
<input
ref={fontInputRef}
type="file"
accept=".ttf,.woff,font/ttf,font/woff"
className="hidden"
onChange={handleFontChange}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
+2 -1
View File
@@ -14,6 +14,7 @@ import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
import { userHasPassword } from '@/lib/auth/has-password'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const mfaRequired = isMfaRequired()
@@ -141,7 +142,7 @@ export function SecuritySettings() {
}
toast({
title: t('toast_unenroll_failed_title'),
description: error.message,
description: getUserErrorMessage(error),
variant: 'destructive',
})
return
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type SaveResult =
| Record<string, unknown>
@@ -82,7 +83,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
} catch (error) {
toast({
title: t('wrapper_save_failed_title'),
description: error instanceof Error ? error.message : t('wrapper_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('wrapper_try_again'),
variant: 'destructive',
})
}
@@ -11,6 +11,7 @@ import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Environment = 'test' | 'prod'
@@ -227,7 +228,7 @@ function SkatteverketPersonalConnectionCard() {
} catch (err) {
toast({
title: t('toast_disconnect_failed'),
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -0,0 +1,243 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { CalendarClock, Pencil, Trash2 } from 'lucide-react'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
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 { useErrorToast } from '@/lib/hooks/use-error-toast'
import type {
TaxAssessmentDecisionType,
TaxAssessmentNotice,
} from '@/types'
function todayLocalIso(): string {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function TaxAssessmentNoticesPanel() {
const t = useTranslations('tax_assessment_notices')
const { toast } = useToast()
const showError = useErrorToast()
const [notices, setNotices] = useState<TaxAssessmentNotice[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(null)
const [decisionType, setDecisionType] = useState<TaxAssessmentDecisionType>('final')
const [decisionDate, setDecisionDate] = useState(todayLocalIso)
const [paymentDueDate, setPaymentDueDate] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
try {
const response = await fetch('/api/tax-assessment-notices')
if (!response.ok) {
if (!cancelled) await showError(response, { context: 'settings' })
return
}
const payload = await response.json() as { data: TaxAssessmentNotice[] }
if (!cancelled) setNotices(payload.data)
} catch (error) {
if (!cancelled) await showError(error, { context: 'settings' })
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
// The error helper is recreated with the toast hook. This load should run
// once per panel mount, not after every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const resetForm = () => {
setEditingId(null)
setDecisionType('final')
setDecisionDate(todayLocalIso())
setPaymentDueDate('')
}
const saveNotice = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!selectedPeriodId || !paymentDueDate) return
setSaving(true)
try {
const response = await fetch(
editingId ? `/api/tax-assessment-notices/${editingId}` : '/api/tax-assessment-notices',
{
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: selectedPeriodId,
decision_type: decisionType,
decision_date: decisionDate,
payment_due_date: paymentDueDate,
}),
},
)
if (!response.ok) {
await showError(response, { context: 'settings' })
return
}
const payload = await response.json() as { data: TaxAssessmentNotice }
setNotices((current) => {
const withoutSaved = current.filter((notice) => notice.id !== payload.data.id)
return [...withoutSaved, payload.data]
.sort((a, b) => a.payment_due_date.localeCompare(b.payment_due_date))
})
toast({ title: t(editingId ? 'updated' : 'saved') })
resetForm()
} catch (error) {
await showError(error, { context: 'settings' })
} finally {
setSaving(false)
}
}
const editNotice = (notice: TaxAssessmentNotice) => {
setEditingId(notice.id)
setSelectedPeriodId(notice.fiscal_period_id)
setDecisionType(notice.decision_type)
setDecisionDate(notice.decision_date)
setPaymentDueDate(notice.payment_due_date)
}
const archiveNotice = async (notice: TaxAssessmentNotice) => {
setSaving(true)
try {
const response = await fetch(`/api/tax-assessment-notices/${notice.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: true }),
})
if (!response.ok) {
await showError(response, { context: 'settings' })
return
}
setNotices((current) => current.filter((item) => item.id !== notice.id))
if (editingId === notice.id) resetForm()
toast({ title: t('archived') })
} catch (error) {
await showError(error, { context: 'settings' })
} finally {
setSaving(false)
}
}
return (
<section className="border-t border-border pt-8" aria-labelledby="tax-assessment-notices-title">
<div className="max-w-3xl">
<div className="flex items-start gap-3">
<CalendarClock className="mt-0.5 h-5 w-5" />
<div>
<h2 id="tax-assessment-notices-title" className="font-display text-xl tracking-tight">
{t('title')}
</h2>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{t('description')}
</p>
</div>
</div>
<form onSubmit={saveNotice} className="mt-6 grid gap-4 rounded-lg border border-border p-4 sm:grid-cols-2 sm:p-6">
<FiscalYearSelector
value={selectedPeriodId}
onChange={(periodId) => setSelectedPeriodId(periodId)}
includeAllOption={false}
label={t('fiscal_period')}
className="sm:col-span-2"
/>
<div className="space-y-1.5">
<Label htmlFor="tax-assessment-decision-type">{t('decision_type')}</Label>
<Select value={decisionType} onValueChange={(value) => setDecisionType(value as TaxAssessmentDecisionType)}>
<SelectTrigger id="tax-assessment-decision-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="final">{t('decision_final')}</SelectItem>
<SelectItem value="reassessment">{t('decision_reassessment')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="tax-assessment-decision-date">{t('decision_date')}</Label>
<Input
id="tax-assessment-decision-date"
type="date"
required
value={decisionDate}
onChange={(event) => setDecisionDate(event.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="tax-assessment-payment-due-date">{t('payment_due_date')}</Label>
<Input
id="tax-assessment-payment-due-date"
type="date"
required
min={decisionDate}
value={paymentDueDate}
onChange={(event) => setPaymentDueDate(event.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('payment_due_date_help')}</p>
</div>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<Button type="submit" disabled={saving || !selectedPeriodId || !paymentDueDate}>
{saving ? t('saving') : t(editingId ? 'update_action' : 'save_action')}
</Button>
{editingId && (
<Button type="button" variant="ghost" onClick={resetForm} disabled={saving}>
{t('cancel')}
</Button>
)}
</div>
</form>
{!loading && notices.length > 0 && (
<div className="mt-6 divide-y divide-border border-y border-border">
{notices.map((notice) => (
<div key={notice.id} className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">
{notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
{notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('due_summary', { date: notice.payment_due_date })}
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => editNotice(notice)} disabled={saving}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void archiveNotice(notice)} disabled={saving}>
<Trash2 className="mr-2 h-4 w-4" />
{t('archive')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)
}
@@ -15,6 +15,7 @@ import { useToast } from '@/components/ui/use-toast'
import { normaliseSwish } from '@/lib/payments/swish'
import { formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import type { CompanySettings } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export function InvoicingSettingsContent() {
const t = useTranslations('settings_invoicing')
@@ -29,7 +30,7 @@ export function InvoicingSettingsContent() {
if (bankErrors.length > 0) {
toast({
title: t('bank_validation_title'),
description: bankErrors.map(e => e.message).join(', '),
description: bankErrors.map(e => getUserErrorMessage(e)).join(', '),
variant: 'destructive',
})
return {}
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNoticesPanel'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
@@ -165,6 +166,8 @@ export function TaxSettingsContent() {
rotRutSignalDetected={rotRutSignalDetected}
/>
</SettingsFormWrapper>
<TaxAssessmentNoticesPanel />
</div>
)
}
+51 -12
View File
@@ -1,16 +1,55 @@
import type { ComponentType } from 'react'
import { AccountSettingsContent } from './AccountSettingsContent'
import { CompanySettingsContent } from './CompanySettingsContent'
import { BookkeepingSettingsContent } from './BookkeepingSettingsContent'
import { TaxSettingsContent } from './TaxSettingsContent'
import { SalarySettingsContent } from './SalarySettingsContent'
import { InvoicingSettingsContent } from './InvoicingSettingsContent'
import { TemplatesSettingsContent } from './TemplatesSettingsContent'
import { PaymentsSettingsContent } from './PaymentsSettingsContent'
import { BankingSettingsContent } from './BankingSettingsContent'
import { AssistantSettingsContent } from './AssistantSettingsContent'
import { ApiSettingsContent } from './ApiSettingsContent'
import { BillingSettingsContent } from './BillingSettingsContent'
import dynamic from 'next/dynamic'
import { SettingsLoadingSkeleton } from '../SettingsLoadingSkeleton'
const AccountSettingsContent = dynamic(() =>
import('./AccountSettingsContent').then((module) => ({ default: module.AccountSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const CompanySettingsContent = dynamic(() =>
import('./CompanySettingsContent').then((module) => ({ default: module.CompanySettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const BookkeepingSettingsContent = dynamic(() =>
import('./BookkeepingSettingsContent').then((module) => ({ default: module.BookkeepingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const TaxSettingsContent = dynamic(() =>
import('./TaxSettingsContent').then((module) => ({ default: module.TaxSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const SalarySettingsContent = dynamic(() =>
import('./SalarySettingsContent').then((module) => ({ default: module.SalarySettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const InvoicingSettingsContent = dynamic(() =>
import('./InvoicingSettingsContent').then((module) => ({ default: module.InvoicingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const TemplatesSettingsContent = dynamic(() =>
import('./TemplatesSettingsContent').then((module) => ({ default: module.TemplatesSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const PaymentsSettingsContent = dynamic(() =>
import('./PaymentsSettingsContent').then((module) => ({ default: module.PaymentsSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const BankingSettingsContent = dynamic(() =>
import('./BankingSettingsContent').then((module) => ({ default: module.BankingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const AssistantSettingsContent = dynamic(() =>
import('./AssistantSettingsContent').then((module) => ({ default: module.AssistantSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const ApiSettingsContent = dynamic(() =>
import('./ApiSettingsContent').then((module) => ({ default: module.ApiSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const BillingSettingsContent = dynamic(() =>
import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
/**
* Single source of truth mapping a settings section id to the component that
@@ -24,6 +24,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface MatchCandidate {
journal_entry_id: string
@@ -84,7 +85,7 @@ export function SkattekontoMatchDialog({
if (cancelled) return
toast({
title: t('fetch_candidates_failed_title'),
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
onClose()
@@ -119,7 +120,7 @@ export function SkattekontoMatchDialog({
} catch (err) {
toast({
title: t('match_failed_title'),
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -580,7 +580,7 @@ export default function NewSupplierInvoiceForm({
if (cancelled) return
toast({
title: t('inbox_load_failed_title'),
description: err instanceof Error ? err.message : t('unknown_error'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
@@ -17,6 +17,7 @@ import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface BankConn {
id: string
@@ -86,7 +87,7 @@ export default function BankSyncNowButton() {
} catch (error) {
toast({
title: t('bank_reconnect'),
description: error instanceof Error ? error.message : 'Reconnect failed',
description: error instanceof Error ? getUserErrorMessage(error) : 'Reconnect failed',
variant: 'destructive',
})
setBusyId(null)
@@ -137,7 +138,7 @@ export default function BankSyncNowButton() {
} catch (error) {
toast({
title: t('bank_sync_button_now'),
description: error instanceof Error ? error.message : 'Sync failed',
description: error instanceof Error ? getUserErrorMessage(error) : 'Sync failed',
variant: 'destructive',
})
} finally {
@@ -21,6 +21,7 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface QuickReviewDialogProps {
open: boolean
@@ -133,7 +134,7 @@ export default function QuickReviewDialog({
const json = await res.json()
if (cancelled) return
if (!res.ok) {
setRateError(json?.error?.message || t('exchange_rate_fetch_failed'))
setRateError(getUserErrorMessage(json?.error) || t('exchange_rate_fetch_failed'))
return
}
if (json?.data) {
+23
View File
@@ -0,0 +1,23 @@
'use client'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
export function DialogLoadingSkeleton() {
const t = useTranslations('common')
return (
<Dialog open>
<DialogContent className="sm:max-w-2xl">
<DialogTitle className="sr-only">{t('loading')}</DialogTitle>
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</div>
</DialogContent>
</Dialog>
)
}