perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited for the slowest worklist scan, and the request also paid a sequential bank_file_imports read after the batch, a second scan of the suggested matches (getWorklistCounts counted the same 200 rows the pane listed) and an awaited stale-dismissal delete on the read path. - page.tsx awaits only what the greeting shell and the redirects need (settings, profile, agent profile, the Skatteverket flag); the notice line, the setup checklist and the Att göra + Fortsätt panes are async server components behind their own Suspense (hem-sections.tsx). RSC streaming applies to client navigations too, so the greeting paints first on every visit and each block fills in as its queries land. - DashboardContent becomes the shell with three slots; HemNotices keeps the one client-side action (the wrong-account sign-out); HemSkeletons are the two fallbacks. - getWorklistCounts accepts the suggested matches the caller is already fetching (a promise, so it stays parallel); listSuggestedMatches runs once at the scan cap and the pane shows the first five. - countInboxDocuments runs its id chunks in one wave instead of N sequential round trips. - getCompanyNotices takes deferReap; Hem passes Next's after() so the stale-dismissal delete runs after the response. - bank_file_imports joins the checklist section's batch. Tests: worklist aggregate (precomputed matches skip the rescan), notices aggregate (deferReap receives the reap; the delete does not run inline and runs when the task does). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
a3326a0296
commit
a8980a3a41
@@ -0,0 +1,232 @@
|
||||
import { after } from 'next/server'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import { HemNotices } from '@/components/dashboard/HemNotices'
|
||||
import { getWorklistCounts, listSuggestedMatches, SUGGESTED_MATCH_SCAN_CAP } from '@/lib/worklist'
|
||||
import { listResumeItems } from '@/lib/worklist/resume'
|
||||
import { getCompanyNotices } from '@/lib/notices'
|
||||
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
|
||||
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
import type { InitialSetupState, MomsPeriod, OnboardingProgress } from '@/types'
|
||||
import { getDashboardAuthContext } from './request-context'
|
||||
|
||||
/**
|
||||
* Hem's streamed sections. Each is an async server component rendered behind
|
||||
* its own <Suspense> in page.tsx, so the greeting shell paints as soon as the
|
||||
* page's small first wave resolves and each block fills in when its own
|
||||
* queries land. The Supabase client comes from the request-cached auth
|
||||
* context, never from props (not serialisable).
|
||||
*/
|
||||
|
||||
type BankConnectionRow = {
|
||||
id: string
|
||||
status: string
|
||||
consent_expires: string | null
|
||||
bank_name: string
|
||||
last_sie_sweep: unknown
|
||||
}
|
||||
|
||||
type SieSweepSummaryLite = {
|
||||
auto_linked?: number
|
||||
suggested?: number
|
||||
unmatched?: number
|
||||
errors?: number
|
||||
ran_at?: string
|
||||
}
|
||||
|
||||
export async function HemNoticesSection({
|
||||
companyId,
|
||||
userId,
|
||||
now,
|
||||
}: {
|
||||
companyId: string
|
||||
userId: string
|
||||
now: Date
|
||||
}) {
|
||||
const { supabase } = await getDashboardAuthContext()
|
||||
// Degraded-state notices (lib/notices): broken/expiring bank connections,
|
||||
// Skatteverket reconnect, failing backups, the wrong-account hint (#1231),
|
||||
// priority-ordered and dismissal-filtered. The stale-dismissal reap is
|
||||
// hygiene and runs after the response (Next `after`), not on the read path.
|
||||
const notices = await getCompanyNotices(supabase, companyId, {
|
||||
userId,
|
||||
now,
|
||||
deferReap: (task) => after(task),
|
||||
})
|
||||
return <HemNotices notices={notices} />
|
||||
}
|
||||
|
||||
export async function HemChecklistSection({
|
||||
companyId,
|
||||
userId,
|
||||
now,
|
||||
initialSetup,
|
||||
agentBuilt,
|
||||
vatRegistered,
|
||||
momsPeriod,
|
||||
}: {
|
||||
companyId: string
|
||||
userId: string
|
||||
now: Date
|
||||
initialSetup: InitialSetupState
|
||||
agentBuilt: boolean
|
||||
vatRegistered: boolean
|
||||
momsPeriod: MomsPeriod | null
|
||||
}) {
|
||||
const { supabase } = await getDashboardAuthContext()
|
||||
const [
|
||||
{ count: customerCount },
|
||||
{ count: invoiceCount },
|
||||
{ count: transactionCount },
|
||||
{ data: bankConnections },
|
||||
{ count: sieImportCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
{ count: inboxItemCount },
|
||||
{ data: nextVatDeadline },
|
||||
{ data: latestFileSweep },
|
||||
] = await Promise.all([
|
||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||
// Skatteverket connections are per (user, company): filtering on user_id
|
||||
// alone made a connection on ANY of the user's companies hide the connect
|
||||
// nudge on all of them.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', userId).eq('company_id', companyId),
|
||||
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
||||
// marks the receipts checklist step done: same "has ever done X" shape
|
||||
// as the other flags above.
|
||||
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
||||
// Rows are system-generated per company settings; dismissed rows are
|
||||
// excluded everywhere deadlines are listed, so here too.
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('due_date')
|
||||
.eq('company_id', companyId)
|
||||
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
||||
.eq('is_completed', false)
|
||||
.is('dismissed_at', null)
|
||||
.gte('due_date', now.toISOString().slice(0, 10))
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
// Latest bank-file SIE sweep (PSD2 sync stamps bank_connections.last_sie_sweep
|
||||
// instead). Feeds the checklist's bank step with "X matchade, Y att
|
||||
// granska". Best-effort: absent rows just render no note.
|
||||
supabase
|
||||
.from('bank_file_imports')
|
||||
.select('sie_sweep')
|
||||
.eq('company_id', companyId)
|
||||
.not('sie_sweep', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
const connections = (bankConnections ?? []) as BankConnectionRow[]
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
hasBankConnected: connections.length > 0 || (transactionCount || 0) > 0,
|
||||
hasSIEImport: (sieImportCount || 0) > 0,
|
||||
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
||||
hasInboxItems: (inboxItemCount || 0) > 0,
|
||||
}
|
||||
|
||||
const vatLine = vatDeadlineLine({
|
||||
vatRegistered,
|
||||
momsPeriod,
|
||||
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
||||
})
|
||||
|
||||
const sweepCandidates: SieSweepSummaryLite[] = [
|
||||
...connections
|
||||
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
|
||||
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
|
||||
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
|
||||
]
|
||||
const sieSweep =
|
||||
sweepCandidates.length > 0
|
||||
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
|
||||
: null
|
||||
|
||||
return (
|
||||
<NewUserChecklist
|
||||
initialState={initialSetup}
|
||||
hasBookkeepingImported={onboardingProgress.hasSIEImport}
|
||||
hasBankConnected={onboardingProgress.hasBankConnected}
|
||||
hasSkatteverketConnected={onboardingProgress.hasSkatteverketConnected}
|
||||
hasInboxItems={onboardingProgress.hasInboxItems}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
vatLine={vatLine}
|
||||
sieSweep={
|
||||
sieSweep
|
||||
? {
|
||||
auto_linked: sieSweep.auto_linked ?? 0,
|
||||
suggested: sieSweep.suggested ?? 0,
|
||||
unmatched: sieSweep.unmatched ?? 0,
|
||||
errors: sieSweep.errors ?? 0,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export async function HemPanesSection({
|
||||
companyId,
|
||||
now,
|
||||
setupOpen,
|
||||
}: {
|
||||
companyId: string
|
||||
now: Date
|
||||
setupOpen: boolean
|
||||
}) {
|
||||
const { supabase } = await getDashboardAuthContext()
|
||||
// Fetched once at the scan cap: the Att göra pane shows the first five and
|
||||
// the worklist count is the list's length (it used to scan the same rows
|
||||
// twice). Everything else runs in the same wave.
|
||||
const suggestedMatchesPromise = listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP)
|
||||
const [worklist, suggestedMatches, resumeItems, { data: bankConnections }, postedEntries] =
|
||||
await Promise.all([
|
||||
// Pending-work counts come from lib/worklist: the same source as the
|
||||
// sidebar badges, so the numbers can never diverge.
|
||||
getWorklistCounts(supabase, companyId, { suggestedMatches: suggestedMatchesPromise }),
|
||||
suggestedMatchesPromise,
|
||||
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
||||
listResumeItems(supabase, companyId, now),
|
||||
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
||||
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
||||
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
||||
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
||||
])
|
||||
|
||||
// "Empty ledger" only matters while the setup checklist is still open; once
|
||||
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
||||
// count must NOT read as empty: that would tell a company with real
|
||||
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
||||
const emptyLedger = setupOpen && !postedEntries.error && (postedEntries.count || 0) === 0
|
||||
|
||||
// Same day-math as the bank_connection_expiring notice predicate
|
||||
// (lib/notices/categories.ts): the Bevaka row and the notice can never
|
||||
// disagree on the threshold.
|
||||
const expiringBankConnections = expiringBankConnectionsFrom(
|
||||
(bankConnections ?? []) as BankConnectionRow[],
|
||||
now,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined}>
|
||||
<AttGoraSection
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches.slice(0, 5)}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
emptyLedger={emptyLedger}
|
||||
/>
|
||||
<ResumePane items={resumeItems} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+50
-150
@@ -1,17 +1,14 @@
|
||||
import { Suspense } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
|
||||
import { listResumeItems } from '@/lib/worklist/resume'
|
||||
import { getCompanyNotices } from '@/lib/notices'
|
||||
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
|
||||
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
import type { OnboardingProgress } from '@/types'
|
||||
import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
|
||||
import {
|
||||
getDashboardAuthContext,
|
||||
getDashboardCompanyId,
|
||||
getDashboardSettings,
|
||||
getResolvedDashboardAgentProfile,
|
||||
} from './request-context'
|
||||
import { HemChecklistSection, HemNoticesSection, HemPanesSection } from './hem-sections'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,6 +17,14 @@ export const dynamic = 'force-dynamic'
|
||||
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
|
||||
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
|
||||
// queries are gone and the page got faster.
|
||||
//
|
||||
// Streaming: the page itself awaits only what the greeting shell and the
|
||||
// redirects need (settings, profile, agent profile, the Skatteverket flag).
|
||||
// The notice line, the setup checklist and the Att göra + Fortsätt panes are
|
||||
// async server components behind their own <Suspense> (hem-sections.tsx),
|
||||
// so ~30 queries fill three blocks in as they land instead of holding the
|
||||
// whole page behind the slowest one. RSC streaming applies to client
|
||||
// navigations too, not only hard loads.
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [{ supabase, user }, companyId] = await Promise.all([
|
||||
@@ -37,70 +42,16 @@ export default async function DashboardPage() {
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [
|
||||
settingsRes,
|
||||
{ count: customerCount },
|
||||
{ count: invoiceCount },
|
||||
{ count: transactionCount },
|
||||
{ data: bankConnections },
|
||||
{ count: sieImportCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
{ count: inboxItemCount },
|
||||
{ count: postedEntryCount, error: postedEntryError },
|
||||
{ data: nextVatDeadline },
|
||||
{ data: profile },
|
||||
agentProfile,
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
notices,
|
||||
] = await Promise.all([
|
||||
getDashboardSettings(),
|
||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||
// Skatteverket connections are per (user, company): filtering on user_id
|
||||
// alone made a connection on ANY of the user's companies hide the connect
|
||||
// nudge on all of them.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
|
||||
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
||||
// marks the receipts checklist step done: same "has ever done X" shape
|
||||
// as the other flags above.
|
||||
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
||||
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
||||
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
||||
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
||||
// Rows are system-generated per company settings; dismissed rows are
|
||||
// excluded everywhere deadlines are listed, so here too.
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('due_date')
|
||||
.eq('company_id', companyId)
|
||||
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
||||
.eq('is_completed', false)
|
||||
.is('dismissed_at', null)
|
||||
.gte('due_date', now.toISOString().slice(0, 10))
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
// First name for the greeting.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
getResolvedDashboardAgentProfile(),
|
||||
// Pending-work counts + suggested matches come from lib/worklist: the
|
||||
// same source as the sidebar badges, so the numbers can never diverge.
|
||||
getWorklistCounts(supabase, companyId),
|
||||
listSuggestedMatches(supabase, companyId, 5),
|
||||
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
||||
listResumeItems(supabase, companyId, now),
|
||||
// Degraded-state notices (lib/notices): broken/expiring bank
|
||||
// connections, Skatteverket reconnect, failing backups, and the
|
||||
// wrong-account hint (#1231), priority-ordered and dismissal-filtered.
|
||||
getCompanyNotices(supabase, companyId, { userId: user.id, now }),
|
||||
])
|
||||
const [settingsRes, { data: profile }, agentProfile, { count: skatteverketTokenCount }] =
|
||||
await Promise.all([
|
||||
getDashboardSettings(),
|
||||
// First name for the greeting.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
getResolvedDashboardAgentProfile(),
|
||||
// The Skatteverket promo below the panes needs this flag in the shell;
|
||||
// the checklist section reads it again for its own step (cheap head count).
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
|
||||
])
|
||||
|
||||
// A FAILED settings read must not masquerade as "onboarding not done":
|
||||
// that sent fully onboarded users back to the wizard on a transient query
|
||||
@@ -117,94 +68,43 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
const agentBuilt = Boolean(agentProfile?.verified_at)
|
||||
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
|
||||
hasSIEImport: (sieImportCount || 0) > 0,
|
||||
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
||||
hasInboxItems: (inboxItemCount || 0) > 0,
|
||||
}
|
||||
|
||||
const vatLine = vatDeadlineLine({
|
||||
vatRegistered: settings.vat_registered,
|
||||
momsPeriod: settings.moms_period ?? null,
|
||||
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
||||
})
|
||||
|
||||
// "Empty ledger" only matters while the setup checklist is still open; once
|
||||
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
||||
// count must NOT read as empty: that would tell a company with real
|
||||
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
||||
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
||||
const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
|
||||
|
||||
// Same day-math as the bank_connection_expiring notice predicate
|
||||
// (lib/notices/categories.ts): the Bevaka row and the notice can never
|
||||
// disagree on the threshold.
|
||||
const expiringBankConnections = expiringBankConnectionsFrom(bankConnections || [], now)
|
||||
|
||||
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
|
||||
|
||||
// Latest SIE reconciliation-sweep summary across both history sources
|
||||
// (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import
|
||||
// stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with
|
||||
// "X matchade, Y att granska" so a migrator sees the sweep outcome without
|
||||
// hunting for it. Best-effort: absent rows just render no note.
|
||||
type SieSweepSummaryLite = {
|
||||
auto_linked?: number
|
||||
suggested?: number
|
||||
unmatched?: number
|
||||
errors?: number
|
||||
ran_at?: string
|
||||
const initialSetup = {
|
||||
path: settings.initial_setup_path ?? null,
|
||||
completedAt: settings.initial_setup_completed_at ?? null,
|
||||
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
||||
}
|
||||
const { data: latestFileSweep } = await supabase
|
||||
.from('bank_file_imports')
|
||||
.select('sie_sweep')
|
||||
.eq('company_id', companyId)
|
||||
.not('sie_sweep', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
const sweepCandidates: SieSweepSummaryLite[] = [
|
||||
...(bankConnections || [])
|
||||
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
|
||||
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
|
||||
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
|
||||
]
|
||||
const sieSweep =
|
||||
sweepCandidates.length > 0
|
||||
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
|
||||
: null
|
||||
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
companyId={companyId}
|
||||
agentBuilt={agentBuilt}
|
||||
userFirstName={userFirstName}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
resumeItems={resumeItems}
|
||||
notices={notices}
|
||||
onboardingProgress={onboardingProgress}
|
||||
initialSetup={{
|
||||
path: settings.initial_setup_path ?? null,
|
||||
completedAt: settings.initial_setup_completed_at ?? null,
|
||||
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
||||
}}
|
||||
vatLine={vatLine}
|
||||
emptyLedger={emptyLedger}
|
||||
sieSweep={
|
||||
sieSweep
|
||||
? {
|
||||
auto_linked: sieSweep.auto_linked ?? 0,
|
||||
suggested: sieSweep.suggested ?? 0,
|
||||
unmatched: sieSweep.unmatched ?? 0,
|
||||
errors: sieSweep.errors ?? 0,
|
||||
}
|
||||
: null
|
||||
initialSetup={initialSetup}
|
||||
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
|
||||
notices={
|
||||
<Suspense fallback={null}>
|
||||
<HemNoticesSection companyId={companyId} userId={user.id} now={now} />
|
||||
</Suspense>
|
||||
}
|
||||
checklist={
|
||||
<Suspense fallback={<ChecklistSkeleton />}>
|
||||
<HemChecklistSection
|
||||
companyId={companyId}
|
||||
userId={user.id}
|
||||
now={now}
|
||||
initialSetup={initialSetup}
|
||||
agentBuilt={agentBuilt}
|
||||
vatRegistered={settings.vat_registered}
|
||||
momsPeriod={settings.moms_period ?? null}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
panes={
|
||||
<Suspense fallback={<PanesSkeleton />}>
|
||||
<HemPanesSection companyId={companyId} now={now} setupOpen={setupOpen} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user