516685526c
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
444 lines
18 KiB
TypeScript
444 lines
18 KiB
TypeScript
/**
|
|
* Application root: routing, data fetching, and the auth gate.
|
|
*/
|
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
import { Link } from 'react-router-dom';
|
|
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
|
import { ThemeProvider } from '@/lib/theme';
|
|
import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
|
|
import { LayoutProvider } from '@/lib/layout';
|
|
import { PiggyContextProvider } from '@/lib/piggy-context';
|
|
import { PlatformAudioProvider } from '@/lib/audio';
|
|
import { Shell } from '@/components/Shell';
|
|
import { SignIn } from '@/pages/SignIn';
|
|
import { CreateProfile } from '@/pages/CreateProfile';
|
|
import { Register } from '@/pages/Register';
|
|
import { PiggyMark } from '@/components/PiggyMark';
|
|
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Toaster } from '@/components/ui/sonner';
|
|
import { usePageTitle } from '@/lib/title';
|
|
|
|
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
|
const Capacity = lazy(() => import('@/pages/Capacity').then(({ Capacity }) => ({ default: Capacity })));
|
|
const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipeline }) => ({ default: DemandPipeline })));
|
|
const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline })));
|
|
const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings })));
|
|
const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts })));
|
|
const Account = lazy(() => import('@/pages/Account').then(({ Account }) => ({ default: Account })));
|
|
const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin })));
|
|
const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview })));
|
|
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
|
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
|
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
|
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
|
|
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
|
|
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
|
|
// Lazy is load-bearing for these five and not merely conventional: they are the
|
|
// only pages that pull react-markdown and remark-gfm, and an eager import would
|
|
// put a markdown parser into the entry chunk every route pays for.
|
|
const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion })));
|
|
const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary })));
|
|
const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate })));
|
|
const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements })));
|
|
const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement })));
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 30_000,
|
|
// Refetching every time a phone user switches apps and comes back is
|
|
// wasteful on cellular; the interval on the dashboard covers freshness.
|
|
refetchOnWindowFocus: false,
|
|
retry: (failureCount, error) => {
|
|
// Retrying an auth failure just produces the same failure slower.
|
|
if (error instanceof ApiError && (error.needsSignIn || error.needsProfile)) return false;
|
|
return failureCount < 2;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
export function App() {
|
|
const [config, setConfig] = useState<PublicConfig | null>(null);
|
|
const [configError, setConfigError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadPublicConfig()
|
|
.then(setConfig)
|
|
.catch((error: unknown) =>
|
|
setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'),
|
|
);
|
|
}, []);
|
|
|
|
if (configError) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Cannot reach PIG"
|
|
description={configError}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
if (!config) return <Splash />;
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider
|
|
onPersist={(prefs) => {
|
|
// Fire and forget: a failed preference save must never block the UI,
|
|
// and the local copy already applied the change.
|
|
void patch('/api/me/preferences', prefs).catch(() => {});
|
|
}}
|
|
>
|
|
{/*
|
|
Above the router, so the music survives navigation AND covers the
|
|
anonymous Learn page — a share-code visitor gets the same platform
|
|
character as a member. It never plays unbidden: the browser refuses
|
|
audio until the page has had a real gesture, so it begins when
|
|
someone actually starts using the page, and the header control mutes
|
|
it for good on that device.
|
|
*/}
|
|
<PlatformAudioProvider>
|
|
<BrowserRouter>
|
|
<AuthGate config={config} />
|
|
</BrowserRouter>
|
|
</PlatformAudioProvider>
|
|
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
|
<Toaster />
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The signed-out screens render outside Shell. Their shared AuthShell supplies
|
|
* the public header and its music control; keeping this boundary component
|
|
* means the auth gate does not need to know anything about that presentation.
|
|
*
|
|
* Learn is not wrapped: it brings its own chrome and already hosts one.
|
|
*/
|
|
function SignedOut({ children }: { children: React.ReactNode }) {
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* Decides what to show based on *why* a request failed.
|
|
*
|
|
* The distinction between "not signed in" and "signed in but not a member" is
|
|
* the one that matters: sending an invited-but-unprovisioned user back to a
|
|
* login screen they have already completed is an infuriating loop, and it is
|
|
* the default behaviour if both are treated as "auth error".
|
|
*/
|
|
function AuthGate({ config }: { config: PublicConfig }) {
|
|
// Which unauthenticated screen to show. Kept in state rather than a route so
|
|
// that a half-filled registration form is not lost to an accidental Back.
|
|
const [showRegister, setShowRegister] = useState(false);
|
|
|
|
const { data, isLoading, error, refetch } = useIdentityQuery();
|
|
|
|
// Adopt the server's stored appearance once we know who this is. Appearance
|
|
// only: sidebar-collapsed and dock-open are per-device and live in
|
|
// localStorage, deliberately (see lib/layout.tsx).
|
|
useEffect(() => {
|
|
if (!data) return;
|
|
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
|
|
.then((profile) => {
|
|
if (!profile) return;
|
|
const host = window as unknown as { __pigAdoptTheme?: (p: unknown) => void };
|
|
host.__pigAdoptTheme?.(profile);
|
|
})
|
|
.catch(() => {});
|
|
}, [data]);
|
|
|
|
// React to sign-in and sign-out without a page reload.
|
|
useEffect(() => {
|
|
const supabase = getSupabase();
|
|
if (!supabase) return;
|
|
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
|
|
void refetch();
|
|
});
|
|
return () => subscription.subscription.unsubscribe();
|
|
}, [refetch]);
|
|
|
|
if (isLoading) return <Splash />;
|
|
|
|
if (error instanceof ApiError) {
|
|
if (error.needsSignIn) {
|
|
/*
|
|
* Learn is the one route reachable without an account. It gates itself on
|
|
* a share code, and the API only ever serves it platform-track rows — so
|
|
* sending a code-holder to the sign-in screen would make the code
|
|
* unusable, which is the whole point of having one.
|
|
*
|
|
* Rendered outside Shell deliberately: the page uses no identity, layout
|
|
* or dock hook, and there is no member to build a workspace chrome for.
|
|
*/
|
|
if (window.location.pathname === '/learn') {
|
|
return (
|
|
<RoutePage>
|
|
<Learn />
|
|
</RoutePage>
|
|
);
|
|
}
|
|
return (
|
|
<SignedOut>
|
|
{showRegister ? (
|
|
<Register
|
|
config={config}
|
|
onBack={() => setShowRegister(false)}
|
|
onRegistered={() => {
|
|
setShowRegister(false);
|
|
void refetch();
|
|
}}
|
|
/>
|
|
) : (
|
|
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
|
)}
|
|
</SignedOut>
|
|
);
|
|
}
|
|
// Authenticated but not a member. This is a step in the flow, not an
|
|
// error — sending them back to a login screen they have already completed
|
|
// would be a loop with no exit.
|
|
if (error.needsProfile) {
|
|
return (
|
|
<SignedOut>
|
|
<CreateProfile config={config} onCreated={() => void refetch()} />
|
|
</SignedOut>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Something went wrong"
|
|
description={error instanceof Error ? error.message : 'Unknown error.'}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<IdentityProvider identity={data}>
|
|
<LayoutProvider>
|
|
<PiggyContextProvider>
|
|
<AppRoutes />
|
|
</PiggyContextProvider>
|
|
</LayoutProvider>
|
|
</IdentityProvider>
|
|
);
|
|
}
|
|
|
|
function AppRoutes() {
|
|
return (
|
|
<Routes>
|
|
<Route element={<Shell />}>
|
|
<Route index element={<RoutePage><Overview /></RoutePage>} />
|
|
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
|
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
|
|
<Route path="calendar" element={<RoutePage><Calendar /></RoutePage>} />
|
|
<Route path="learn" element={<RoutePage><Learn /></RoutePage>} />
|
|
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
|
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
|
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
|
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
|
|
{/*
|
|
The first record route in the product. Registered after the list so
|
|
the list keeps `/accounts` exactly; react-router matches the more
|
|
specific path regardless of order, but keeping them adjacent is how
|
|
the next four record routes will read.
|
|
*/}
|
|
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
|
|
{/*
|
|
Flat, in the register of the routes above — nesting these under a
|
|
layout route would give Motion a chrome no other group has, and the
|
|
five pages share no shell of their own.
|
|
*/}
|
|
<Route path="motion" element={<RoutePage><Motion /></RoutePage>} />
|
|
<Route path="motion/library" element={<RoutePage><MotionLibrary /></RoutePage>} />
|
|
<Route path="motion/library/:id" element={<RoutePage><MotionTemplate /></RoutePage>} />
|
|
<Route path="motion/engagements" element={<RoutePage><MotionEngagements /></RoutePage>} />
|
|
<Route path="motion/engagements/:id" element={<RoutePage><Engagement /></RoutePage>} />
|
|
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
|
|
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
|
|
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
|
|
<Route path="team" element={<Team />} />
|
|
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
|
|
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
|
|
<Route path="*" element={<Placeholder title="Not found" />} />
|
|
</Route>
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
function RoutePage({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Suspense fallback={<RouteLoading />}>
|
|
{children}
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function RouteLoading() {
|
|
return (
|
|
<div
|
|
className="flex min-h-[50dvh] items-center justify-center"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
|
<PiggyMark className="h-9 w-9 animate-pulse text-fg" aria-hidden />
|
|
<span>Loading view…</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Splash() {
|
|
return (
|
|
<Centered>
|
|
<PiggyMark className="h-12 w-12 animate-pulse text-fg" title="Loading pig" />
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex min-h-dvh items-center justify-center bg-bg px-6">
|
|
<div className="w-full max-w-md">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Placeholder({ title }: { title: string }) {
|
|
usePageTitle(title);
|
|
return (
|
|
<EmptyState
|
|
title={title}
|
|
description="That page does not exist or may have moved. Use Search to return to a workspace."
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Team() {
|
|
usePageTitle('Team');
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['team'],
|
|
queryFn: () =>
|
|
get<
|
|
{
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
title: string | null;
|
|
teams: { team: string; role: string }[];
|
|
}[]
|
|
>('/api/team'),
|
|
});
|
|
|
|
const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0);
|
|
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
|
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
|
See who can operate each side of the compute business and where ownership is thin.
|
|
</p>
|
|
</div>
|
|
<Link
|
|
to="/settings"
|
|
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
|
|
>
|
|
Manage access in Settings
|
|
</Link>
|
|
</header>
|
|
|
|
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
|
|
{[
|
|
['People', data?.length ?? 0],
|
|
['Teams', representedTeams],
|
|
['Assignments', assignments],
|
|
].map(([label, value]) => (
|
|
<Card key={label} className="p-3 sm:p-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
|
|
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{error ? (
|
|
<Card>
|
|
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
|
|
</Card>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length === 0 ? (
|
|
<Card>
|
|
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
|
|
</Card>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length ? (
|
|
<section aria-labelledby="team-members-heading">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
|
|
<span className="text-xs text-muted">Roles are enforced server-side</span>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{data.map((person) => {
|
|
const initials = person.name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join('');
|
|
return (
|
|
<Card key={person.id} className="p-4 sm:p-5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Avatar className="size-11 border border-border">
|
|
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
|
|
{initials || 'P'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold">{person.name}</p>
|
|
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
|
{person.teams.map((membership) => (
|
|
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
|
|
{membership.team} · {membership.role}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{person.teams.length === 0 ? (
|
|
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|