Files
accounted/app/(dashboard)/layout.tsx
T
Jakob WennbergandClaude Opus 4.8 f59da07fc0 feat(worklist): unified pending-work counts + vampire-transaction fix (#673)
* feat(worklist): unified pending-work counts in lib/worklist

One source of truth for every "Att göra" count, shared by the sidebar
badges, the home page (PR 3), and eventually the MCP list tools. Each
category documents its pending/done predicate in types.ts; counts are
cheap head-only queries that soft-fail to 0 so a broken badge can never
take down a layout. listSuggestedMatches() returns confirmable
transaction↔invoice match rows for the upcoming one-click confirm UI.

GET /api/worklist/counts exposes the aggregate for client refetch.

Partial index on transactions (company_id WHERE unbooked, not ignored)
— the badge predicate runs on every dashboard render — and a
company-scoped index on invoice_inbox_items, which was only indexed by
user_id from before the multi-tenant refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transactions): unify unbooked predicate, add Ignorera action

The "vampire transactions" fix. Three surfaces counted "unbooked"
differently — the sidebar badge included ignored rows (is_business IS
NULL only), the transactions page count and inbox list disagreed with
each other — so old transactions kept haunting the inbox with inflated
badges after every sync. All three now share lib/worklist's canonical
predicate: is_business IS NULL AND is_ignored = false.

The transactions page also gains "Ignorera transaktionen…" in the
booking dialog (mirroring BankReconciliationView's flow: confirm →
ignore → Ångra toast), so non-business strays can finally be cleared
from the worklist without fabricating a verifikation. Recovery remains
on Rapporter → Bankavstämning under "Ignorerade transaktioner".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(worklist): address PR #673 review — exact missing-underlag count, tenant-tagged logs

Review findings:
- countVerifikatMissingDocument subtracted set SIZES, which both let
  documents on non-document-requiring entries (e.g. VAT settlements)
  shrink the count and silently truncated at the PostgREST row cap on
  large document histories. Now an exact per-entry set difference over
  fetchAllRows-paginated id-only reads. (greptile, swedish-compliance)
- logAndZero gains companyId as a structured log field so repeated
  count failures can be correlated to a tenant. (compliance-swarm V16)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(worklist): address PR #673 review round 2 — chunked .in(), BFL-aware ignore copy

- countInboxDocuments: dedupe + chunk the document-id .in() filter at 150
  ids per request — PostgREST serialises it into the GET query string, so
  a large inbox could exceed proxy URL limits (HTTP 414) and silently
  zero the badge via the error branch. (greptile)
- Ignorera confirm dialog now states the BFL boundary: only for
  non-affärshändelser (duplicates, own-account transfers) — real
  purchases and payments must be booked. Persisting a structured ignore
  reason is noted as follow-up. (swedish-compliance)
- New tests: chunked counting sums across batches; a mid-stream
  pagination error in countVerifikatMissingDocument throws (fetchAllRows
  propagates page errors) and soft-fails to a logged 0 — never a count
  computed from a silently truncated set. (swedish-compliance)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:11:37 +02:00

300 lines
11 KiB
TypeScript

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import CommandPalette from '@/components/common/CommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist'
import type { EntityType, CompanyRole, Team } from '@/types'
/**
* Routes inside the dashboard group that must remain reachable when the
* user has no active company. Keep in sync with the middleware's
* no-company allowlist.
*/
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
export default async function DashboardLayout({
children,
settingsModal,
}: {
children: React.ReactNode
// `@settingsModal` parallel slot — renders the routed settings modal over the
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// Resolve active company from user_preferences (authoritative). The
// `gnubok-company-id` cookie is intentionally no longer consulted here —
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
const companyId = await getActiveCompanyId(supabase, user.id)
// Read the pathname forwarded by middleware so we can branch on it.
const headerStore = await headers()
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
// Fetch team membership + team info
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id, role')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let team: Team | null = null
if (teamMembership?.team_id) {
const { data: teamRow } = await supabase
.from('teams')
.select('*')
.eq('id', teamMembership.team_id)
.single()
team = teamRow
}
const isTeamMember = !!teamMembership
// No companies — redirect to onboarding, except for allowed escape-hatch
// routes (so the user can still reach /settings/account to delete their
// account after archiving their last company).
if (!companyId) {
if (!isNoCompanyAllowed) {
redirect('/onboarding')
}
return (
<CompanyProvider
value={{
company: null,
role: null,
companies: [],
isTeamMember,
team,
isSandbox: false,
}}
>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className="safe-area-main-padding md:!pb-0 md:pl-64"
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
// Fetch company + membership for context provider
const [
{ data: companyRow },
{ data: memberRow },
{ data: allMemberships },
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
])
if (!companyRow || !memberRow) {
// Stale cookie pointing to a deleted/inaccessible company.
// Render the empty-state dashboard so user can switch or create a company.
const companyContextValue = {
company: null,
role: null,
companies: (allMemberships || []).filter(m => m.companies).map((m) => ({
company: m.companies as unknown as import('@/types').Company,
role: m.role as CompanyRole,
})),
isTeamMember,
team,
isSandbox: false,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
const [
{ data: settings },
uncategorizedCount,
pendingOpsCount,
{ data: agentProfileIdentity },
{ data: userProfile },
] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('company_id', companyId)
.single(),
// Shared worklist predicates (lib/worklist) — the badge must show the
// same number as every other "att göra" surface. Notably this excludes
// is_ignored rows, which the old inline query here did not.
countUnbookedTransactions(supabase, companyId),
countPendingOperations(supabase, companyId),
// Agent identity — name + avatar — surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
// The signed-in user's profile — shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
])
// If onboarding incomplete, still render the dashboard — the page component
// will show the inline onboarding card instead of the normal dashboard content.
// Use company_name from settings as the display name (companies.name may be stale)
const displayName = settings?.company_name || companyRow.name
const companyWithName = { ...companyRow, name: displayName }
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const isSandbox = settings?.is_sandbox === true
// Backfill a verified agent_profile for sandbox sessions that pre-date the
// seed change. Without this an old anonymous session shows the "Bygg din
// bokföringsassistent" CTA in three places (dashboard hero, NewUserChecklist
// step 4, /chat layout redirect) and the user can still kick off a build
// flow that the server now 403s. Best-effort; doesn't block the layout
// even if the insert fails.
let resolvedAgentIdentity = agentProfileIdentity
if (isSandbox && !agentProfileIdentity?.verified_at) {
await ensureSandboxAgentProfile(supabase, companyId)
const { data: refreshed } = await supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle()
resolvedAgentIdentity = refreshed ?? agentProfileIdentity
}
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Override active company's name with settings name
if (c.id === companyId) {
return { company: { ...c, name: displayName }, role: m.role as CompanyRole }
}
return { company: c, role: m.role as CompanyRole }
}),
isTeamMember,
team,
isSandbox,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider
identity={{
displayName: resolvedAgentIdentity?.display_name ?? null,
avatarId: resolvedAgentIdentity?.avatar_id ?? null,
isVerified: Boolean(resolvedAgentIdentity?.verified_at),
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount}
pendingOperationsCount={pendingOpsCount}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
userEmail={user.email ?? null}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger />
<CommandPalette />
<SettingsHotkey />
{settingsModal}
</div>
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
</AgentSheetProvider>
</CompanyProvider>
)
}