An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
253 lines
9.9 KiB
TypeScript
253 lines
9.9 KiB
TypeScript
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,
|
|
listExpensePayoutsDue,
|
|
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,
|
|
hasMcpKey,
|
|
vatRegistered,
|
|
momsPeriod,
|
|
}: {
|
|
companyId: string
|
|
userId: string
|
|
now: Date
|
|
initialSetup: InitialSetupState
|
|
/** Live OAuth-minted MCP key exists for this user: see claudeStepDone(). */
|
|
hasMcpKey: 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}
|
|
hasMcpKey={hasMcpKey}
|
|
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)
|
|
// Same pattern for people owed for utlägg: Hem renders one row per person
|
|
// and the worklist count is the list's length.
|
|
const expensePayoutsPromise = listExpensePayoutsDue(supabase, companyId)
|
|
const [worklist, suggestedMatches, expensePayouts, resumeItems, bankConnectionsRes, 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,
|
|
expensePayoutsDue: expensePayoutsPromise,
|
|
}),
|
|
suggestedMatchesPromise,
|
|
expensePayoutsPromise,
|
|
// 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
|
|
|
|
const bankConnections = bankConnectionsRes.data
|
|
// Same degrade-on-error rule as emptyLedger: a failed fetch must NOT tell a
|
|
// connected company that no bank is connected, so errors read as connected.
|
|
const hasActiveBankConnection = !!bankConnectionsRes.error || (bankConnections ?? []).length > 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)}
|
|
expensePayouts={expensePayouts}
|
|
expiringBankConnections={expiringBankConnections}
|
|
emptyLedger={emptyLedger}
|
|
hasActiveBankConnection={hasActiveBankConnection}
|
|
/>
|
|
<ResumePane items={resumeItems} />
|
|
</div>
|
|
)
|
|
}
|