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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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å.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 > 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user