* feat(ui): frame-layout shell, pill buttons, 24px page titles (UI migration PR 1) The visual shell from the concept, zero behavior change: - New --frame token pair (40 18% 96% light / 0 0% 5% dark); the dashboard wrapper is bg-frame and <main> becomes a rounded 12px panel with its own inner scroll (md-gated; mobile keeps document flow + bottom nav) - Sidebar goes borderless/transparent on the frame - Buttons are pills app-wide (radius 99px, default 7px/16px padding, 13px text, icon buttons become circles), set once in components/ui/button.tsx - PageHeader locked at exactly 24px/32px Hedvig serif - MainContainer resets panel scroll on route change (Next's window scroll-to-top never fires for an inner scroll container) - chat layout + extension workspaces switch viewport-height formulas to h-full so they fill the panel instead of overflowing it by 20px - .claude/rules/design.md rewritten with the 14 locked UI-migration conventions from dev_docs/ui_migration_plan.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): clamp hand-rolled page titles to the locked 24px/32px Transaktioner (TransactionStatusBar) and 13 other pages hand-roll their h1 instead of using PageHeader, so they kept text-3xl/4xl after the shell change. Clamp them all to font-display text-2xl leading-8. Onboarding heroes and headline numbers are intentionally untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): keep the frame strip above the panel on banner-less accounts <main>'s 10px top margin was the first in-flow margin inside the shell wrapper, so it collapsed through the wrapper and pushed the whole shell down, showing white body background above the panel instead of the warm frame strip (only visible on real accounts: the sandbox banner blocked the collapse). Flex containers never collapse child margins, so the shell wrappers become md:flex md:flex-col. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
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 LazyCommandPalette from '@/components/common/LazyCommandPalette'
|
|
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
|
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
|
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
|
import { CompanyProvider } from '@/contexts/CompanyContext'
|
|
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import type { EntityType, CompanyRole, Team } from '@/types'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getResolvedDashboardAgentProfile,
|
|
} from './request-context'
|
|
|
|
/**
|
|
* 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']
|
|
|
|
/**
|
|
* Frame layout: on desktop the page is a rounded panel floating on the
|
|
* warm-toned frame (bg-frame on the wrapper div), with its own inner
|
|
* scroll. 10px margin against the frame; height is the remaining
|
|
* viewport. The sidebar (fixed, w-64) sits borderless on the frame, so
|
|
* the panel starts at ml-64. On mobile the panel dissolves: full-width
|
|
* document flow with the bottom nav, exactly as before.
|
|
*/
|
|
const MAIN_PANEL_CLASS =
|
|
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
|
|
'md:min-h-0 md:ml-64 md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
|
|
'md:overflow-y-auto md:rounded-xl md:border md:border-border'
|
|
|
|
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, user } = await getDashboardAuthContext()
|
|
|
|
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.
|
|
// Team membership (with the team row embedded) only depends on user.id,
|
|
// so it resolves in parallel, this layout is on the critical path of
|
|
// every dashboard page, so sequential round-trips are wall-clock time.
|
|
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
|
|
getDashboardCompanyId(),
|
|
// Read the pathname forwarded by middleware so we can branch on it.
|
|
headers(),
|
|
supabase
|
|
.from('team_members')
|
|
.select('team_id, role, teams:team_id(*)')
|
|
.eq('user_id', user.id)
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
])
|
|
|
|
const pathname = headerStore.get('x-pathname') ?? ''
|
|
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
|
|
pathname.startsWith(p)
|
|
)
|
|
|
|
const team: Team | null =
|
|
(teamMembership?.teams as unknown as Team | null) ?? null
|
|
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: [],
|
|
trialEndsAt: null,
|
|
}}
|
|
>
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
|
<DashboardNav
|
|
companyName={getBranding().appName.toLowerCase()}
|
|
entityType="enskild_firma"
|
|
isSandbox={false}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
/>
|
|
<main
|
|
id="main-content"
|
|
className={MAIN_PANEL_CLASS}
|
|
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, together with the
|
|
// nav/badge data, none of these depend on each other, only on
|
|
// companyId/user.id, so one round-trip batch instead of two. The rare
|
|
// stale-cookie early return below wastes the extra reads; that's cheaper
|
|
// than serializing two batches on every dashboard render.
|
|
const [
|
|
{ data: companyRow },
|
|
{ data: memberRow },
|
|
{ data: allMemberships },
|
|
{ data: settings },
|
|
agentProfileIdentity,
|
|
{ data: userProfile },
|
|
entitlements,
|
|
{ data: allSettingsNames },
|
|
] = 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),
|
|
getDashboardSettings(),
|
|
// Nav badge counts (unbooked transactions, pending operations) are NOT
|
|
// fetched here anymore: DashboardNav loads them client-side after mount
|
|
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
|
|
// first paint on every dashboard navigation.
|
|
// Agent identity, name + avatar, surfaced on the FAB and chat
|
|
// surfaces. Null when no agent_profile exists yet (banner CTA path).
|
|
getResolvedDashboardAgentProfile(),
|
|
// 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(),
|
|
getCompanyEntitlements(supabase, companyId),
|
|
// Current display names for ALL the user's companies (the switcher list).
|
|
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
|
|
// select returns exactly the caller's companies, letting non-active rows
|
|
// show company_settings.company_name instead of the frozen companies.name.
|
|
supabase.from('company_settings').select('company_id, company_name'),
|
|
])
|
|
|
|
// company_id -> current display name for every company the user belongs to.
|
|
const nameByCompany = new Map(
|
|
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
|
|
)
|
|
|
|
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) => {
|
|
const c = m.companies as unknown as import('@/types').Company
|
|
return {
|
|
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
|
role: m.role as CompanyRole,
|
|
}
|
|
}),
|
|
isTeamMember,
|
|
team,
|
|
isSandbox: false,
|
|
capabilities: [],
|
|
trialEndsAt: null,
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
|
<DashboardNav
|
|
companyName={getBranding().appName.toLowerCase()}
|
|
entityType="enskild_firma"
|
|
isSandbox={false}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
/>
|
|
<main id="main-content" className={MAIN_PANEL_CLASS} 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>
|
|
)
|
|
}
|
|
|
|
// 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
|
|
|
|
const companyContextValue = {
|
|
company: companyWithName,
|
|
role: memberRow.role as CompanyRole,
|
|
companies: (allMemberships || []).map((m) => {
|
|
const c = m.companies as unknown as import('@/types').Company
|
|
// Current display name for every company (company_settings.company_name,
|
|
// falling back to the frozen companies.name) so non-active switcher rows
|
|
// are current too. For the active company this equals `displayName`.
|
|
return {
|
|
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
|
role: m.role as CompanyRole,
|
|
}
|
|
}),
|
|
isTeamMember,
|
|
team,
|
|
isSandbox,
|
|
capabilities: entitlements.capabilities,
|
|
trialEndsAt: entitlements.trialEndsAt,
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<AgentSheetProvider
|
|
identity={{
|
|
displayName: agentProfileIdentity?.display_name ?? null,
|
|
avatarId: agentProfileIdentity?.avatar_id ?? null,
|
|
isVerified: Boolean(agentProfileIdentity?.verified_at),
|
|
}}
|
|
>
|
|
<CompanyTabSync />
|
|
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
|
{/* 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}
|
|
isSandbox={isSandbox}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
userName={userProfile?.full_name ?? null}
|
|
userEmail={user.email ?? null}
|
|
/>
|
|
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
|
<MainContainer companyId={companyId}>{children}</MainContainer>
|
|
</main>
|
|
<AgentTrigger />
|
|
<LazyCommandPalette />
|
|
<SettingsHotkey />
|
|
{settingsModal}
|
|
</div>
|
|
{!isSandbox && (
|
|
<RecaptIdentify
|
|
userId={user.id}
|
|
email={user.email}
|
|
displayName={settings?.company_name || undefined}
|
|
/>
|
|
)}
|
|
</AgentSheetProvider>
|
|
</CompanyProvider>
|
|
)
|
|
}
|