Files
accounted/app/(dashboard)/layout.tsx
T
Jakob WennbergandClaude Fable 5 8bb49c07a2 feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:26:42 +02:00

326 lines
12 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 { getCompanyCapabilities } from '@/lib/entitlements/has-capability'
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,
capabilities: [],
}}
>
<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,
capabilities: [],
}
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 },
capabilities,
] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.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(),
getCompanyCapabilities(supabase, companyId),
])
// 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
// Resolve entity type the same way the report engines and
// getCompanyEntityType do: company_settings is read-primary, companies is the
// canonical fallback, then default to enskild_firma. Mirroring it onto the
// active company keeps the settings rail (useSettingsNavItems, which reads
// context) and the sidebar in agreement on who is an employer. #782
const entityType =
(settings?.entity_type as EntityType) ||
(companyRow.entity_type as EntityType) ||
'enskild_firma'
const paysSalaries = settings?.pays_salaries ?? false
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
// mechanism as paysSalaries: UI gate only, never load-bearing for
// correctness (dimensions plan §2).
const dimensionsEnabled = settings?.dimensions_enabled ?? false
const companyWithName = {
...companyRow,
name: displayName,
entity_type: entityType,
pays_salaries: paysSalaries,
}
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,
capabilities,
}
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}
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
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>
)
}