Add Motion: the go-to-market operating system on top of the ledger
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>
This commit is contained in:
@@ -36,6 +36,14 @@ const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default:
|
||||
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: {
|
||||
@@ -248,6 +256,16 @@ function AppRoutes() {
|
||||
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>} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Source with a copy button — the fence chrome, in one place.
|
||||
*
|
||||
* Reference architectures arrive as `mermaid`, both in a template's structured
|
||||
* `fields` and inside the markdown body a promoted artefact brings with it, and
|
||||
* neither is rendered as a picture. Bundling mermaid means roughly two
|
||||
* megabytes and evaluating author-supplied text, and the proxy allows exactly
|
||||
* one inline script by hash (AGENTS.md §5), so it would drag a CSP change onto
|
||||
* the deployment host too. Source with a copy button gets the reader into their
|
||||
* own diagram tool in two clicks; the picture is tracked as follow-up in
|
||||
* `docs/motion.md`.
|
||||
*
|
||||
* The copy button is here rather than in each caller because a diagram in a
|
||||
* body and a diagram in a field are the same thing to the person reading it,
|
||||
* and one of the two silently lacking the button is the sort of difference
|
||||
* nobody reports.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
export function CodeBlock({
|
||||
source,
|
||||
language,
|
||||
label,
|
||||
}: {
|
||||
source: string;
|
||||
/** The fence's language, shown as the block's caption. Absent for a bare fence. */
|
||||
language?: string | null;
|
||||
/** What the copy button says it copies, for a screen reader. */
|
||||
label?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
// `navigator.clipboard` is absent outside a secure context, which the LAN
|
||||
// dev server is — so this branch is reached routinely, not exceptionally.
|
||||
if (!navigator.clipboard) {
|
||||
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(source);
|
||||
setCopied(true);
|
||||
toast.success('Source copied.');
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pl-3">
|
||||
<span className="min-w-0 truncate font-mono text-[11px] lowercase text-muted">
|
||||
{language ?? 'source'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void copy()}
|
||||
aria-label={label ? `Copy the ${label} source` : 'Copy the source'}
|
||||
>
|
||||
{copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="scroll-x p-3 text-xs leading-5">
|
||||
<code className="font-mono">{source}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* The structured half of a template, rendered per kind.
|
||||
*
|
||||
* `fields` is authored JSON. It is not validated on the way out of the
|
||||
* database, it is edited by anyone with `motion:write`, it survives promotion
|
||||
* from an engagement artefact unchanged, and it will outlive whatever shape
|
||||
* this file expects today. So every access below is narrowed, every list is
|
||||
* filtered to the entries that carry the property being rendered, and a shape
|
||||
* this file does not recognise renders as nothing at all.
|
||||
*
|
||||
* That last rule is the important one. The alternative — assume the shape and
|
||||
* let the page throw — turns one badly-typed seed row into a blank library for
|
||||
* everybody, and the crash surfaces in a route far from the row that caused it.
|
||||
* An omitted section is a bug someone reports; a white screen is an outage.
|
||||
*
|
||||
* A qualification framework's dimensions are shown with their weights and
|
||||
* their 0–4 anchors, because the anchors are what stop a score being a vibe:
|
||||
* the number only means something if two people reading the same evidence pick
|
||||
* the same one.
|
||||
*/
|
||||
import { type ReactNode } from 'react';
|
||||
import { Skull, TriangleAlert } from 'lucide-react';
|
||||
import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core';
|
||||
import { Badge, cn } from '@/components/ui';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { anchorScale, asRecord, number, recordList, text, textList } from './fields';
|
||||
|
||||
export function FieldsView({
|
||||
kind,
|
||||
fields,
|
||||
className,
|
||||
}: {
|
||||
kind: MotionKind;
|
||||
/** Untrusted. `unknown` on purpose — see the header. */
|
||||
fields: unknown;
|
||||
className?: string;
|
||||
}) {
|
||||
const record = asRecord(fields);
|
||||
if (!record) return null;
|
||||
|
||||
const body = renderKind(kind, record);
|
||||
if (!body) return null;
|
||||
|
||||
return <div className={cn('min-w-0 space-y-5', className)}>{body}</div>;
|
||||
}
|
||||
|
||||
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
|
||||
switch (kind) {
|
||||
case 'discovery':
|
||||
return <DiscoveryFields fields={fields} />;
|
||||
case 'qualification':
|
||||
return <QualificationFields fields={fields} />;
|
||||
case 'poc':
|
||||
return <PocFields fields={fields} />;
|
||||
case 'proposal':
|
||||
return <ProposalFields fields={fields} />;
|
||||
case 'pricing':
|
||||
return <PricingFields fields={fields} />;
|
||||
case 'architecture':
|
||||
return <ArchitectureFields fields={fields} />;
|
||||
case 'case_study':
|
||||
return <CaseStudyFields fields={fields} />;
|
||||
case 'narrative':
|
||||
return <NarrativeFields fields={fields} />;
|
||||
case 'playbook':
|
||||
return <PlaybookFields fields={fields} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the kinds
|
||||
|
||||
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const sections = recordList(fields.sections);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section, index) => {
|
||||
const name = text(section.name);
|
||||
if (!name) return null;
|
||||
const questions = recordList(section.questions);
|
||||
return (
|
||||
<Section key={index} title={name}>
|
||||
<Note label="Goal" value={text(section.goal)} />
|
||||
<Note label="A bad answer" value={text(section.badAnswer)} tone="warning" />
|
||||
{questions.length === 0 ? null : (
|
||||
<ol className="min-w-0 space-y-3">
|
||||
{questions.map((question, questionIndex) => {
|
||||
const asked = text(question.q);
|
||||
if (!asked) return null;
|
||||
return (
|
||||
<li key={questionIndex} className="min-w-0 rounded-lg border border-border p-3">
|
||||
<p className="min-w-0 break-words font-medium leading-6">{asked}</p>
|
||||
<Note label="Why" value={text(question.why)} />
|
||||
<Note label="Listen for" value={text(question.listenFor)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const dimensions = recordList(fields.dimensions);
|
||||
const bands = recordList(fields.bands);
|
||||
const disqualifiers = recordList(fields.disqualifiers);
|
||||
if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null;
|
||||
|
||||
const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dimensions.length === 0 ? null : (
|
||||
<Section
|
||||
title="Dimensions"
|
||||
// Weights need not sum to 100 — the score is a proportion of the
|
||||
// maximum — so the total is shown rather than assumed.
|
||||
aside={weightTotal > 0 ? `${weightTotal} weight in total` : undefined}
|
||||
>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{dimensions.map((dimension, index) => {
|
||||
const name = text(dimension.name);
|
||||
if (!name) return null;
|
||||
const weight = number(dimension.weight);
|
||||
const group = text(dimension.group);
|
||||
return (
|
||||
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
|
||||
{group ? <Badge tone="neutral">{group}</Badge> : null}
|
||||
{weight === null ? null : (
|
||||
<span className="nums whitespace-nowrap text-xs text-muted">weight {weight}</span>
|
||||
)}
|
||||
</div>
|
||||
<Note label="Why it predicts" value={text(dimension.why)} />
|
||||
<Anchors anchors={dimension.anchors} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{bands.length === 0 ? null : (
|
||||
<Section title="What each score means">
|
||||
<ul className="min-w-0 space-y-2">
|
||||
{bands.map((band, index) => {
|
||||
const label = text(band.label);
|
||||
if (!label) return null;
|
||||
const min = number(band.min);
|
||||
const max = number(band.max);
|
||||
return (
|
||||
<li key={index} className="min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
{min === null || max === null ? null : (
|
||||
<span className="nums ml-2 text-xs text-muted">
|
||||
{min}–{max}
|
||||
</span>
|
||||
)}
|
||||
<Note label="Action" value={text(band.action)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{disqualifiers.length === 0 ? null : (
|
||||
<Section title="Disqualifiers" icon={<Skull className="size-4" aria-hidden />}>
|
||||
<ul className="min-w-0 space-y-3">
|
||||
{disqualifiers.map((disqualifier, index) => {
|
||||
const name = text(disqualifier.name);
|
||||
if (!name) return null;
|
||||
return (
|
||||
<li key={index} className="min-w-0">
|
||||
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
|
||||
<Note label="Test" value={text(disqualifier.test)} />
|
||||
<Note label="Why" value={text(disqualifier.why)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The 0–4 scale, extracted by the same rule the scorer reads it with. */
|
||||
function Anchors({ anchors }: { anchors: unknown }) {
|
||||
const rows = anchorScale(anchors);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<dl className="mt-3 min-w-0 space-y-1.5 border-t border-border pt-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.score} className="flex min-w-0 gap-3">
|
||||
<dt className="nums w-5 shrink-0 text-sm font-semibold text-muted">{row.score}</dt>
|
||||
<dd className="min-w-0 break-words text-sm leading-6">{row.anchor}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function PocFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const hypothesis = text(fields.hypothesis);
|
||||
const milestones = recordList(fields.milestones);
|
||||
const metrics = recordList(fields.successMetrics);
|
||||
const risks = recordList(fields.risks);
|
||||
if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hypothesis ? (
|
||||
<Section title="Hypothesis">
|
||||
<p className="min-w-0 break-words leading-6">{hypothesis}</p>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{milestones.length === 0 ? null : (
|
||||
<Section title="Milestones">
|
||||
<ol className="min-w-0 space-y-3">
|
||||
{milestones.map((milestone, index) => {
|
||||
const title = text(milestone.title);
|
||||
if (!title) return null;
|
||||
return (
|
||||
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{text(milestone.week) ? (
|
||||
<Badge tone="neutral">{text(milestone.week)}</Badge>
|
||||
) : null}
|
||||
<span className="min-w-0 break-words font-medium leading-6">{title}</span>
|
||||
{/* A kill gate is the only reason a POC ends early rather
|
||||
than drifting into a second quarter, so it is the one
|
||||
property here that earns a colour. */}
|
||||
{milestone.killGate === true ? <Badge tone="danger">Kill gate</Badge> : null}
|
||||
</div>
|
||||
<Note label="Exit criterion" value={text(milestone.exitCriterion)} />
|
||||
<Note label="Owner" value={text(milestone.owner)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{metrics.length === 0 ? null : (
|
||||
<Section title="Success metrics">
|
||||
<ScrollTable
|
||||
head={['Metric', 'Baseline', 'Target', 'Measured by']}
|
||||
rows={metrics.map((metric) => [
|
||||
text(metric.metric),
|
||||
text(metric.baseline),
|
||||
text(metric.target),
|
||||
text(metric.measuredBy),
|
||||
])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{risks.length === 0 ? null : (
|
||||
<Section title="Risks" icon={<TriangleAlert className="size-4" aria-hidden />}>
|
||||
<ScrollTable
|
||||
head={['Risk', 'Owner', 'Mitigation']}
|
||||
rows={risks.map((risk) => [text(risk.risk), text(risk.owner), text(risk.mitigation)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const blocks = recordList(fields.blocks);
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
const title = text(block.title);
|
||||
if (!title) return null;
|
||||
const body = text(block.text);
|
||||
return (
|
||||
<Section key={index} title={title}>
|
||||
<Note label="Use when" value={text(block.useWhen)} />
|
||||
<Note label="Avoid when" value={text(block.avoidWhen)} tone="warning" />
|
||||
{body ? (
|
||||
// Proposal blocks are lifted verbatim into a document, so the
|
||||
// whitespace the author wrote is part of the block.
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
|
||||
{body}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const inputs = recordList(fields.inputs);
|
||||
const packages = recordList(fields.packages);
|
||||
const tradeables = recordList(fields.tradeables);
|
||||
if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{inputs.length === 0 ? null : (
|
||||
<Section title="Inputs">
|
||||
<div className="min-w-0 space-y-3">
|
||||
{inputs.map((input, index) => {
|
||||
const name = text(input.name);
|
||||
if (!name) return null;
|
||||
const unit = text(input.unit);
|
||||
return (
|
||||
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 break-words font-medium leading-6">{name}</span>
|
||||
{unit ? <Badge tone="neutral">{unit}</Badge> : null}
|
||||
</div>
|
||||
<Note label="Where it comes from" value={text(input.howToGet)} />
|
||||
<Note label="Why it matters" value={text(input.whyItMatters)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{packages.length === 0 ? null : (
|
||||
<Section title="Packages">
|
||||
<ScrollTable
|
||||
head={['Package', 'Shape', 'Fits when', 'Fails when']}
|
||||
rows={packages.map((entry) => [
|
||||
text(entry.name),
|
||||
text(entry.shape),
|
||||
text(entry.fitsWhen),
|
||||
text(entry.failsWhen),
|
||||
])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tradeables.length === 0 ? null : (
|
||||
<Section title="What to trade">
|
||||
<ScrollTable
|
||||
head={['Give', 'Get']}
|
||||
rows={tradeables.map((entry) => [text(entry.give), text(entry.get)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchitectureFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const architectures = recordList(fields.architectures);
|
||||
if (architectures.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{architectures.map((architecture, index) => {
|
||||
const name = text(architecture.name);
|
||||
if (!name) return null;
|
||||
const components = recordList(architecture.components);
|
||||
return (
|
||||
<Section key={index} title={name}>
|
||||
<Note label="Fits when" value={text(architecture.fitsWhen)} />
|
||||
<Note label="Fails when" value={text(architecture.failureMode)} tone="warning" />
|
||||
<Diagram source={text(architecture.mermaid)} name={name} />
|
||||
{components.length === 0 ? null : (
|
||||
<ScrollTable
|
||||
head={['Component', 'Run by', 'Why']}
|
||||
rows={components.map((component) => [
|
||||
text(component.component),
|
||||
text(component.runBy),
|
||||
text(component.why),
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The diagram, as source rather than as a picture — deliberately, for now. See
|
||||
* `CodeBlock` for why, and for the copy button that makes it usable.
|
||||
*/
|
||||
function Diagram({ source, name }: { source: string | null; name: string }) {
|
||||
if (!source) return null;
|
||||
return <CodeBlock source={source} language="mermaid" label={`${name} diagram`} />;
|
||||
}
|
||||
|
||||
function CaseStudyFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const sections = recordList(fields.sections);
|
||||
const harvest = recordList(fields.harvest);
|
||||
if (sections.length === 0 && harvest.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.length === 0 ? null : (
|
||||
<Section title="Sections">
|
||||
<ol className="min-w-0 space-y-3">
|
||||
{sections.map((section, index) => {
|
||||
const name = text(section.name);
|
||||
if (!name) return null;
|
||||
return (
|
||||
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
|
||||
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
|
||||
<Note label="What goes here" value={text(section.prompt)} />
|
||||
{/* Evidence rules are the reason a case study can be shown to
|
||||
the next customer at all — see AGENTS.md §4. */}
|
||||
<Note label="Evidence rule" value={text(section.evidenceRule)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{harvest.length === 0 ? null : (
|
||||
<Section title="When to capture it">
|
||||
<ScrollTable
|
||||
head={['When', 'Capture', 'Why']}
|
||||
rows={harvest.map((entry) => [text(entry.when), text(entry.capture), text(entry.why)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NarrativeFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const narratives = recordList(fields.narratives);
|
||||
if (narratives.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{narratives.map((narrative, index) => {
|
||||
const audience = text(narrative.audience);
|
||||
if (!audience) return null;
|
||||
const body = text(narrative.text);
|
||||
return (
|
||||
<Section key={index} title={audience}>
|
||||
<Note label="What they already believe" value={text(narrative.belief)} />
|
||||
{body ? (
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
|
||||
{body}
|
||||
</p>
|
||||
) : null}
|
||||
<Note label="Analogy that works" value={text(narrative.analogyThatWorks)} />
|
||||
<Note label="Analogy that fails" value={text(narrative.analogyThatFails)} tone="warning" />
|
||||
<Note label="What they ask next" value={text(narrative.nextQuestion)} />
|
||||
<Note label="Answer" value={text(narrative.answer)} />
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
|
||||
const stages = recordList(fields.stages);
|
||||
const research = asRecord(fields.researchInterface);
|
||||
const promotion = recordList(fields.promotion);
|
||||
if (stages.length === 0 && !research && promotion.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{stages.map((entry, index) => {
|
||||
const stage = stageLabel(entry.stage);
|
||||
if (!stage) return null;
|
||||
const days = number(entry.typicalDays);
|
||||
return (
|
||||
<Section key={index} title={stage} aside={days === null ? undefined : `~${days} days`}>
|
||||
<Note label="Entry" value={text(entry.entry)} />
|
||||
<Note label="What it does" value={text(entry.does)} />
|
||||
<Note label="Exit" value={text(entry.exit)} />
|
||||
<Pills label="Artefacts" items={textList(entry.artifacts)} />
|
||||
<Pills label="Who is involved" items={textList(entry.involves)} />
|
||||
{/* Where the stage dies is the part of a playbook that gets read
|
||||
twice — the sequence is obvious, the failure is not. */}
|
||||
<Note label="How it dies" value={text(entry.diesBy)} tone="warning" />
|
||||
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
|
||||
<Note label="Unstick move" value={text(entry.unstickMove)} />
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{research ? (
|
||||
<Section title="Research interface">
|
||||
<Pills label="Scope must contain" items={textList(research.scopeMustContain)} />
|
||||
<Pills label="Owed back" items={textList(research.oweBack)} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{promotion.length === 0 ? null : (
|
||||
<Section title="What to promote, and when">
|
||||
<ScrollTable
|
||||
head={['Trigger', 'Promote', 'Into']}
|
||||
rows={promotion.map((entry) => [text(entry.trigger), text(entry.promote), text(entry.into)])}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- fragments
|
||||
|
||||
function Section({
|
||||
title,
|
||||
aside,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
aside?: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="min-w-0 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex min-w-0 items-center gap-2 break-words font-semibold leading-snug">
|
||||
{icon ? <span className="shrink-0 text-muted">{icon}</span> : null}
|
||||
{title}
|
||||
</h3>
|
||||
{aside ? <span className="nums whitespace-nowrap text-xs text-muted">{aside}</span> : null}
|
||||
</div>
|
||||
<div className="mt-3 min-w-0 space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled paragraph. Renders nothing at all when the value is absent. */
|
||||
function Note({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
tone?: 'warning';
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<p className="mt-2 min-w-0 break-words text-sm leading-6">
|
||||
<span
|
||||
className={cn(
|
||||
'mr-2 text-xs font-medium uppercase tracking-wide',
|
||||
tone === 'warning' ? 'text-warning' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{value}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Pills({ label, items }: { label: string; items: string[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 min-w-0">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted">{label}</span>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap gap-1.5">
|
||||
{items.map((item, index) => (
|
||||
<Badge key={index} tone="neutral" className="min-w-0">
|
||||
<span className="truncate">{item}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A table whose overflow stays inside its own box. Rows are dropped when every
|
||||
* cell in them is empty, so a partially-authored list does not render as a
|
||||
* column of blank stripes.
|
||||
*/
|
||||
function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) {
|
||||
const present = rows.filter((row) => row.some((cell) => cell !== null));
|
||||
if (present.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="scroll-x min-w-0 rounded-lg border border-border">
|
||||
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
|
||||
<thead className="border-b border-border bg-surface-2">
|
||||
<tr>
|
||||
{head.map((heading) => (
|
||||
<th key={heading} className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted">
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{present.map((row, index) => (
|
||||
<tr key={index} className="transition-colors hover:bg-surface-2">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="max-w-[28rem] px-3 py-2 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- narrowing
|
||||
|
||||
/**
|
||||
* A playbook stage names a `DemandStage`, and the label comes from `@pig/core`
|
||||
* so a renamed stage renames here too. An unrecognised value is shown as
|
||||
* written rather than dropped: content authored ahead of an ontology change is
|
||||
* still worth reading.
|
||||
*/
|
||||
function stageLabel(value: unknown): string | null {
|
||||
const raw = text(value);
|
||||
if (!raw) return null;
|
||||
return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Half of the loop: library template → engagement artefact.
|
||||
*
|
||||
* The picker is a list rather than a grid of `TemplateCard`s, because the
|
||||
* question being answered here is not "what is in the library" but "which of
|
||||
* these four is the one for this stage" — so the rows are dense, the stage
|
||||
* filter opens pre-set to the deal's own stage, and usage is on every row.
|
||||
* Usage is the only quality signal the library has: a v3 used eleven times is
|
||||
* tested and a v1 used never is somebody's draft, and that distinction matters
|
||||
* more when copying into a live deal than when browsing.
|
||||
*
|
||||
* Instantiating copies the body and fields and increments the template's
|
||||
* usage count, which is what closes the template to in-place edits (§7a). That
|
||||
* is stated on the dialog rather than left to be discovered later by whoever
|
||||
* tries to fix a typo in it.
|
||||
*/
|
||||
import { useDeferredValue, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Library, RefreshCw, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DEMAND_STAGES,
|
||||
DEMAND_STAGE_LABELS,
|
||||
MOTION_KINDS,
|
||||
MOTION_KIND_LABELS,
|
||||
type DemandStage,
|
||||
type MotionKind,
|
||||
} from '@pig/core';
|
||||
import { get, post } from '@/lib/api';
|
||||
import { Badge, Button, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MotionKindBadge } from './MotionKindBadge';
|
||||
|
||||
/** `GET /api/motion/templates` — summaries, so no body and no fields. */
|
||||
interface TemplateRow {
|
||||
id: string;
|
||||
kind: MotionKind;
|
||||
slug: string;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
stage: DemandStage;
|
||||
visibility: 'private' | 'shared';
|
||||
isSystem: boolean;
|
||||
usageCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function InstantiateDialog({
|
||||
engagementId,
|
||||
defaultStage = null,
|
||||
defaultKind = null,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
engagementId: string;
|
||||
/** The deal's stage, so the list opens on the templates that serve it. */
|
||||
defaultStage?: DemandStage | null;
|
||||
/** Set when the caller is after one kind — a framework to score against. */
|
||||
defaultKind?: MotionKind | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [kind, setKind] = useState<'all' | MotionKind>(defaultKind ?? 'all');
|
||||
const [stage, setStage] = useState<'all' | DemandStage>(defaultStage ?? 'all');
|
||||
const [query, setQuery] = useState('');
|
||||
const search = useDeferredValue(query.trim());
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: ['motion', 'library', { kind, stage, search }],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (kind !== 'all') params.set('kind', kind);
|
||||
if (stage !== 'all') params.set('stage', stage);
|
||||
if (search) params.set('q', search);
|
||||
const suffix = params.toString();
|
||||
return get<{ templates: TemplateRow[]; truncated: boolean }>(
|
||||
`/api/motion/templates${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const instantiate = useMutation({
|
||||
mutationFn: (templateId: string) =>
|
||||
post<{ artifact: { id: string } }>(`/api/motion/engagements/${engagementId}/artifacts`, {
|
||||
templateId,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
// The prefix invalidation is deliberate: instantiating moves the
|
||||
// template's usage count too, so the library and the OS home are stale
|
||||
// the moment this succeeds, not only the engagement.
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success('Artefact added to the engagement');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const rows = templates.data?.templates ?? [];
|
||||
const filtered = kind !== 'all' || stage !== 'all' || Boolean(search);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85dvh] w-[calc(100vw-1.5rem)] max-w-2xl grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden p-4 sm:p-6">
|
||||
<DialogHeader className="pr-11">
|
||||
<DialogTitle>Instantiate from the library</DialogTitle>
|
||||
<DialogDescription>
|
||||
The body and structured fields are copied into this engagement, and the template is
|
||||
closed to in-place edits from here on — later changes to it become a new version.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_9.5rem_9.5rem]">
|
||||
<div className="relative min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
|
||||
<Input
|
||||
aria-label="Search the library"
|
||||
className="pl-9"
|
||||
placeholder="Search title or summary"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={kind} onValueChange={(value) => setKind(value as typeof kind)}>
|
||||
<SelectTrigger aria-label="Filter by kind" className="h-11 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All kinds</SelectItem>
|
||||
{MOTION_KINDS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{MOTION_KIND_LABELS[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={stage} onValueChange={(value) => setStage(value as typeof stage)}>
|
||||
<SelectTrigger aria-label="Filter by stage" className="h-11 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All stages</SelectItem>
|
||||
{DEMAND_STAGES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{DEMAND_STAGE_LABELS[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 overflow-y-auto">
|
||||
{templates.isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{templates.isError ? (
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Library unavailable"
|
||||
description={
|
||||
templates.error instanceof Error
|
||||
? templates.error.message
|
||||
: 'The library could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void templates.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Library />}
|
||||
title={filtered ? 'No template matches' : 'The library is empty'}
|
||||
description={
|
||||
filtered
|
||||
? 'Clear a filter, or write the artefact from scratch and promote it once it has proved itself.'
|
||||
: 'Nothing has been published yet. An artefact written here can be promoted into the library once it is final.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{rows.length ? (
|
||||
<ul className="flex min-w-0 flex-col gap-2">
|
||||
{rows.map((template) => (
|
||||
<li
|
||||
key={template.id}
|
||||
className="flex min-w-0 flex-col gap-2 rounded-xl border border-border p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<MotionKindBadge kind={template.kind} />
|
||||
<Badge tone="neutral" className="min-w-0">
|
||||
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
|
||||
</Badge>
|
||||
<span className="nums whitespace-nowrap text-xs text-muted">
|
||||
v{template.version} ·{' '}
|
||||
{template.usageCount === 1
|
||||
? 'used once'
|
||||
: `used ${template.usageCount} times`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 min-w-0 break-words font-medium leading-snug">
|
||||
{template.title}
|
||||
</p>
|
||||
{template.summary ? (
|
||||
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
|
||||
{template.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={instantiate.isPending}
|
||||
onClick={() => instantiate.mutate(template.id)}
|
||||
>
|
||||
Use this
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{templates.data?.truncated ? (
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
The library is wider than this answer. Narrow it with a kind, a stage or a search.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Motion bodies, rendered as markdown.
|
||||
*
|
||||
* A template body is a document — a discovery guide, a playbook, a narrative —
|
||||
* and the app had nowhere to render one: Piggy's transcript uses Streamdown,
|
||||
* which is built for a half-finished token stream in a 22rem dock, not for a
|
||||
* page of authored prose. This is the page-width counterpart, on plain
|
||||
* `react-markdown` with GFM for the tables and task lists the content uses.
|
||||
*
|
||||
* Every element is styled from the map below. There is no
|
||||
* `@tailwindcss/typography` in this repo and one is deliberately not being
|
||||
* added for this, so there is no `prose` to fall back on and an element absent
|
||||
* from the map renders with bare browser defaults.
|
||||
*
|
||||
* `rehypePlugins` is deliberately empty. Without `rehype-raw`, react-markdown
|
||||
* does not render embedded HTML at all, and its default `urlTransform` already
|
||||
* drops `javascript:` and other non-navigational protocols — so the safe
|
||||
* behaviour here is the behaviour of adding nothing. Template bodies are
|
||||
* author-written but they are also promoted out of engagement artefacts that
|
||||
* anyone with `motion:write` can edit, so they are treated as untrusted.
|
||||
*/
|
||||
import { isValidElement, type ComponentProps, type CSSProperties, type ReactNode } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { cn } from '@/components/ui';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
|
||||
/** Fenced blocks carry their language as `language-<name>` on the `code` element. */
|
||||
const LANGUAGE_CLASS = /language-([\w-]+)/;
|
||||
|
||||
export function Markdown({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Block rhythm lives on the container rather than on each element, so
|
||||
// the spacing between a heading and the paragraph under it does not
|
||||
// depend on which of the two carries the margin.
|
||||
'min-w-0 space-y-4 break-words text-sm leading-6 text-fg [&>*:first-child]:pt-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
p: ({ children }) => <p className="leading-6">{children}</p>,
|
||||
|
||||
/*
|
||||
* Headings buy their air with padding, not margin — the container's
|
||||
* `space-y-4` sets the gap below, and a margin above would be collapsed
|
||||
* against it inconsistently. The scale stays close to body size: these are
|
||||
* section headings inside a card, not the page's own title.
|
||||
*/
|
||||
h1: ({ children }) => <h1 className="pt-4 text-xl font-semibold tracking-tight">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="pt-4 text-lg font-semibold tracking-tight">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="pt-3 text-base font-semibold">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="pt-2 text-sm font-semibold">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="pt-2 text-sm font-medium text-muted">{children}</h5>,
|
||||
h6: ({ children }) => (
|
||||
<h6 className="pt-2 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>
|
||||
),
|
||||
|
||||
ul: ({ children }) => <ul className="list-disc space-y-1.5 pl-5 marker:text-muted">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal space-y-1.5 pl-5 marker:text-muted">{children}</ol>,
|
||||
// A nested list is the first *element* child of its item even when prose
|
||||
// precedes it, so the parent's `space-y` never reaches it.
|
||||
li: ({ children }) => <li className="leading-6 [&>ol]:mt-1.5 [&>ul]:mt-1.5">{children}</li>,
|
||||
|
||||
strong: ({ children }) => <strong className="font-semibold text-fg">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="text-muted line-through">{children}</del>,
|
||||
a: MarkdownLink,
|
||||
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-4 text-muted [&>*+*]:mt-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-border" />,
|
||||
|
||||
img: ({ src, alt }) => (
|
||||
// `referrerPolicy` so an image URL that arrived with a promoted artefact
|
||||
// cannot use the referer to learn which template the reader had open.
|
||||
<img
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={alt ?? ''}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="max-w-full rounded-lg border border-border"
|
||||
/>
|
||||
),
|
||||
|
||||
/*
|
||||
* The fence chrome is built entirely in `pre`, which never renders the
|
||||
* `code` element react-markdown handed it — it reads the language and the
|
||||
* text off it instead. That is what makes the `code` entry below reachable
|
||||
* only for inline code: react-markdown 10 stopped passing an `inline` flag,
|
||||
* and the usual replacement — guessing from the `language-` class — gets a
|
||||
* fenced block with no language wrong every time.
|
||||
*/
|
||||
pre: ({ children }) => <CodeFence>{children}</CodeFence>,
|
||||
code: ({ children }) => (
|
||||
<code className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
|
||||
table: ({ children }) => (
|
||||
// Without this the widest table on the page sets the width of the page,
|
||||
// and every route scrolls sideways on a phone.
|
||||
<div className="scroll-x rounded-lg border border-border">
|
||||
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
|
||||
the scroller rather than squash the columns when it is not. */}
|
||||
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
|
||||
// A row highlight is what lets you keep your place across a table that is
|
||||
// wider than the pane and has been scrolled sideways.
|
||||
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
|
||||
th: ({ children, style }) => (
|
||||
<th
|
||||
className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted"
|
||||
style={alignStyle(style)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
// No `nums` here, deliberately: tabular figures on every cell render an
|
||||
// author's prose column in the digit-width of a ledger.
|
||||
td: ({ children, style }) => (
|
||||
<td className="px-3 py-2 align-top" style={alignStyle(style)}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Links in a template body point out of PIG — a vendor's docs, a paper, a
|
||||
* customer's status page — so they open in a new tab rather than navigating a
|
||||
* workspace someone has unsaved edits in, carry no referer, and wear a marker
|
||||
* glyph so a plausible phrase cannot pass itself off as internal navigation.
|
||||
*/
|
||||
function MarkdownLink({ href, children }: ComponentProps<'a'>) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={href}
|
||||
className="font-medium text-info underline decoration-border underline-offset-2 hover:decoration-info"
|
||||
>
|
||||
{children}
|
||||
<ArrowUpRight className="ml-0.5 inline size-3 align-[-0.1em]" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fenced code block, built from the `code` element rather than around it.
|
||||
*
|
||||
* The chrome — the language caption and the copy button — comes from
|
||||
* `CodeBlock`, so a mermaid fence inside a promoted body offers the same copy
|
||||
* action a mermaid field does in `FieldsView`. They are the same thing to the
|
||||
* person reading them.
|
||||
*/
|
||||
function CodeFence({ children }: { children: ReactNode }) {
|
||||
const element = isValidElement<{ className?: string; children?: ReactNode }>(children)
|
||||
? children
|
||||
: null;
|
||||
const language = LANGUAGE_CLASS.exec(element?.props.className ?? '')?.[1];
|
||||
const text = codeText(element ? element.props.children : children);
|
||||
|
||||
return <CodeBlock source={text} language={language} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* GFM column alignment — the `---:` in a delimiter row — is the one piece of
|
||||
* element styling the markdown itself owns, and a currency column that
|
||||
* silently reverts to the left is the difference between a readable table and
|
||||
* a wall. It reaches the cell as `style.textAlign`; only that property is
|
||||
* taken, so nothing else an author writes can style the page.
|
||||
*/
|
||||
function alignStyle(style: CSSProperties | undefined): CSSProperties | undefined {
|
||||
const value = style?.textAlign;
|
||||
return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined;
|
||||
}
|
||||
|
||||
/** The fence body reaches us as React children, normally one text node deep. */
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === 'string') return children;
|
||||
if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join('');
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children);
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Which of the nine kinds a template or artefact is.
|
||||
*
|
||||
* The tone is neutral for every kind, deliberately. The badge palette here is
|
||||
* semantic — positive, warning, danger, info mean something about a number —
|
||||
* and the kinds are a taxonomy, not a severity scale; colouring them would
|
||||
* teach people to read "case study" as good news. The differentiation comes
|
||||
* from the glyph instead, which is what lets a mixed list of artefacts be
|
||||
* scanned by shape rather than read word by word.
|
||||
*/
|
||||
import {
|
||||
BookOpen,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
Route,
|
||||
Search,
|
||||
Tags,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { MOTION_KIND_DESCRIPTIONS, MOTION_KIND_LABELS, type MotionKind } from '@pig/core';
|
||||
import { Badge, cn } from '@/components/ui';
|
||||
|
||||
const KIND_ICONS: Record<MotionKind, LucideIcon> = {
|
||||
discovery: Search,
|
||||
qualification: Gauge,
|
||||
poc: FlaskConical,
|
||||
proposal: FileText,
|
||||
pricing: Tags,
|
||||
architecture: Network,
|
||||
case_study: BookOpen,
|
||||
narrative: MessagesSquare,
|
||||
playbook: Route,
|
||||
};
|
||||
|
||||
export function MotionKindBadge({
|
||||
kind,
|
||||
/** Glyph only, for a dense row where the label is already in the title. */
|
||||
iconOnly = false,
|
||||
className,
|
||||
}: {
|
||||
kind: MotionKind;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = KIND_ICONS[kind];
|
||||
const label = MOTION_KIND_LABELS[kind];
|
||||
return (
|
||||
<Badge
|
||||
tone="neutral"
|
||||
// The description is the only definition of a kind most people will ever
|
||||
// read, and there is nowhere else on a card to put it.
|
||||
title={`${label} — ${MOTION_KIND_DESCRIPTIONS[kind]}`}
|
||||
className={cn('min-w-0 max-w-full', className)}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" aria-hidden />
|
||||
{iconOnly ? <span className="sr-only">{label}</span> : <span className="truncate">{label}</span>}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Scoring a deal against a qualification framework.
|
||||
*
|
||||
* The arithmetic is `motionScoreBasisPoints` from `@pig/core` — the same pure
|
||||
* function the API calls before it writes the row. That import is the whole
|
||||
* design of this component: a client that computes the total itself would
|
||||
* eventually disagree with the server about a number people act on, and a
|
||||
* qualification score that reads 74% while you are filling the form in and
|
||||
* 71% once it is saved is worse than one the form never showed at all.
|
||||
* Nothing here posts a score; it posts the dimensions and lets the server
|
||||
* recompute, so the two can never diverge even if this file is wrong.
|
||||
*
|
||||
* Every dimension must be answered before Save enables. A partial score is not
|
||||
* a smaller score — the maximum shrinks with it, so answering only the three
|
||||
* dimensions that went well produces a band of "Strategic" on a deal nobody
|
||||
* has qualified. The running total shown while the form is incomplete is
|
||||
* labelled provisional for exactly that reason.
|
||||
*
|
||||
* `fields` is authored JSON and arrives unvalidated, the same way it does in
|
||||
* `FieldsView`: a dimension without an id cannot be posted (the API keys on
|
||||
* it) and a dimension without anchors cannot be scored honestly, so both are
|
||||
* dropped rather than rendered as an empty control.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
MOTION_MAX_DIMENSION_SCORE,
|
||||
MOTION_MIN_DIMENSION_SCORE,
|
||||
motionBand,
|
||||
motionScoreBasisPoints,
|
||||
type MotionDimensionScore,
|
||||
} from '@pig/core';
|
||||
import { post } from '@/lib/api';
|
||||
import { Badge, Button, cn } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { anchorScale, asRecord, number, text, type AnchorRow } from './fields';
|
||||
import { percent } from './format';
|
||||
|
||||
/** A dimension this component was able to make sense of. */
|
||||
interface ScorableDimension {
|
||||
id: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
group: string | null;
|
||||
why: string | null;
|
||||
anchors: AnchorRow[];
|
||||
}
|
||||
|
||||
export function QualificationScorer({
|
||||
engagementId,
|
||||
frameworkTemplateId,
|
||||
fields,
|
||||
previousBasisPoints = null,
|
||||
onScored,
|
||||
onCancel,
|
||||
}: {
|
||||
engagementId: string;
|
||||
/** Recorded on the score so the framework behind it stays identifiable. */
|
||||
frameworkTemplateId: string | null;
|
||||
/** The framework's `fields`. Untrusted author JSON — see the header. */
|
||||
fields: unknown;
|
||||
/** The last score on this engagement, if there is one, for the movement. */
|
||||
previousBasisPoints?: number | null;
|
||||
onScored: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const dimensions = useMemo(() => scorableDimensions(fields), [fields]);
|
||||
const [answers, setAnswers] = useState<Record<string, number>>({});
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const answered: MotionDimensionScore[] = dimensions
|
||||
.filter((dimension) => answers[dimension.id] !== undefined)
|
||||
.map((dimension) => ({
|
||||
id: dimension.id,
|
||||
weight: dimension.weight,
|
||||
score: answers[dimension.id] as number,
|
||||
}));
|
||||
const complete = dimensions.length > 0 && answered.length === dimensions.length;
|
||||
const basisPoints = motionScoreBasisPoints(answered);
|
||||
const band = motionBand(basisPoints);
|
||||
const movement =
|
||||
complete && previousBasisPoints !== null ? basisPoints - previousBasisPoints : null;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
post<{ score: { id: string } }>(`/api/motion/engagements/${engagementId}/scores`, {
|
||||
dimensions: answered,
|
||||
frameworkTemplateId,
|
||||
note: note.trim() ? note.trim() : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success('Qualification scored');
|
||||
onScored();
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (dimensions.length === 0) {
|
||||
return (
|
||||
<div className="mt-6 rounded-lg border border-border p-4">
|
||||
<p className="font-medium">This framework has no scorable dimensions</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
A dimension needs an id, a name and at least one anchor before it can be scored. Fix
|
||||
the framework in the library, then score against a new version of it.
|
||||
</p>
|
||||
<Button className="mt-4" type="button" variant="outline" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-5 flex min-w-0 flex-col gap-5 pb-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{/* Sticky, because the reason to show a running total at all is to let
|
||||
somebody see a marginal answer move the band while they are still
|
||||
looking at the anchors that produced it. */}
|
||||
<div className="sticky top-0 z-10 -mx-1 min-w-0 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="nums text-3xl font-semibold leading-none">{percent(basisPoints)}</span>
|
||||
<Badge tone={band.tone}>{band.label}</Badge>
|
||||
{movement === null ? null : <Movement delta={movement} />}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
{complete ? (
|
||||
<>
|
||||
Weighted across {dimensions.length} dimensions ·{' '}
|
||||
<span className="nums">{basisPoints}</span> basis points
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Provisional — {answered.length} of {dimensions.length} scored. The maximum shrinks
|
||||
with the dimensions you leave blank, so this band is not the deal's band yet.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
{dimensions.map((dimension) => (
|
||||
<fieldset key={dimension.id} className="min-w-0 rounded-xl border border-border p-3">
|
||||
<legend className="flex min-w-0 flex-wrap items-center gap-2 px-1">
|
||||
<span className="min-w-0 break-words font-medium leading-6">{dimension.name}</span>
|
||||
{dimension.group ? <Badge tone="neutral">{dimension.group}</Badge> : null}
|
||||
<span className="nums whitespace-nowrap text-xs text-muted">
|
||||
{dimension.weight === 0 ? 'no weight' : `weight ${dimension.weight}`}
|
||||
</span>
|
||||
</legend>
|
||||
{dimension.why ? (
|
||||
<p className="min-w-0 break-words text-sm leading-6 text-muted">{dimension.why}</p>
|
||||
) : null}
|
||||
<div className="mt-2 min-w-0 space-y-1.5">
|
||||
{dimension.anchors.map((anchor) => {
|
||||
const selected = answers[dimension.id] === anchor.score;
|
||||
return (
|
||||
<label
|
||||
key={anchor.score}
|
||||
className={cn(
|
||||
'tap flex min-h-11 min-w-0 cursor-pointer gap-3 rounded-lg border p-2.5',
|
||||
selected ? 'border-brand bg-surface-2' : 'border-border hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="sr-only"
|
||||
name={`dimension-${dimension.id}`}
|
||||
value={anchor.score}
|
||||
checked={selected}
|
||||
onChange={() =>
|
||||
setAnswers((current) => ({ ...current, [dimension.id]: anchor.score }))
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'nums flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
||||
selected ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{anchor.score}
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-sm leading-6">{anchor.anchor}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="qualification-note">
|
||||
What moved since the last score
|
||||
</label>
|
||||
<Textarea
|
||||
id="qualification-note"
|
||||
className="min-h-24"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="The evidence behind the answers that changed."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!complete || save.isPending}
|
||||
title={complete ? undefined : 'Score every dimension first.'}
|
||||
>
|
||||
Record score
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Movement({ delta }: { delta: number }) {
|
||||
if (delta === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted">
|
||||
<Minus className="size-3.5" aria-hidden />
|
||||
Unchanged
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const Icon = delta > 0 ? ArrowUpRight : ArrowDownRight;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'nums inline-flex items-center gap-1 text-xs font-medium',
|
||||
delta > 0 ? 'text-positive' : 'text-danger',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{delta > 0 ? '+' : '−'}
|
||||
{percent(Math.abs(delta))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ narrowing
|
||||
|
||||
function scorableDimensions(fields: unknown): ScorableDimension[] {
|
||||
const record = asRecord(fields);
|
||||
const raw = Array.isArray(record?.dimensions) ? record.dimensions : [];
|
||||
const seen = new Set<string>();
|
||||
const scorable: ScorableDimension[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
const dimension = asRecord(entry);
|
||||
if (!dimension) continue;
|
||||
const id = text(dimension.id);
|
||||
const name = text(dimension.name);
|
||||
if (!id || !name || seen.has(id)) continue;
|
||||
const anchors = anchorScale(dimension.anchors);
|
||||
if (anchors.length === 0) continue;
|
||||
seen.add(id);
|
||||
scorable.push({
|
||||
id,
|
||||
name,
|
||||
// Clamped to the API's own bound rather than trusted: a weight the seed
|
||||
// author typed as 1500 would otherwise fail zod after the whole form is
|
||||
// filled in, which reads as the save being broken.
|
||||
weight: Math.min(1_000, Math.max(0, Math.round(number(dimension.weight) ?? 0))),
|
||||
group: text(dimension.group),
|
||||
why: text(dimension.why),
|
||||
anchors,
|
||||
});
|
||||
}
|
||||
return scorable;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The demand motion, across the top.
|
||||
*
|
||||
* The eight open stages are the spine of the whole feature — Motion binds
|
||||
* artefacts to them rather than inventing a second pipeline — so the rail is
|
||||
* the one control that appears on the OS home, the library filter and the
|
||||
* engagement workspace, and it has to mean the same thing in all three.
|
||||
*
|
||||
* The closed stages are absent because `DEMAND_OPEN_STAGES` is the default:
|
||||
* a won or lost deal has left the motion, and a rail segment nobody can file
|
||||
* work against is a segment people ask about once a quarter.
|
||||
*
|
||||
* It scrolls horizontally rather than wrapping. Wrapping puts `procurement`
|
||||
* under `qualification` at 393px, which reads as a second row of the sequence
|
||||
* starting over; a scroller keeps the order legible and keeps the overflow
|
||||
* inside this box instead of dragging the page sideways.
|
||||
*/
|
||||
import { DEMAND_OPEN_STAGES, DEMAND_STAGE_LABELS, type DemandStage } from '@pig/core';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
export function StageRail({
|
||||
counts,
|
||||
coverage,
|
||||
stages = DEMAND_OPEN_STAGES,
|
||||
countLabel = 'engagements',
|
||||
coverageLabel = 'templates',
|
||||
activeStage,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
/** The headline figure per stage. A stage absent from the record reads as 0. */
|
||||
counts: Partial<Record<DemandStage, number>>;
|
||||
/** The second figure, if the caller has one — library cover, typically. */
|
||||
coverage?: Partial<Record<DemandStage, number>>;
|
||||
stages?: readonly DemandStage[];
|
||||
countLabel?: string;
|
||||
coverageLabel?: string;
|
||||
activeStage?: DemandStage | null;
|
||||
/** Omit to render a read-only rail: a non-interactive button is a trap. */
|
||||
onSelect?: (stage: DemandStage) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('scroll-x min-w-0 pb-1', className)}>
|
||||
<ol className="flex min-w-0 items-stretch gap-2">
|
||||
{stages.map((stage) => {
|
||||
const count = counts[stage] ?? 0;
|
||||
const covered = coverage?.[stage];
|
||||
const active = activeStage === stage;
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{DEMAND_STAGE_LABELS[stage]}
|
||||
</span>
|
||||
<span className={cn('nums text-2xl font-semibold leading-none', count === 0 && 'text-muted')}>
|
||||
{count}
|
||||
</span>
|
||||
{covered === undefined ? null : (
|
||||
// A stage with no template is the finding this rail exists to
|
||||
// surface — it is where the motion stops repeating — so it is
|
||||
// called out rather than shown as another grey zero.
|
||||
<span className={cn('nums truncate text-xs', covered === 0 ? 'text-warning' : 'text-muted')}>
|
||||
{covered === 0 ? `No ${coverageLabel}` : `${covered} ${coverageLabel}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const shape = cn(
|
||||
'tap flex w-[8.5rem] shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
|
||||
active ? 'border-brand bg-surface-2' : 'border-border bg-surface',
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={stage} className="flex min-w-0">
|
||||
{onSelect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(stage)}
|
||||
aria-pressed={active}
|
||||
aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}
|
||||
className={cn(shape, 'transition-colors hover:bg-surface-2')}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div className={shape} aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* One library template, as a browsable card.
|
||||
*
|
||||
* The three facts on it that are not the title are the ones that decide
|
||||
* whether it is worth opening: who can see it, how many engagements have
|
||||
* already used it, and whether it came back out of one. Usage is the closest
|
||||
* thing the library has to a quality signal — a v3 used eleven times is
|
||||
* tested, a v1 used never is a draft somebody left — and hiding it behind a
|
||||
* click is what turns a library into a folder.
|
||||
*/
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, Sparkles, Users } from 'lucide-react';
|
||||
import { DEMAND_STAGE_LABELS } from '@pig/core';
|
||||
import { Badge, Card, cn } from '@/components/ui';
|
||||
import { MotionKindBadge } from './MotionKindBadge';
|
||||
import type { MotionTemplateView } from './model';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function TemplateCard({
|
||||
template,
|
||||
to,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
template: MotionTemplateView;
|
||||
/** Defaults to the template's own page; pass a filtered return path instead. */
|
||||
to?: string;
|
||||
/** Actions belonging to the calling page — instantiate, publish, fork. */
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('flex min-w-0 flex-col gap-3 p-4', className)}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<MotionKindBadge kind={template.kind} />
|
||||
<Badge tone="neutral" className="min-w-0">
|
||||
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
|
||||
</Badge>
|
||||
{template.visibility === 'private' ? (
|
||||
<Badge tone="warning" title="Only you and a platform admin can see this">
|
||||
<Lock className="size-3 shrink-0" aria-hidden />
|
||||
Private
|
||||
</Badge>
|
||||
) : null}
|
||||
{template.isSystem ? (
|
||||
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
|
||||
<Sparkles className="size-3 shrink-0" aria-hidden />
|
||||
Starter
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{/* break-words, not truncate: the title is the only way to tell two
|
||||
versions of the same lineage apart, and an unbroken word at 393px
|
||||
is what drags the whole page sideways. */}
|
||||
<h3 className="min-w-0 break-words font-semibold leading-snug">
|
||||
<Link
|
||||
to={to ?? `/motion/library/${template.id}`}
|
||||
className="underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
>
|
||||
{template.title}
|
||||
</Link>
|
||||
</h3>
|
||||
{template.summary ? (
|
||||
<p className="mt-1 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
|
||||
{template.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<span className="nums whitespace-nowrap">v{template.version}</span>
|
||||
<span className="nums inline-flex min-w-0 items-center gap-1 whitespace-nowrap">
|
||||
<Users className="size-3 shrink-0" aria-hidden />
|
||||
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
|
||||
</span>
|
||||
{template.originArtifactId ? (
|
||||
// The loop, made visible. This row exists because an engagement
|
||||
// proved it, which is the whole argument for the feature.
|
||||
<span className="min-w-0 truncate">Promoted from an engagement</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{footer ? <div className="flex min-w-0 flex-wrap items-center gap-2">{footer}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Narrowing the authored JSON a template carries in `fields`.
|
||||
*
|
||||
* One rule per shape, in one place, because there are two readers of the same
|
||||
* bytes and they must not disagree. `FieldsView` renders a qualification
|
||||
* framework's dimensions and `QualificationScorer` turns the same dimensions
|
||||
* into a form — so the day whitespace-only anchors are made to render as an
|
||||
* em-dash rather than vanish, a second copy of `text()` would leave the scorer
|
||||
* still dropping them, and the reader would be shown five dimensions while the
|
||||
* score was computed against four.
|
||||
*
|
||||
* Everything here is total: an unrecognised shape yields null or an empty
|
||||
* list, never a throw. An omitted section is a bug someone reports; a white
|
||||
* screen on one badly-typed row is an outage.
|
||||
*/
|
||||
import { MOTION_MAX_DIMENSION_SCORE, MOTION_MIN_DIMENSION_SCORE } from '@pig/core';
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
// `typeof null` is 'object' and an array is one too; both would satisfy a
|
||||
// naive check and then read `undefined` off every property.
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A non-empty string, or null. Whitespace-only is treated as absent. */
|
||||
export function text(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
export function number(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The entries of a list that are objects. Anything else is dropped silently. */
|
||||
export function recordList(value: unknown): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => entry !== null);
|
||||
}
|
||||
|
||||
export function textList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => text(entry)).filter((entry): entry is string => entry !== null);
|
||||
}
|
||||
|
||||
export interface AnchorRow {
|
||||
score: number;
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 0–4 scale, from `@pig/core` rather than from the keys present in the
|
||||
* JSON, so a framework that forgot to write an anchor for 2 still offers the
|
||||
* same scale everywhere it is read.
|
||||
*/
|
||||
export function anchorScale(anchors: unknown): AnchorRow[] {
|
||||
const record = asRecord(anchors);
|
||||
if (!record) return [];
|
||||
|
||||
const rows: AnchorRow[] = [];
|
||||
for (let score = MOTION_MIN_DIMENSION_SCORE; score <= MOTION_MAX_DIMENSION_SCORE; score += 1) {
|
||||
const anchor = text(record[String(score)]);
|
||||
if (anchor) rows.push({ score, anchor });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Display formatting shared by every page that renders a qualification score.
|
||||
*
|
||||
* One formatter, because the same 6250 was reading as `63%` on the Motion home
|
||||
* and `62.5%` on the engagement that page links to — and the difference lands
|
||||
* exactly where a band boundary does. It lived in `QualificationScorer` until
|
||||
* three pages that do not otherwise touch the scorer were importing from it.
|
||||
*/
|
||||
|
||||
/** Basis points as a percentage. Rounded, never truncated — AGENTS.md §4. */
|
||||
export function percent(basisPoints: number): string {
|
||||
return `${(Math.round(basisPoints / 10) / 10).toFixed(1)}%`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The shape a template arrives in on the wire.
|
||||
*
|
||||
* It describes a *response*, not the server's own row, so it is declared here
|
||||
* rather than imported from `@pig/db`: a column the API does not serialise must
|
||||
* be absent, and one it may not have joined yet must be optional, or the
|
||||
* compiler asserts a guarantee the JSON does not carry. The enums are the
|
||||
* exception — those come from `@pig/core`, because a kind the ontology has
|
||||
* dropped should stop compiling rather than keep rendering.
|
||||
*
|
||||
* `fields` is the deliberate hole in the type. It is author-written JSON whose
|
||||
* shape varies by kind and is not validated on the way out of the database, so
|
||||
* it arrives as `unknown` and is narrowed at the point of use in `FieldsView`.
|
||||
* Typing it as the shape we hope for would move a runtime crash into a place
|
||||
* where nobody is looking for it.
|
||||
*
|
||||
* There is only one type here on purpose. Eight more were written alongside it
|
||||
* and never imported, and by the time anyone read them they no longer matched
|
||||
* `services/motion.ts` — `EngagementDetail` promised `.artifacts` where the
|
||||
* endpoint returns `.stages`. Each page declares the subset of the response it
|
||||
* actually reads, which is checked against the `get<T>()` call that fetches it;
|
||||
* a shared file that nothing imports is checked against nothing at all.
|
||||
*/
|
||||
import type { DemandStage, MotionKind, MotionVisibility } from '@pig/core';
|
||||
|
||||
export interface MotionTemplateView {
|
||||
id: string;
|
||||
kind: MotionKind;
|
||||
/** Stable across versions — the identity of the lineage, not of the row. */
|
||||
slug: string;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
fields: unknown;
|
||||
stage: DemandStage;
|
||||
visibility: MotionVisibility;
|
||||
ownerUserId: string | null;
|
||||
ownerName?: string | null;
|
||||
supersedesId: string | null;
|
||||
originArtifactId: string | null;
|
||||
isSystem: boolean;
|
||||
usageCount: number;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -209,7 +209,11 @@ export function Stat({
|
||||
: 'text-fg';
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
|
||||
// is always a grid child whose figure is `tabular-nums` and whose hint does
|
||||
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
|
||||
// shrink and the page scrolls sideways (AGENTS.md §5).
|
||||
<div className="card min-w-0 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
|
||||
{value}
|
||||
|
||||
+27
-5
@@ -20,8 +20,11 @@ import {
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
MessageCircleMore,
|
||||
Route,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -33,7 +36,7 @@ import {
|
||||
import type { Capability, Team } from '@pig/core';
|
||||
import { canAny, type PermissionIdentity } from './permissions';
|
||||
|
||||
export const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
|
||||
export const NAV_GROUPS = ['Intelligence', 'Motion', 'Marketplace', 'Records', 'Control'] as const;
|
||||
export type NavGroup = (typeof NAV_GROUPS)[number];
|
||||
|
||||
export interface NavItem {
|
||||
@@ -61,6 +64,15 @@ export const NAV: NavItem[] = [
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
|
||||
{ to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
|
||||
// None of these is `primary`: the phone tab bar holds five and the ledger
|
||||
// pages already hold all five. The three routes are reachable from the
|
||||
// sidebar and from ⌘K, which a NavItem earns by structurally satisfying
|
||||
// CommandDestination. Reads are gated on `book:read` like Accounts and
|
||||
// Contracts, so no `requires` — the write capabilities are enforced per
|
||||
// control on the page, not by hiding the destination.
|
||||
{ to: '/motion', label: 'Motion', icon: Route, group: 'Motion' },
|
||||
{ to: '/motion/library', label: 'Library', icon: Library, group: 'Motion' },
|
||||
{ to: '/motion/engagements', label: 'Engagements', icon: Handshake, group: 'Motion' },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
|
||||
@@ -106,9 +118,19 @@ export function visibleNav(identity: PermissionIdentity | undefined): NavItem[]
|
||||
});
|
||||
}
|
||||
|
||||
/** The nav entry a pathname belongs to, for the header title and active state. */
|
||||
/**
|
||||
* The nav entry a pathname belongs to, for the header title and active state.
|
||||
*
|
||||
* The longest match wins, not the first. Motion is the first group whose own
|
||||
* destinations nest — `/motion` is a prefix of `/motion/library` — and a
|
||||
* first-match scan titles the library page "Motion". Longest-match is a no-op
|
||||
* for every other route in the table.
|
||||
*/
|
||||
export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined {
|
||||
return items.find((item) =>
|
||||
item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`),
|
||||
);
|
||||
let match: NavItem | undefined;
|
||||
for (const item of items) {
|
||||
if (item.to === '/' ? pathname !== '/' : pathname !== item.to && !pathname.startsWith(`${item.to}/`)) continue;
|
||||
if (!match || item.to.length > match.to.length) match = item;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* The engagement workspace: one demand deal, worked with the motion.
|
||||
*
|
||||
* The stage rail is the spine, and it is the *deal's* stages rather than any
|
||||
* state of the engagement's own — an engagement that could disagree with its
|
||||
* deal about what stage the work is at would be a second answer to a question
|
||||
* that already has one.
|
||||
*
|
||||
* Two actions here are first-class because they are the loop: instantiate a
|
||||
* template into a stage, and promote a finished artefact back into the library.
|
||||
* Everything else on the page exists to make those two legible — the score
|
||||
* history because qualification is a movement rather than a number, and the
|
||||
* per-stage grouping because "what did we write at proposal" is the question
|
||||
* somebody opening this page a quarter later is actually asking.
|
||||
*
|
||||
* The loading, missing and failed branches are early returns rather than
|
||||
* sibling conditionals. That is the detail-page pattern in this repo
|
||||
* (`Account.tsx` is the same shape, 404 branch included), and it differs from
|
||||
* the list pages deliberately: a detail page has nothing to render around the
|
||||
* hole.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Gauge,
|
||||
Handshake,
|
||||
Library,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ARTIFACT_STATUSES,
|
||||
ARTIFACT_STATUS_LABELS,
|
||||
DEMAND_OPEN_STAGES,
|
||||
DEMAND_STAGES,
|
||||
DEMAND_STAGE_LABELS,
|
||||
ENGAGEMENT_STATUSES,
|
||||
ENGAGEMENT_STATUS_LABELS,
|
||||
toPiggyPageRoute,
|
||||
type ArtifactStatus,
|
||||
type DemandStage,
|
||||
type EngagementStatus,
|
||||
type MotionBandTone,
|
||||
type MotionKind,
|
||||
type MotionVisibility,
|
||||
} from '@pig/core';
|
||||
import { ApiError, get, patch, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
Stat,
|
||||
cn,
|
||||
} from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FieldsView } from '@/components/motion/FieldsView';
|
||||
import { InstantiateDialog } from '@/components/motion/InstantiateDialog';
|
||||
import { Markdown } from '@/components/motion/Markdown';
|
||||
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
|
||||
import { QualificationScorer } from '@/components/motion/QualificationScorer';
|
||||
import { percent } from '@/components/motion/format';
|
||||
import { StageRail } from '@/components/motion/StageRail';
|
||||
|
||||
// --------------------------------------------------------------------- wire
|
||||
|
||||
interface ScoreView {
|
||||
id: string;
|
||||
frameworkTemplateId: string | null;
|
||||
dimensions: unknown;
|
||||
basisPoints: number;
|
||||
band: string;
|
||||
tone: MotionBandTone;
|
||||
note: string | null;
|
||||
scoredByUserId: string | null;
|
||||
scoredAt: string;
|
||||
}
|
||||
|
||||
interface ArtifactView {
|
||||
id: string;
|
||||
engagementId: string;
|
||||
templateId: string | null;
|
||||
kind: MotionKind;
|
||||
stage: DemandStage;
|
||||
title: string;
|
||||
body: string;
|
||||
fields: unknown;
|
||||
status: ArtifactStatus;
|
||||
authoredByUserId: string | null;
|
||||
promotedTemplateId: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface EngagementView {
|
||||
id: string;
|
||||
demandDealId: string;
|
||||
dealName: string | null;
|
||||
/** The deal's stage. An engagement has none of its own — that is the point. */
|
||||
stage: DemandStage | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
status: EngagementStatus;
|
||||
summary: string | null;
|
||||
ownerUserId: string | null;
|
||||
playbookTemplateId: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
artifactCount: number;
|
||||
latestScore: ScoreView | null;
|
||||
}
|
||||
|
||||
interface TemplateSummary {
|
||||
id: string;
|
||||
kind: MotionKind;
|
||||
slug: string;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
stage: DemandStage;
|
||||
visibility: MotionVisibility;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
/** `GET /api/motion/engagements/:id` — `stages` is all ten, always, in order. */
|
||||
interface EngagementDetail {
|
||||
engagement: EngagementView;
|
||||
stages: { stage: DemandStage; artifacts: ArtifactView[] }[];
|
||||
scores: ScoreView[];
|
||||
playbook: TemplateSummary | null;
|
||||
}
|
||||
|
||||
const WRITE_DENIED = 'Editing this engagement needs the motion:write permission.';
|
||||
const PUBLISH_DENIED = 'Promoting into the library needs the motion:publish permission.';
|
||||
|
||||
// --------------------------------------------------------------------- page
|
||||
|
||||
export function Engagement() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const me = useIdentity();
|
||||
const mayWrite = canAny(me, 'motion:write');
|
||||
const mayPublish = canAny(me, 'motion:publish');
|
||||
const [stageFilter, setStageFilter] = useState<DemandStage | null>(null);
|
||||
const [instantiating, setInstantiating] = useState<{ kind: MotionKind | null } | null>(null);
|
||||
const [scoring, setScoring] = useState(false);
|
||||
const [promoting, setPromoting] = useState<ArtifactView | null>(null);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['motion', 'engagements', id, 'detail'],
|
||||
queryFn: () => get<EngagementDetail>(`/api/motion/engagements/${id}`),
|
||||
enabled: Boolean(id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const engagement = detail.data?.engagement;
|
||||
usePageTitle(engagement?.dealName ?? 'Engagement');
|
||||
usePiggyContext({
|
||||
type: 'page',
|
||||
route: toPiggyPageRoute('/motion/engagements'),
|
||||
label: engagement?.dealName ? `Engagement — ${engagement.dealName}` : 'Engagement',
|
||||
});
|
||||
|
||||
const groups = detail.data?.stages ?? [];
|
||||
const counts = useMemo(() => {
|
||||
const record: Partial<Record<DemandStage, number>> = {};
|
||||
for (const group of groups) record[group.stage] = group.artifacts.length;
|
||||
return record;
|
||||
}, [groups]);
|
||||
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<Restricted
|
||||
icon={<Lock />}
|
||||
title="This engagement is restricted"
|
||||
description="An engagement sits on a demand deal, and reading the book needs the book permission. Ask a platform administrator for team membership."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error instanceof ApiError && detail.error.status === 404) {
|
||||
return (
|
||||
<Restricted
|
||||
icon={<Handshake />}
|
||||
title="No such engagement"
|
||||
description="It has been removed, or the link was to an id that never existed."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error || !detail.data || !engagement) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Engagement unavailable"
|
||||
description={
|
||||
detail.error instanceof Error
|
||||
? detail.error.message
|
||||
: 'The workspace could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void detail.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const artifacts = groups.flatMap((group) => group.artifacts);
|
||||
const promoted = artifacts.filter((artifact) => artifact.promotedTemplateId).length;
|
||||
const finalised = artifacts.filter((artifact) => artifact.status === 'final').length;
|
||||
const frameworks = artifacts.filter((artifact) => artifact.kind === 'qualification');
|
||||
const scores = detail.data.scores;
|
||||
|
||||
/*
|
||||
* The eight open stages always, plus any closed stage that actually holds
|
||||
* work or is where the deal now sits. A rail that hides `closed_won` would
|
||||
* lose the case study written after the deal landed — which is precisely the
|
||||
* artefact most worth promoting.
|
||||
*/
|
||||
const railStages = DEMAND_STAGES.filter(
|
||||
(stage) =>
|
||||
DEMAND_OPEN_STAGES.includes(stage) ||
|
||||
(counts[stage] ?? 0) > 0 ||
|
||||
stage === engagement.stage,
|
||||
);
|
||||
|
||||
const visibleGroups = stageFilter
|
||||
? groups.filter((group) => group.stage === stageFilter)
|
||||
: groups.filter((group) => group.artifacts.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<BackLink />
|
||||
|
||||
<Header
|
||||
engagement={engagement}
|
||||
mayWrite={mayWrite}
|
||||
onInstantiate={() => setInstantiating({ kind: null })}
|
||||
/>
|
||||
|
||||
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4" aria-label="Engagement totals">
|
||||
<Stat label="Artefacts" value={artifacts.length} hint="Not archived" />
|
||||
<Stat label="Final" value={finalised} hint="Promotable" />
|
||||
<Stat
|
||||
label="Promoted"
|
||||
value={promoted}
|
||||
hint="Back into the library"
|
||||
tone={promoted ? 'positive' : 'default'}
|
||||
/>
|
||||
<Stat
|
||||
label="Qualification"
|
||||
value={engagement.latestScore ? percent(engagement.latestScore.basisPoints) : '—'}
|
||||
hint={engagement.latestScore ? engagement.latestScore.band : 'Never scored'}
|
||||
tone={engagement.latestScore?.tone === 'danger' ? 'danger' : engagement.latestScore?.tone === 'warning' ? 'warning' : 'default'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section aria-label="Artefacts by stage" className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">Where the work sits</h2>
|
||||
{stageFilter ? (
|
||||
<Button variant="ghost" onClick={() => setStageFilter(null)}>
|
||||
Show every stage
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<StageRail
|
||||
counts={counts}
|
||||
stages={railStages}
|
||||
countLabel="artefacts"
|
||||
activeStage={stageFilter ?? engagement.stage}
|
||||
onSelect={(stage) => setStageFilter((current) => (current === stage ? null : stage))}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
{stageFilter
|
||||
? `Filtered to ${DEMAND_STAGE_LABELS[stageFilter]}.`
|
||||
: engagement.stage
|
||||
? `The deal is at ${DEMAND_STAGE_LABELS[engagement.stage]}. Select a stage to filter the artefacts below.`
|
||||
: 'Select a stage to filter the artefacts below.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Qualification
|
||||
scores={scores}
|
||||
frameworks={frameworks}
|
||||
mayWrite={mayWrite}
|
||||
onScore={() => setScoring(true)}
|
||||
onInstantiateFramework={() => setInstantiating({ kind: 'qualification' })}
|
||||
/>
|
||||
|
||||
{detail.data.playbook ? <Playbook playbook={detail.data.playbook} /> : null}
|
||||
|
||||
<section className="flex min-w-0 flex-col gap-3" aria-label="Artefacts">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">Artefacts</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={() => setInstantiating({ kind: null })}
|
||||
>
|
||||
<Library aria-hidden />
|
||||
Instantiate from library
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{visibleGroups.length === 0 || visibleGroups.every((group) => group.artifacts.length === 0) ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<FileText />}
|
||||
title={stageFilter ? 'Nothing filed at this stage' : 'No artefacts yet'}
|
||||
description={
|
||||
stageFilter
|
||||
? 'Instantiate a template that serves this stage, or clear the filter to see the rest.'
|
||||
: 'Instantiate a discovery guide or a qualification framework from the library. What comes out of this engagement can be promoted back into it.'
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={() => setInstantiating({ kind: null })}
|
||||
>
|
||||
Instantiate a template
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
visibleGroups.map((group) => (
|
||||
<div key={group.stage} className="flex min-w-0 flex-col gap-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{DEMAND_STAGE_LABELS[group.stage]}
|
||||
</h3>
|
||||
{group.artifacts.map((artifact) => (
|
||||
<ArtifactPanel
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
mayWrite={mayWrite}
|
||||
mayPublish={mayPublish}
|
||||
onPromote={() => setPromoting(artifact)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{instantiating ? (
|
||||
<InstantiateDialog
|
||||
// Remounted per intent, so opening it to find a framework really does
|
||||
// open on the frameworks rather than on whatever was filtered last.
|
||||
key={instantiating.kind ?? 'any'}
|
||||
engagementId={engagement.id}
|
||||
defaultStage={engagement.stage}
|
||||
defaultKind={instantiating.kind}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setInstantiating(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Sheet open={scoring} onOpenChange={setScoring}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Score qualification</SheetTitle>
|
||||
<SheetDescription>
|
||||
Answered against the anchors, so two people reading the same evidence reach the same
|
||||
number. Scores are appended, never edited — the movement is the evidence that
|
||||
qualification happened.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScoringPanel
|
||||
engagementId={engagement.id}
|
||||
frameworks={frameworks}
|
||||
previousBasisPoints={scores[0]?.basisPoints ?? null}
|
||||
onDone={() => setScoring(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<PromoteSheet artifact={promoting} onClose={() => setPromoting(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sections
|
||||
|
||||
function Header({
|
||||
engagement,
|
||||
mayWrite,
|
||||
onInstantiate,
|
||||
}: {
|
||||
engagement: EngagementView;
|
||||
mayWrite: boolean;
|
||||
onInstantiate: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const update = useMutation({
|
||||
mutationFn: (status: EngagementStatus) =>
|
||||
patch<{ engagement: { id: string } }>(`/api/motion/engagements/${engagement.id}`, { status }),
|
||||
onSuccess: async (_data, status) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success(`Engagement marked ${ENGAGEMENT_STATUS_LABELS[status].toLocaleLowerCase()}`);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{engagement.stage ? (
|
||||
<Badge tone="neutral" className="min-w-0">
|
||||
<span className="truncate">{DEMAND_STAGE_LABELS[engagement.stage]}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge tone={statusTone(engagement.status)}>
|
||||
{ENGAGEMENT_STATUS_LABELS[engagement.status]}
|
||||
</Badge>
|
||||
<span className="nums text-xs text-muted">
|
||||
Opened {shortDate(engagement.openedAt)}
|
||||
{engagement.closedAt ? ` · closed ${shortDate(engagement.closedAt)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mt-1 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
|
||||
{engagement.dealName ?? 'Untitled deal'}
|
||||
</h1>
|
||||
<p className="mt-1 min-w-0 truncate text-sm text-muted">
|
||||
{engagement.accountId ? (
|
||||
<Link
|
||||
to={`/accounts/${engagement.accountId}`}
|
||||
className="underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
>
|
||||
{engagement.accountName ?? 'Unknown account'}
|
||||
</Link>
|
||||
) : (
|
||||
(engagement.accountName ?? 'Unknown account')
|
||||
)}
|
||||
</p>
|
||||
{engagement.summary ? (
|
||||
<p className="mt-2 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
|
||||
{engagement.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={engagement.status}
|
||||
disabled={!mayWrite || update.isPending}
|
||||
onValueChange={(value) => update.mutate(value as EngagementStatus)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Engagement status"
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
className="h-11 w-40 min-w-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ENGAGEMENT_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{ENGAGEMENT_STATUS_LABELS[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={onInstantiate}
|
||||
>
|
||||
<Library aria-hidden />
|
||||
Instantiate
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The score, and what it did.
|
||||
*
|
||||
* The number on its own answers a question nobody asks twice. What a review
|
||||
* actually needs is the direction: a deal that has moved 5,800 → 7,100 across
|
||||
* three weeks is a different deal from one that has sat at 7,100 since it was
|
||||
* opened, and the second one is the one where nothing has been learned.
|
||||
*/
|
||||
function Qualification({
|
||||
scores,
|
||||
frameworks,
|
||||
mayWrite,
|
||||
onScore,
|
||||
onInstantiateFramework,
|
||||
}: {
|
||||
scores: ScoreView[];
|
||||
frameworks: ArtifactView[];
|
||||
mayWrite: boolean;
|
||||
onScore: () => void;
|
||||
onInstantiateFramework: () => void;
|
||||
}) {
|
||||
const latest = scores[0] ?? null;
|
||||
const scorable = frameworks.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="flex min-w-0 flex-col gap-4 p-4 sm:p-5">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold">Qualification</h2>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
Weighted dimensions, scored against anchors. The band is what gets acted on.
|
||||
</p>
|
||||
</div>
|
||||
{scorable ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={onScore}
|
||||
>
|
||||
<Gauge aria-hidden />
|
||||
{latest ? 'Score again' : 'Score this deal'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!mayWrite}
|
||||
title={
|
||||
mayWrite
|
||||
? 'Scoring needs a qualification framework in this engagement.'
|
||||
: WRITE_DENIED
|
||||
}
|
||||
onClick={onInstantiateFramework}
|
||||
>
|
||||
Add a framework
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="nums text-3xl font-semibold leading-none">
|
||||
{percent(latest.basisPoints)}
|
||||
</span>
|
||||
<Badge tone={latest.tone}>{latest.band}</Badge>
|
||||
<span className="nums text-xs text-muted">scored {shortDate(latest.scoredAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
{scorable
|
||||
? 'Not scored yet. The framework is here; the first score is the baseline everything after is read against.'
|
||||
: 'Not scored yet, and there is no framework in this engagement to score against. Instantiate one from the library.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{scores.length > 1 ? (
|
||||
<div className="min-w-0 border-t border-border pt-3">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Movement</h3>
|
||||
<ol className="min-w-0 space-y-2">
|
||||
{scores.map((score, index) => {
|
||||
const previous = scores[index + 1];
|
||||
const delta = previous ? score.basisPoints - previous.basisPoints : null;
|
||||
return (
|
||||
<li key={score.id} className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3">
|
||||
<span className="nums w-16 shrink-0 text-sm font-medium">
|
||||
{percent(score.basisPoints)}
|
||||
</span>
|
||||
<span className="w-28 shrink-0">
|
||||
<Badge tone={score.tone}>{score.band}</Badge>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'nums w-20 shrink-0 text-xs font-medium',
|
||||
delta === null ? 'text-muted' : delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{delta === null
|
||||
? 'baseline'
|
||||
: delta === 0
|
||||
? 'no change'
|
||||
: `${delta > 0 ? '+' : '−'}${percent(Math.abs(delta))}`}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words text-xs text-muted">
|
||||
{shortDate(score.scoredAt)}
|
||||
{score.note ? ` · ${score.note}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which framework the scorer is filling in, when the engagement holds several. */
|
||||
function ScoringPanel({
|
||||
engagementId,
|
||||
frameworks,
|
||||
previousBasisPoints,
|
||||
onDone,
|
||||
}: {
|
||||
engagementId: string;
|
||||
frameworks: ArtifactView[];
|
||||
previousBasisPoints: number | null;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(frameworks[0]?.id ?? '');
|
||||
const framework = frameworks.find((artifact) => artifact.id === selected) ?? frameworks[0];
|
||||
|
||||
if (!framework) {
|
||||
return (
|
||||
<div className="mt-6 rounded-lg border border-border p-4">
|
||||
<p className="font-medium">No framework in this engagement</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Instantiate a qualification template first — the dimensions and their anchors come from
|
||||
it, and a score without them is a number two people would not agree on.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{frameworks.length > 1 ? (
|
||||
<div className="mt-5 flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="scoring-framework">Framework</Label>
|
||||
<Select value={framework.id} onValueChange={setSelected}>
|
||||
<SelectTrigger id="scoring-framework" aria-label="Framework" className="h-11 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{frameworks.map((artifact) => (
|
||||
<SelectItem key={artifact.id} value={artifact.id}>
|
||||
{artifact.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<QualificationScorer
|
||||
key={framework.id}
|
||||
engagementId={engagementId}
|
||||
// The template behind the artefact, not the artefact: the score records
|
||||
// which framework produced it, and a from-scratch framework has none.
|
||||
frameworkTemplateId={framework.templateId}
|
||||
fields={framework.fields}
|
||||
previousBasisPoints={previousBasisPoints}
|
||||
onScored={onDone}
|
||||
onCancel={onDone}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Playbook({ playbook }: { playbook: TemplateSummary }) {
|
||||
return (
|
||||
<Card className="flex min-w-0 flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">Playbook</p>
|
||||
<p className="mt-1 min-w-0 break-words font-medium">{playbook.title}</p>
|
||||
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm text-muted">
|
||||
{playbook.summary}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="shrink-0">
|
||||
<Link to={`/motion/library/${playbook.id}`}>
|
||||
Open v{playbook.version}
|
||||
<ArrowUpRight aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One artefact, with the two controls that move it: its status, and promotion.
|
||||
*
|
||||
* Promotion is refused by the API for an artefact that is not final and for one
|
||||
* already promoted, so the button is disabled with the reason on it rather than
|
||||
* hidden — a control that vanishes teaches nothing, and "why can I not promote
|
||||
* this" is a question the status select right beside it answers.
|
||||
*/
|
||||
function ArtifactPanel({
|
||||
artifact,
|
||||
mayWrite,
|
||||
mayPublish,
|
||||
onPromote,
|
||||
}: {
|
||||
artifact: ArtifactView;
|
||||
mayWrite: boolean;
|
||||
mayPublish: boolean;
|
||||
onPromote: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const update = useMutation({
|
||||
mutationFn: (status: ArtifactStatus) =>
|
||||
patch<{ artifact: { id: string } }>(`/api/motion/artifacts/${artifact.id}`, { status }),
|
||||
onSuccess: async (_data, status) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success(`Marked ${ARTIFACT_STATUS_LABELS[status].toLocaleLowerCase()}`);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const promotable = artifact.status === 'final' && !artifact.promotedTemplateId && mayPublish;
|
||||
const promoteReason = artifact.promotedTemplateId
|
||||
? 'Already in the library. Promote a later revision instead.'
|
||||
: artifact.status !== 'final'
|
||||
? 'Only a final artefact may be promoted — the library is what the next deployment copies.'
|
||||
: PUBLISH_DENIED;
|
||||
|
||||
return (
|
||||
<Card className="flex min-w-0 flex-col gap-3 p-4">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<MotionKindBadge kind={artifact.kind} />
|
||||
<Badge tone={artifactTone(artifact.status)}>{ARTIFACT_STATUS_LABELS[artifact.status]}</Badge>
|
||||
{artifact.promotedTemplateId ? (
|
||||
<Badge tone="positive" title="This artefact became a library version">
|
||||
<Sparkles className="size-3 shrink-0" aria-hidden />
|
||||
In the library
|
||||
</Badge>
|
||||
) : null}
|
||||
{artifact.templateId ? null : (
|
||||
<Badge tone="neutral" title="Written here rather than instantiated">
|
||||
From scratch
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h4 className="min-w-0 break-words font-semibold leading-snug">{artifact.title}</h4>
|
||||
<p className="nums mt-0.5 text-xs text-muted">Updated {shortDate(artifact.updatedAt)}</p>
|
||||
</div>
|
||||
|
||||
<details className="group min-w-0">
|
||||
<summary className="tap flex cursor-pointer items-center gap-1.5 text-sm font-medium text-muted hover:text-fg">
|
||||
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
|
||||
Read it
|
||||
</summary>
|
||||
<div className="mt-3 min-w-0 border-t border-border pt-3">
|
||||
{artifact.body ? <Markdown content={artifact.body} /> : null}
|
||||
<FieldsView kind={artifact.kind} fields={artifact.fields} className="mt-4" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={artifact.status}
|
||||
disabled={!mayWrite || update.isPending}
|
||||
onValueChange={(value) => update.mutate(value as ArtifactStatus)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`Status of ${artifact.title}`}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
className="h-11 w-36 min-w-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ARTIFACT_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{ARTIFACT_STATUS_LABELS[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{artifact.promotedTemplateId ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/motion/library/${artifact.promotedTemplateId}`}>
|
||||
Open the library version
|
||||
<ArrowUpRight aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!promotable}
|
||||
title={promotable ? undefined : promoteReason}
|
||||
onClick={onPromote}
|
||||
>
|
||||
<Sparkles aria-hidden />
|
||||
Promote to library
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{promotable ? null : (
|
||||
<p className="min-w-0 break-words text-xs text-muted">{promoteReason}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotion, with the two fields the library reads and nothing else.
|
||||
*
|
||||
* The lineage is not offered as a control. An artefact instantiated from a
|
||||
* template lands as the next version of that template's lineage, which is what
|
||||
* makes "this came from playbook v3, and v4 is what we learned" true; letting
|
||||
* somebody retarget it here would break the only chain that carries the claim.
|
||||
*/
|
||||
function PromoteSheet({ artifact, onClose }: { artifact: ArtifactView | null; onClose: () => void }) {
|
||||
return (
|
||||
<Sheet open={Boolean(artifact)} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
|
||||
{artifact ? <PromoteForm key={artifact.id} artifact={artifact} onDone={onClose} /> : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteForm({ artifact, onDone }: { artifact: ArtifactView; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState(artifact.title);
|
||||
const [summary, setSummary] = useState('');
|
||||
|
||||
const promote = useMutation({
|
||||
mutationFn: () =>
|
||||
post<{ template: { id: string; version: number } }>(
|
||||
`/api/motion/artifacts/${artifact.id}/promote`,
|
||||
{
|
||||
title: title.trim() || artifact.title,
|
||||
...(summary.trim() ? { summary: summary.trim() } : {}),
|
||||
},
|
||||
),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success(`Promoted as v${result.template.version}`);
|
||||
onDone();
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Promote to the library</SheetTitle>
|
||||
<SheetDescription>
|
||||
{artifact.templateId
|
||||
? 'This lands as the next version of the lineage it came from, pointing back at both its predecessor and this artifact.'
|
||||
: 'This starts a new lineage at version 1, pointing back at this artefact as the engagement that proved it.'}{' '}
|
||||
Promoted templates are shared with everyone.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
promote.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="promote-title">Title</Label>
|
||||
<Input
|
||||
id="promote-title"
|
||||
required
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="promote-summary">Summary</Label>
|
||||
<Textarea
|
||||
id="promote-summary"
|
||||
className="min-h-24"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder="What the next person needs to know before copying this."
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
Left blank, the previous version's summary carries over.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{promote.isError ? <p className="text-sm text-danger">{promote.error.message}</p> : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onDone}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={promote.isPending}>
|
||||
Promote
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
to="/motion/engagements"
|
||||
className="tap -ml-1 inline-flex w-fit items-center gap-2 rounded-lg px-1 text-sm font-medium text-muted hover:text-fg"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
All engagements
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Restricted({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState icon={icon} title={title} description={description} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status: EngagementStatus): 'positive' | 'danger' | 'warning' | 'info' {
|
||||
return status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
|
||||
}
|
||||
|
||||
function artifactTone(status: ArtifactStatus): 'neutral' | 'info' | 'positive' {
|
||||
return status === 'final' ? 'positive' : status === 'review' ? 'info' : 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* The Motion home, which exists to answer one question: is the motion actually
|
||||
* repeating?
|
||||
*
|
||||
* Every panel here is a different way of asking it. The rail asks whether the
|
||||
* library reaches the stages where work is actually sitting; the kind grid asks
|
||||
* whether the nine kinds are covered or whether three of them are aspirational;
|
||||
* the promotions panel asks whether anything has come *back* out of an
|
||||
* engagement, which is the only evidence that the loop closed at all.
|
||||
*
|
||||
* So the absences are promoted rather than hidden. A stage with live
|
||||
* engagements and no template is the most useful finding this page can carry —
|
||||
* it names the place where the next deployment will be improvised — and a
|
||||
* missing row rendered as a grey zero is a finding nobody reads. Gaps get a
|
||||
* warning tone, a count and a link that lands on the library already filtered
|
||||
* to the hole.
|
||||
*
|
||||
* Counting is the server's job: `/api/motion` returns all eight stages and all
|
||||
* nine kinds in ontology order, zeros included, so nothing here zero-fills.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Handshake,
|
||||
Library,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DEMAND_STAGE_LABELS,
|
||||
MOTION_KIND_DESCRIPTIONS,
|
||||
MOTION_KIND_LABELS,
|
||||
toPiggyPageRoute,
|
||||
type DemandStage,
|
||||
type EngagementStatus,
|
||||
type MotionKind,
|
||||
} from '@pig/core';
|
||||
import { StageRail } from '@/components/motion/StageRail';
|
||||
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
|
||||
import { percent } from '@/components/motion/format';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Stat,
|
||||
} from '@/components/ui';
|
||||
import { get, relativeTime, shortDate } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
// ------------------------------------------------------------------- shapes
|
||||
|
||||
interface StageCoverage {
|
||||
stage: DemandStage;
|
||||
engagements: number;
|
||||
templates: number;
|
||||
}
|
||||
|
||||
interface KindCoverage {
|
||||
kind: MotionKind;
|
||||
templates: number;
|
||||
shared: number;
|
||||
}
|
||||
|
||||
interface EngagementSummary {
|
||||
id: string;
|
||||
demandDealId: string;
|
||||
dealName: string | null;
|
||||
stage: DemandStage | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
status: EngagementStatus;
|
||||
summary: string | null;
|
||||
openedAt: string;
|
||||
artifactCount: number;
|
||||
latestScore: { basisPoints: number; band: string } | null;
|
||||
}
|
||||
|
||||
interface Promotion {
|
||||
templateId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
kind: MotionKind;
|
||||
version: number;
|
||||
promotedAt: string;
|
||||
engagementId: string | null;
|
||||
dealName: string | null;
|
||||
}
|
||||
|
||||
interface MotionOverview {
|
||||
totals: {
|
||||
templates: number;
|
||||
shared: number;
|
||||
private: number;
|
||||
engagements: number;
|
||||
open: number;
|
||||
promotions: number;
|
||||
};
|
||||
stages: StageCoverage[];
|
||||
library: KindCoverage[];
|
||||
engagements: EngagementSummary[];
|
||||
promotions: Promotion[];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- page
|
||||
|
||||
export function Motion() {
|
||||
usePageTitle('Motion');
|
||||
usePiggyContext({ type: 'page', route: toPiggyPageRoute('/motion'), label: 'Motion' });
|
||||
const me = useIdentity();
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['motion', 'overview'],
|
||||
queryFn: () => get<MotionOverview>('/api/motion'),
|
||||
enabled: canAny(me, 'book:read'),
|
||||
});
|
||||
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<PageHeader />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock />}
|
||||
title="Motion is restricted"
|
||||
description="The library and its engagements sit behind book access. Ask a platform administrator for team membership."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = overview.data;
|
||||
// A stage carrying live work with nothing in the library behind it. Both
|
||||
// halves matter: an empty stage with no template is a stage nobody is
|
||||
// working, which is not a gap in the motion.
|
||||
const gaps = (data?.stages ?? []).filter(
|
||||
(stage) => stage.templates === 0 && stage.engagements > 0,
|
||||
);
|
||||
const uncovered = (data?.stages ?? []).filter((stage) => stage.templates === 0);
|
||||
const empty = data ? data.totals.templates === 0 && data.totals.engagements === 0 : false;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<PageHeader />
|
||||
|
||||
{overview.isLoading ? <Skeleton className="h-72" /> : null}
|
||||
|
||||
{overview.isError ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Motion is unavailable"
|
||||
description={
|
||||
overview.error instanceof Error
|
||||
? overview.error.message
|
||||
: 'The motion summary could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void overview.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{empty ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Library />}
|
||||
title="Nothing in the motion yet"
|
||||
description="A motion starts as one template. Author the discovery guide you already run from memory, then instantiate it into the next deal."
|
||||
action={
|
||||
<Button variant="primary" asChild>
|
||||
<Link to="/motion/library">Open the library</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data && !empty ? (
|
||||
<>
|
||||
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Stat
|
||||
label="Templates"
|
||||
value={data.totals.templates}
|
||||
hint={`${data.totals.shared} shared · ${data.totals.private} private`}
|
||||
/>
|
||||
<Stat
|
||||
label="Open engagements"
|
||||
value={data.totals.open}
|
||||
hint={`${data.totals.engagements} recorded in total`}
|
||||
/>
|
||||
<Stat
|
||||
label="Stages covered"
|
||||
value={`${data.stages.length - uncovered.length}/${data.stages.length}`}
|
||||
hint={uncovered.length ? 'The library stops short of the motion' : 'Every stage has a template'}
|
||||
tone={gaps.length ? 'warning' : uncovered.length ? 'default' : 'positive'}
|
||||
/>
|
||||
<Stat
|
||||
label="Promotions"
|
||||
value={data.totals.promotions}
|
||||
hint={data.totals.promotions ? 'Artefacts that came back as templates' : 'Nothing has closed the loop yet'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>The motion, stage by stage</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Live engagements sit on the deal’s stage. The second figure is what the
|
||||
library has waiting there.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-3">
|
||||
<StageRail counts={countsByStage(data.stages)} coverage={templatesByStage(data.stages)} />
|
||||
{gaps.length ? (
|
||||
<div className="flex min-w-0 gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{gaps.length === 1 ? 'One stage is' : `${gaps.length} stages are`} being worked
|
||||
without a template
|
||||
</p>
|
||||
<p className="mt-1 text-muted">
|
||||
Whatever gets written there this week is written from memory, and the next
|
||||
deal starts over. Author one from the engagement that is already in it.
|
||||
</p>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
{gaps.map((gap) => (
|
||||
<Link
|
||||
key={gap.stage}
|
||||
to={`/motion/library?stage=${gap.stage}`}
|
||||
className="tap inline-flex min-h-11 min-w-0 items-center gap-1 rounded-lg bg-surface px-3 text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="truncate">{DEMAND_STAGE_LABELS[gap.stage]}</span>
|
||||
<span className="nums shrink-0 text-muted">
|
||||
{gap.engagements} live
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Library cover, by kind</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Nine kinds. A kind with nothing in it is a conversation that gets improvised
|
||||
every time.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<Link to="/motion/library">
|
||||
<Library aria-hidden />
|
||||
Library
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.library.map((kind) => (
|
||||
<KindCoverageCard key={kind.kind} coverage={kind} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>What got easier</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Artefacts an engagement proved, promoted back into the library as a new version.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-2">
|
||||
{data.promotions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles />}
|
||||
title="Nothing promoted yet"
|
||||
description="Finalise an artefact in an engagement and promote it. That is the step that makes the next deployment cheaper than this one."
|
||||
/>
|
||||
) : (
|
||||
data.promotions.map((promotion) => (
|
||||
<PromotionRow key={promotion.templateId} promotion={promotion} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Open engagements</CardTitle>
|
||||
<p className="text-sm text-muted">Where the library is being used right now.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<Link to="/motion/engagements">
|
||||
<Handshake aria-hidden />
|
||||
All
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-2">
|
||||
{data.engagements.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Handshake />}
|
||||
title="No open engagements"
|
||||
description="Open one against a demand deal to file discovery, scoping and proposal work against its stages."
|
||||
/>
|
||||
) : (
|
||||
data.engagements.map((engagement) => (
|
||||
<EngagementRow key={engagement.id} engagement={engagement} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fragments
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
|
||||
Go-to-market
|
||||
</p>
|
||||
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Motion</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
The repeatable practice behind the ledger: what the library covers, where engagements
|
||||
actually sit, and what came back out of one.
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function KindCoverageCard({ coverage }: { coverage: KindCoverage }) {
|
||||
const missing = coverage.templates === 0;
|
||||
return (
|
||||
<Link
|
||||
to={`/motion/library?kind=${coverage.kind}`}
|
||||
className="tap flex min-h-11 min-w-0 flex-col gap-2 rounded-xl border border-border bg-surface-2/60 p-3 hover:bg-surface-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<MotionKindBadge kind={coverage.kind} />
|
||||
{missing ? (
|
||||
<Badge tone="warning" className="shrink-0">
|
||||
No template
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="nums shrink-0 text-sm font-semibold">{coverage.templates}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="min-w-0 line-clamp-2 text-xs leading-5 text-muted">
|
||||
{missing ? MOTION_KIND_DESCRIPTIONS[coverage.kind] : `${coverage.shared} shared to the book`}
|
||||
</p>
|
||||
<span className="sr-only">{MOTION_KIND_LABELS[coverage.kind]}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PromotionRow({ promotion }: { promotion: Promotion }) {
|
||||
// The whole row is the target, as it is for `EngagementRow` in the card
|
||||
// beside it. A one-line text link is about 20px tall on a phone, and two
|
||||
// identically-shaped rows where only one can be tapped reads as a dead page
|
||||
// rather than as a smaller hit area.
|
||||
return (
|
||||
<Link
|
||||
to={`/motion/library/${promotion.templateId}`}
|
||||
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="min-w-0 break-words font-medium">{promotion.title}</p>
|
||||
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
|
||||
<MotionKindBadge kind={promotion.kind} iconOnly />
|
||||
<span className="nums whitespace-nowrap">v{promotion.version}</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{promotion.dealName ? `from ${promotion.dealName}` : 'from an engagement'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="nums shrink-0 text-xs text-muted" title={shortDate(promotion.promotedAt)}>
|
||||
{relativeTime(promotion.promotedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function EngagementRow({ engagement }: { engagement: EngagementSummary }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/motion/engagements/${engagement.id}`}
|
||||
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="min-w-0 truncate font-medium">
|
||||
{engagement.dealName ?? 'Unnamed deal'}
|
||||
</p>
|
||||
<p className="mt-1 min-w-0 truncate text-xs text-muted">
|
||||
{engagement.accountName ?? 'Unknown account'} ·{' '}
|
||||
{engagement.stage ? DEMAND_STAGE_LABELS[engagement.stage] : 'No stage'} ·{' '}
|
||||
<span className="nums">{engagement.artifactCount}</span> artefact
|
||||
{engagement.artifactCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{engagement.latestScore ? (
|
||||
<Badge tone="neutral" className="nums">
|
||||
{percent(engagement.latestScore.basisPoints)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ArrowUpRight className="size-4 text-muted" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function countsByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
|
||||
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.engagements]));
|
||||
}
|
||||
|
||||
function templatesByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
|
||||
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.templates]));
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Every engagement, one per demand deal.
|
||||
*
|
||||
* The list answers a different question from the Motion home: not "is the
|
||||
* motion repeating" but "which deals are being worked with it, and what does
|
||||
* qualification say about them now". So the columns are the ones that move —
|
||||
* stage, artefact count, latest score — and the status filter lives in the URL
|
||||
* so a view of everything still open can be pasted to somebody.
|
||||
*
|
||||
* `Unscored` is a stat rather than a column because it is the finding: an open
|
||||
* engagement nobody has qualified is a deal being worked on instinct, and it is
|
||||
* invisible in a table that only shows the scores that exist.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Handshake,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DEMAND_STAGE_LABELS,
|
||||
ENGAGEMENT_STATUSES,
|
||||
ENGAGEMENT_STATUS_LABELS,
|
||||
isEngagementStatus,
|
||||
toPiggyPageRoute,
|
||||
type DemandStage,
|
||||
type EngagementStatus,
|
||||
type MotionBandTone,
|
||||
} from '@pig/core';
|
||||
import { percent } from '@/components/motion/format';
|
||||
import { get, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Stat,
|
||||
cn,
|
||||
} from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
/** The score the list carries — the latest one, or none yet. */
|
||||
interface LatestScore {
|
||||
basisPoints: number;
|
||||
band: string;
|
||||
tone: MotionBandTone;
|
||||
scoredAt: string;
|
||||
}
|
||||
|
||||
/** `GET /api/motion/engagements` rows. `stage` is the deal's, not the engagement's. */
|
||||
interface EngagementRow {
|
||||
id: string;
|
||||
demandDealId: string;
|
||||
dealName: string | null;
|
||||
stage: DemandStage | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
status: EngagementStatus;
|
||||
summary: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
artifactCount: number;
|
||||
latestScore: LatestScore | null;
|
||||
}
|
||||
|
||||
interface DemandDealRow {
|
||||
deal: { id: string; name: string; stage: DemandStage };
|
||||
accountName: string | null;
|
||||
}
|
||||
|
||||
const WRITE_DENIED = 'Opening an engagement needs the motion:write permission.';
|
||||
|
||||
export function MotionEngagements() {
|
||||
usePageTitle('Engagements');
|
||||
usePiggyContext({
|
||||
type: 'page',
|
||||
route: toPiggyPageRoute('/motion/engagements'),
|
||||
label: 'Engagements',
|
||||
});
|
||||
const me = useIdentity();
|
||||
const mayWrite = canAny(me, 'motion:write');
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
// The filter lives in the URL so a view of "everything still open" can be
|
||||
// pasted to somebody, which is the whole reason anyone filters a list.
|
||||
const statusParam = params.get('status');
|
||||
const status = statusParam && isEngagementStatus(statusParam) ? statusParam : null;
|
||||
|
||||
const engagements = useQuery({
|
||||
queryKey: ['motion', 'engagements', { status }],
|
||||
queryFn: () =>
|
||||
get<{ engagements: EngagementRow[] }>(
|
||||
`/api/motion/engagements${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
// The permission short-circuit below is an early return, and a hook cannot
|
||||
// sit behind one — so without this the read fires for exactly the people
|
||||
// the page is about to tell they have no access, and react-query retries
|
||||
// the 403 while they read the empty state.
|
||||
enabled: canAny(me, 'book:read'),
|
||||
});
|
||||
|
||||
const rows = engagements.data?.engagements ?? [];
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
open: rows.filter((row) => row.status === 'open').length,
|
||||
artifacts: rows.reduce((sum, row) => sum + row.artifactCount, 0),
|
||||
unscored: rows.filter((row) => row.status === 'open' && !row.latestScore).length,
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock />}
|
||||
title="Engagements are restricted"
|
||||
description="Reading the book behind an engagement needs the book permission. Ask a platform administrator for team membership."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
One per demand deal: the artefacts it has produced, and what qualification says about
|
||||
it now rather than when it was opened.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={() => setOpening(true)}
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
Open an engagement
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{rows.length ? (
|
||||
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-3" aria-label="Engagement summary">
|
||||
<Stat label="Open" value={totals.open} hint="Still in the motion" />
|
||||
<Stat label="Artefacts" value={totals.artifacts} hint="Across the list below" />
|
||||
<Stat
|
||||
label="Unscored"
|
||||
value={totals.unscored}
|
||||
hint="Open, never qualified"
|
||||
tone={totals.unscored ? 'warning' : 'default'}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<StatusFilter
|
||||
value={status}
|
||||
onChange={(next) => {
|
||||
const updated = new URLSearchParams(params);
|
||||
if (next) updated.set('status', next);
|
||||
else updated.delete('status');
|
||||
setParams(updated, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{engagements.isLoading ? <Skeleton className="h-72" /> : null}
|
||||
|
||||
{engagements.isError ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Engagements unavailable"
|
||||
description={
|
||||
engagements.error instanceof Error
|
||||
? engagements.error.message
|
||||
: 'The list could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void engagements.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!engagements.isLoading && !engagements.isError && rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Handshake />}
|
||||
title={status ? 'No engagement in this state' : 'No engagements yet'}
|
||||
description={
|
||||
status
|
||||
? 'Clear the filter to see the rest of them.'
|
||||
: 'An engagement binds the library to a demand deal. Open one on the deal you are working now, and the artefacts it produces can be promoted back.'
|
||||
}
|
||||
action={
|
||||
status ? undefined : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!mayWrite}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={() => setOpening(true)}
|
||||
>
|
||||
Open the first one
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{rows.length ? (
|
||||
<>
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Deal</TableHead>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Artefacts</TableHead>
|
||||
<TableHead>Qualification</TableHead>
|
||||
<TableHead>Opened</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Open</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/motion/engagements/${row.id}`}
|
||||
className="font-medium underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
>
|
||||
{row.dealName ?? 'Untitled deal'}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted">
|
||||
{row.accountName ?? 'Unknown account'}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{row.stage ? DEMAND_STAGE_LABELS[row.stage] : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="nums text-sm">{row.artifactCount}</TableCell>
|
||||
<TableCell>
|
||||
<ScoreCell score={row.latestScore} />
|
||||
</TableCell>
|
||||
<TableCell className="nums text-sm">{shortDate(row.openedAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/motion/engagements/${row.id}`}
|
||||
className="tap inline-flex items-center justify-center text-muted"
|
||||
aria-label={`Open ${row.dealName ?? 'engagement'}`}
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{rows.map((row) => (
|
||||
<Link
|
||||
key={row.id}
|
||||
to={`/motion/engagements/${row.id}`}
|
||||
className="card min-h-11 min-w-0 p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<StatusBadge status={row.status} />
|
||||
{row.stage ? (
|
||||
<Badge tone="neutral" className="min-w-0">
|
||||
<span className="truncate">{DEMAND_STAGE_LABELS[row.stage]}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 truncate font-medium">{row.dealName ?? 'Untitled deal'}</p>
|
||||
<p className="mt-0.5 truncate text-sm text-muted">
|
||||
{row.accountName ?? 'Unknown account'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="shrink-0 text-muted" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end justify-between gap-3 border-t border-border pt-3 text-xs text-muted">
|
||||
<p className="nums min-w-0">
|
||||
{row.artifactCount} artifact{row.artifactCount === 1 ? '' : 's'} · opened{' '}
|
||||
{shortDate(row.openedAt)}
|
||||
</p>
|
||||
<ScoreCell score={row.latestScore} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<OpenEngagementSheet open={opening} onOpenChange={setOpening} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening an engagement on a deal that already has one is a 409, so the deals
|
||||
* that already have one are not offered. The server still refuses — this is a
|
||||
* courtesy, not the rule — but an option that can only fail is a worse thing
|
||||
* to ship than a shorter list.
|
||||
*/
|
||||
function OpenEngagementSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
// Its own check rather than a prop. The trigger is already disabled without
|
||||
// `motion:write`, but these two reads are what the sheet costs, and a query
|
||||
// that fires for somebody who cannot use the result is a 403 in the network
|
||||
// tab and a retry loop behind a dialog they were never meant to open.
|
||||
const mayWrite = canAny(useIdentity(), 'motion:write');
|
||||
const [demandDealId, setDemandDealId] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
|
||||
const deals = useQuery({
|
||||
queryKey: ['deals', 'demand'],
|
||||
queryFn: () => get<{ deals: DemandDealRow[] }>('/api/deals/demand'),
|
||||
enabled: open && mayWrite,
|
||||
});
|
||||
/*
|
||||
* Its own unfiltered query rather than the rows the page is showing. The
|
||||
* status filter is the trap: filtered to "Won", the page holds none of the
|
||||
* open engagements, so every deal already running would be offered here and
|
||||
* every one of them would answer 409. The key matches the page's own
|
||||
* unfiltered query, so the two share a cache entry rather than racing.
|
||||
*/
|
||||
const allEngagements = useQuery({
|
||||
queryKey: ['motion', 'engagements', { status: null }],
|
||||
queryFn: () => get<{ engagements: EngagementRow[] }>('/api/motion/engagements'),
|
||||
enabled: open && mayWrite,
|
||||
});
|
||||
|
||||
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
|
||||
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
post<{ engagement: { id: string } }>('/api/motion/engagements', {
|
||||
demandDealId,
|
||||
summary: summary.trim() ? summary.trim() : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion'] });
|
||||
toast.success('Engagement opened');
|
||||
setDemandDealId('');
|
||||
setSummary('');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Open an engagement</SheetTitle>
|
||||
<SheetDescription>
|
||||
An engagement hangs off a demand deal rather than replacing it — the deal keeps its
|
||||
stage, and the engagement collects what the motion produces against it.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="engagement-deal">Demand deal</Label>
|
||||
<Select value={demandDealId || undefined} onValueChange={setDemandDealId}>
|
||||
<SelectTrigger id="engagement-deal" aria-label="Demand deal" className="h-11 min-w-0">
|
||||
<SelectValue placeholder={deals.isLoading || allEngagements.isLoading ? 'Loading deals…' : 'Choose a deal'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{available.map((row) => (
|
||||
<SelectItem key={row.deal.id} value={row.deal.id}>
|
||||
{row.deal.name} · {row.accountName ?? 'Unknown account'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
|
||||
<p className="text-xs text-muted">
|
||||
Every demand deal already has an engagement. Open one from the deal in Pipeline
|
||||
once there is a new deal to run.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="engagement-summary">What this engagement is for</Label>
|
||||
<Textarea
|
||||
id="engagement-summary"
|
||||
className="min-h-24"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder="The deployment being scoped, in one or two sentences."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{create.isError ? <p className="text-sm text-danger">{create.error.message}</p> : null}
|
||||
|
||||
<div className="flex min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!demandDealId || create.isPending}>
|
||||
Open engagement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
function StatusFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: EngagementStatus | null;
|
||||
onChange: (value: EngagementStatus | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" aria-label="Filter by status" className="scroll-x -mx-1 flex gap-1 px-1 pb-1">
|
||||
{([null, ...ENGAGEMENT_STATUSES] as const).map((option) => (
|
||||
<button
|
||||
key={option ?? 'all'}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === option}
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
'tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors',
|
||||
value === option
|
||||
? 'bg-surface text-fg shadow-sm ring-1 ring-border'
|
||||
: 'text-muted hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{option ? ENGAGEMENT_STATUS_LABELS[option] : 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: EngagementStatus }) {
|
||||
const tone =
|
||||
status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
|
||||
return <Badge tone={tone}>{ENGAGEMENT_STATUS_LABELS[status]}</Badge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The band leads and the figure follows it.
|
||||
*
|
||||
* "Qualified" is what a seller acts on; 6,200 basis points is what an analyst
|
||||
* checks afterwards. Showing the number alone invites the reading that 62 is
|
||||
* nearly 70 and therefore nearly good, when the band boundary at 7500 is the
|
||||
* only place anything changes.
|
||||
*/
|
||||
function ScoreCell({ score }: { score: LatestScore | null }) {
|
||||
if (!score) return <span className="text-sm text-muted">Not scored</span>;
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Badge tone={score.tone}>{score.band}</Badge>
|
||||
<span className="nums whitespace-nowrap text-xs text-muted" title={`${score.basisPoints} basis points`}>
|
||||
{percent(score.basisPoints)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* The library: nine kinds, filtered four ways, in a URL somebody can send.
|
||||
*
|
||||
* The filters live in the query string rather than in component state, which is
|
||||
* a first for this app. It is not tidiness — "the proposal blocks we have for
|
||||
* procurement" is a thing one person tells another, and until now the only way
|
||||
* to share a view was to describe the clicks. `useSearchParams` makes the view
|
||||
* the address.
|
||||
*
|
||||
* Two rules keep that URL readable, and both are load-bearing:
|
||||
* defaults are never written (an unset filter is an absent parameter, not
|
||||
* `?kind=all`), and every write is `replace`, so dragging across the stage rail
|
||||
* leaves one history entry rather than eight for the back button to walk.
|
||||
*
|
||||
* An unrecognised value is treated as absent. The API answers `400
|
||||
* invalid_request` on an unknown enum, and a hand-edited URL — or one that
|
||||
* outlived a kind the ontology dropped — should degrade to the unfiltered
|
||||
* library, not to an error page.
|
||||
*
|
||||
* The rail's figures come from `/api/motion` rather than from the rows on
|
||||
* screen: a rail computed from the filtered result empties as you use it, which
|
||||
* makes the one control that is meant to navigate the motion useless the moment
|
||||
* anything is selected.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AlertTriangle, Layers, Library, Lock, RefreshCw, Search } from 'lucide-react';
|
||||
import {
|
||||
DEMAND_STAGES,
|
||||
MOTION_KINDS,
|
||||
MOTION_KIND_LABELS,
|
||||
MOTION_VISIBILITIES,
|
||||
MOTION_VISIBILITY_LABELS,
|
||||
isMotionKind,
|
||||
isMotionVisibility,
|
||||
toPiggyPageRoute,
|
||||
type DemandStage,
|
||||
type MotionKind,
|
||||
type MotionVisibility,
|
||||
} from '@pig/core';
|
||||
import { StageRail } from '@/components/motion/StageRail';
|
||||
import { TemplateCard } from '@/components/motion/TemplateCard';
|
||||
import type { MotionTemplateView } from '@/components/motion/model';
|
||||
import { Button, Card, CardContent, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { get } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
// ------------------------------------------------------------------- shapes
|
||||
|
||||
/**
|
||||
* What the list endpoint serialises: the row without `body` or `fields`.
|
||||
*
|
||||
* Two hundred playbooks is a megabyte of markdown nobody on this page reads, so
|
||||
* the API sends the summary and the detail endpoint sends the document.
|
||||
*/
|
||||
type TemplateSummary = Omit<MotionTemplateView, 'body' | 'fields'>;
|
||||
|
||||
interface TemplateList {
|
||||
templates: TemplateSummary[];
|
||||
/** The scan hit its bound — the library is wider than this answer. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface StageCoverage {
|
||||
stage: DemandStage;
|
||||
engagements: number;
|
||||
templates: number;
|
||||
}
|
||||
|
||||
interface MotionOverview {
|
||||
stages: StageCoverage[];
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
kind: MotionKind | null;
|
||||
stage: DemandStage | null;
|
||||
visibility: MotionVisibility | null;
|
||||
q: string;
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
const STAGE_PARAM = 'stage';
|
||||
/** Long enough that a typed word is one request, short enough to feel live. */
|
||||
const SEARCH_SETTLE_MS = 250;
|
||||
|
||||
// --------------------------------------------------------------------- page
|
||||
|
||||
export function MotionLibrary() {
|
||||
usePageTitle('Library');
|
||||
usePiggyContext({
|
||||
type: 'page',
|
||||
route: toPiggyPageRoute('/motion/library'),
|
||||
label: 'Motion library',
|
||||
});
|
||||
const me = useIdentity();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const filters = readFilters(params);
|
||||
|
||||
// The input is local and the URL follows it, rather than the other way
|
||||
// round: bound directly to the query string, every keystroke is a request
|
||||
// against a 400-row scan and a URL that is only copyable between words.
|
||||
const [text, setText] = useState(filters.q);
|
||||
const pushed = useRef(filters.q);
|
||||
useEffect(() => {
|
||||
if (text === filters.q) return;
|
||||
const timer = setTimeout(() => {
|
||||
pushed.current = text;
|
||||
setParam(setParams, 'q', text);
|
||||
}, SEARCH_SETTLE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [filters.q, setParams, text]);
|
||||
useEffect(() => {
|
||||
// The URL moved and it was not us — a pasted link, or a back navigation
|
||||
// out of some other filter. Adopt it. Without this the pending input wins
|
||||
// the next tick and pushes itself straight back over the address, which
|
||||
// reads as a back button that does not work.
|
||||
if (filters.q === pushed.current) return;
|
||||
pushed.current = filters.q;
|
||||
setText(filters.q);
|
||||
}, [filters.q]);
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: ['motion', 'templates', filters],
|
||||
queryFn: () => get<TemplateList>(`/api/motion/templates${queryString(filters)}`),
|
||||
enabled: canAny(me, 'book:read'),
|
||||
});
|
||||
// Shared with the Motion home, and deliberately unfiltered — see the header.
|
||||
const overview = useQuery({
|
||||
queryKey: ['motion', 'overview'],
|
||||
queryFn: () => get<MotionOverview>('/api/motion'),
|
||||
enabled: canAny(me, 'book:read'),
|
||||
});
|
||||
|
||||
const railCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(overview.data?.stages ?? []).map((stage) => [stage.stage, stage.templates]),
|
||||
) as Partial<Record<DemandStage, number>>,
|
||||
[overview.data],
|
||||
);
|
||||
|
||||
const filtered = Boolean(
|
||||
filters.kind || filters.stage || filters.visibility || filters.q || filters.all,
|
||||
);
|
||||
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<PageHeader />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock />}
|
||||
title="The library is restricted"
|
||||
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = templates.data?.templates ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<PageHeader />
|
||||
|
||||
<StageRail
|
||||
counts={railCounts}
|
||||
countLabel="templates"
|
||||
activeStage={filters.stage}
|
||||
onSelect={(stage) =>
|
||||
setParam(setParams, STAGE_PARAM, stage === filters.stage ? '' : stage)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_170px_170px]">
|
||||
<div className="relative min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
|
||||
<Input
|
||||
aria-label="Search the library"
|
||||
className="pl-9"
|
||||
placeholder="Search title, summary or slug"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<EnumSelect
|
||||
aria-label="Filter by kind"
|
||||
value={filters.kind ?? 'all'}
|
||||
onValueChange={(value) => setParam(setParams, 'kind', value === 'all' ? '' : value)}
|
||||
>
|
||||
<SelectItem value="all">All kinds</SelectItem>
|
||||
{MOTION_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{MOTION_KIND_LABELS[kind]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</EnumSelect>
|
||||
<EnumSelect
|
||||
aria-label="Filter by visibility"
|
||||
value={filters.visibility ?? 'all'}
|
||||
onValueChange={(value) => setParam(setParams, 'visibility', value === 'all' ? '' : value)}
|
||||
>
|
||||
<SelectItem value="all">Shared and private</SelectItem>
|
||||
{MOTION_VISIBILITIES.map((visibility) => (
|
||||
<SelectItem key={visibility} value={visibility}>
|
||||
{MOTION_VISIBILITY_LABELS[visibility]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</EnumSelect>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-11 min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<p className="min-w-0 text-sm text-muted">
|
||||
<strong className="nums text-fg">{rows.length}</strong>{' '}
|
||||
{filters.all ? 'versions' : 'templates'} shown
|
||||
{templates.data?.truncated ? ' · more exist than fit in one answer' : ''}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{/* Lineages collapse to their newest version by default. The whole
|
||||
history is what you want when judging whether a template is
|
||||
settled or still being rewritten every fortnight. */}
|
||||
<Button
|
||||
variant={filters.all ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
aria-pressed={filters.all}
|
||||
onClick={() => setParam(setParams, 'all', filters.all ? '' : '1')}
|
||||
>
|
||||
<Layers aria-hidden />
|
||||
{filters.all ? 'Newest versions' : 'Every version'}
|
||||
</Button>
|
||||
{filtered ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setText('');
|
||||
setParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.isLoading ? <Skeleton className="h-64" /> : null}
|
||||
|
||||
{templates.isError ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Library unavailable"
|
||||
description={
|
||||
templates.error instanceof Error
|
||||
? templates.error.message
|
||||
: 'The library could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void templates.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Library />}
|
||||
title={filtered ? 'No templates match' : 'The library is empty'}
|
||||
description={
|
||||
filtered
|
||||
? 'Clear a filter to see the rest of the library. A private template belonging to somebody else will never appear here.'
|
||||
: 'Nine kinds and nothing in them yet. Author the discovery guide you already run from memory, and instantiate it into the next deal.'
|
||||
}
|
||||
action={
|
||||
filtered ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setText('');
|
||||
setParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{rows.length ? (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
// The card reads neither `body` nor `fields`; its prop type asks
|
||||
// for them only because one interface describes both endpoints.
|
||||
// Asserting beats inventing an empty body, which would be
|
||||
// indistinguishable from a template somebody saved blank.
|
||||
template={template as MotionTemplateView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fragments
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Motion</p>
|
||||
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Library</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
Discovery guides, qualification frameworks, proposal blocks and playbooks — the parts of a
|
||||
deployment that should not be rewritten each time. This view is linkable: send the address,
|
||||
not the clicks.
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type EnumSelectProps = React.ComponentProps<typeof Select> & { 'aria-label': string };
|
||||
|
||||
function EnumSelect({ children, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger aria-label={ariaLabel} className="h-11 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>{children}</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ filters
|
||||
|
||||
/**
|
||||
* Local, because `@pig/core` ships guards for the motion enums but not for the
|
||||
* stage list. Declared here rather than added there: this is the only caller,
|
||||
* and widening the ontology's surface for one URL parser is not the trade.
|
||||
*/
|
||||
function isDemandStage(value: string): value is DemandStage {
|
||||
return (DEMAND_STAGES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** An unrecognised value reads as absent — see the header. */
|
||||
function readFilters(params: URLSearchParams): Filters {
|
||||
const kind = params.get('kind');
|
||||
const stage = params.get(STAGE_PARAM);
|
||||
const visibility = params.get('visibility');
|
||||
return {
|
||||
kind: kind && isMotionKind(kind) ? kind : null,
|
||||
stage: stage && isDemandStage(stage) ? stage : null,
|
||||
visibility: visibility && isMotionVisibility(visibility) ? visibility : null,
|
||||
q: params.get('q')?.trim() ?? '',
|
||||
all: params.get('all') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one parameter, dropping it when it falls back to the default.
|
||||
*
|
||||
* Functional form, because two filters can settle within one tick — the search
|
||||
* debounce firing while a stage is being pressed — and reading `params` from
|
||||
* the closure would make the second write discard the first.
|
||||
*/
|
||||
function setParam(
|
||||
setParams: ReturnType<typeof useSearchParams>[1],
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
setParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function queryString(filters: Filters): string {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.kind) query.set('kind', filters.kind);
|
||||
if (filters.stage) query.set(STAGE_PARAM, filters.stage);
|
||||
if (filters.visibility) query.set('visibility', filters.visibility);
|
||||
if (filters.q) query.set('q', filters.q);
|
||||
if (filters.all) query.set('all', '1');
|
||||
const rendered = query.toString();
|
||||
return rendered ? `?${rendered}` : '';
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* One template, with its provenance stated rather than implied.
|
||||
*
|
||||
* The document is the obvious half of this page and the least interesting one.
|
||||
* The argument for the whole feature is the chain: this is v3, it supersedes
|
||||
* v2, and it exists because an artefact in a real engagement was finalised and
|
||||
* promoted. A library that shows only the newest body is a folder of files —
|
||||
* the lineage is what makes "every deployment makes the next one easier" a
|
||||
* mechanism somebody can audit.
|
||||
*
|
||||
* The one rule this page has to teach is §7a: a template that has been
|
||||
* instantiated is never edited in place, because a live engagement must not
|
||||
* have the thing it was copied from change underneath it. So a used template
|
||||
* does not show a dead Save button — it says what the rule is and offers the
|
||||
* move that is actually available, a new version in the same lineage.
|
||||
*
|
||||
* `canEdit` and `canPublish` are the server's judgement, read off the detail
|
||||
* response. Re-deriving them here from `usageCount` and ownership would put a
|
||||
* second copy of the rule in TSX, and the two copies would drift.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
GitBranch,
|
||||
History,
|
||||
Lock,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DEMAND_STAGES,
|
||||
DEMAND_STAGE_LABELS,
|
||||
MOTION_KIND_DESCRIPTIONS,
|
||||
MOTION_VISIBILITY_LABELS,
|
||||
toPiggyPageRoute,
|
||||
type DemandStage,
|
||||
type MotionKind,
|
||||
type MotionVisibility,
|
||||
} from '@pig/core';
|
||||
import { FieldsView } from '@/components/motion/FieldsView';
|
||||
import { Markdown } from '@/components/motion/Markdown';
|
||||
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, get, patch, post, relativeTime, shortDate } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
// ------------------------------------------------------------------- shapes
|
||||
|
||||
interface TemplateSummary {
|
||||
id: string;
|
||||
kind: MotionKind;
|
||||
slug: string;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
stage: DemandStage;
|
||||
visibility: MotionVisibility;
|
||||
ownerUserId: string | null;
|
||||
supersedesId: string | null;
|
||||
originArtifactId: string | null;
|
||||
isSystem: boolean;
|
||||
usageCount: number;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TemplateDetail extends TemplateSummary {
|
||||
body: string;
|
||||
fields: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface TemplateDetailResponse {
|
||||
template: TemplateDetail;
|
||||
/** Every version of this slug the reader may see, oldest first. */
|
||||
lineage: TemplateSummary[];
|
||||
/** Owner or admin, and never instantiated. The §7a rule, already applied. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
}
|
||||
|
||||
interface EngagementOption {
|
||||
id: string;
|
||||
dealName: string | null;
|
||||
accountName: string | null;
|
||||
stage: DemandStage | null;
|
||||
}
|
||||
|
||||
interface Promotion {
|
||||
templateId: string;
|
||||
engagementId: string | null;
|
||||
dealName: string | null;
|
||||
}
|
||||
|
||||
interface MotionOverview {
|
||||
promotions: Promotion[];
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
stage: DemandStage;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- page
|
||||
|
||||
export function MotionTemplate() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const me = useIdentity();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [engagementId, setEngagementId] = useState('');
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['motion', 'templates', id, 'detail'],
|
||||
queryFn: () => get<TemplateDetailResponse>(`/api/motion/templates/${id}`),
|
||||
enabled: Boolean(id) && canAny(me, 'book:read'),
|
||||
retry: false,
|
||||
});
|
||||
const engagements = useQuery({
|
||||
queryKey: ['motion', 'engagements', 'open'],
|
||||
queryFn: () => get<{ engagements: EngagementOption[] }>('/api/motion/engagements?status=open'),
|
||||
enabled: Boolean(id) && canAny(me, 'motion:write'),
|
||||
});
|
||||
/*
|
||||
* Read only for the provenance line. The detail response carries
|
||||
* `originArtifactId` but not the engagement behind it, and the overview's
|
||||
* promotion list is the one place that join already exists — so a recently
|
||||
* promoted template can name the deal it came out of, and an older one says
|
||||
* plainly that it came from an engagement without inventing which.
|
||||
*/
|
||||
const overview = useQuery({
|
||||
queryKey: ['motion', 'overview'],
|
||||
queryFn: () => get<MotionOverview>('/api/motion'),
|
||||
enabled: Boolean(detail.data?.template.originArtifactId),
|
||||
});
|
||||
|
||||
const template = detail.data?.template;
|
||||
usePageTitle(template?.title ?? 'Template');
|
||||
usePiggyContext({
|
||||
type: 'page',
|
||||
route: toPiggyPageRoute('/motion/library'),
|
||||
label: template ? template.title : 'Motion library',
|
||||
});
|
||||
|
||||
const invalidate = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['motion', 'templates'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['motion', 'overview'] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (input: EditState) => patch<{ template: TemplateDetail }>(`/api/motion/templates/${id}`, input),
|
||||
onSuccess: async () => {
|
||||
setEditing(false);
|
||||
await invalidate();
|
||||
toast.success('Template saved');
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const fork = useMutation({
|
||||
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/versions`, {}),
|
||||
onSuccess: async ({ template: created }) => {
|
||||
await invalidate();
|
||||
toast.success(`Version ${created.version} drafted, private to you`);
|
||||
navigate(`/motion/library/${created.id}`);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/publish`, {}),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
toast.success('Published to the book');
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const instantiate = useMutation({
|
||||
mutationFn: (target: string) =>
|
||||
post<{ artifact: { id: string; engagementId: string } }>(
|
||||
`/api/motion/engagements/${target}/artifacts`,
|
||||
{ templateId: id },
|
||||
),
|
||||
onSuccess: async ({ artifact }) => {
|
||||
await invalidate();
|
||||
await queryClient.invalidateQueries({ queryKey: ['motion', 'engagements'] });
|
||||
toast.success('Instantiated into the engagement');
|
||||
navigate(`/motion/engagements/${artifact.engagementId}`);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (!canAny(me, 'book:read')) {
|
||||
return (
|
||||
<Restricted
|
||||
icon={<Lock />}
|
||||
title="This template is restricted"
|
||||
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<BackLink />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error instanceof ApiError && detail.error.status === 404) {
|
||||
return (
|
||||
<Restricted
|
||||
icon={<Lock />}
|
||||
title="No such template"
|
||||
// The same answer covers both cases on purpose, and saying so is
|
||||
// kinder than a bare "not found" that reads as a broken link.
|
||||
description="It has been archived, the link was to an id that never existed, or it is private to somebody else."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error || !detail.data || !template) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<BackLink />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="Template unavailable"
|
||||
description={
|
||||
detail.error instanceof Error ? detail.error.message : 'The template could not be loaded.'
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => void detail.refetch()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { lineage, canEdit, canPublish } = detail.data;
|
||||
const mayWrite = canAny(me, 'motion:write');
|
||||
const supersedes = lineage.find((version) => version.id === template.supersedesId) ?? null;
|
||||
const promotion = overview.data?.promotions.find((row) => row.templateId === template.id) ?? null;
|
||||
const openEngagements = engagements.data?.engagements ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<BackLink />
|
||||
|
||||
<header className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<MotionKindBadge kind={template.kind} />
|
||||
<Badge tone="neutral">{DEMAND_STAGE_LABELS[template.stage]}</Badge>
|
||||
<Badge tone={template.visibility === 'private' ? 'warning' : 'neutral'}>
|
||||
{MOTION_VISIBILITY_LABELS[template.visibility]}
|
||||
</Badge>
|
||||
{template.isSystem ? (
|
||||
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
|
||||
<Sparkles className="size-3 shrink-0" aria-hidden />
|
||||
Starter
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mt-2 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
|
||||
{template.title}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
|
||||
{template.summary}
|
||||
</p>
|
||||
<p className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<span className="nums whitespace-nowrap">Version {template.version}</span>
|
||||
<span className="nums inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<Users className="size-3 shrink-0" aria-hidden />
|
||||
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{MOTION_KIND_DESCRIPTIONS[template.kind]}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Provenance
|
||||
template={template}
|
||||
supersedes={supersedes}
|
||||
engagementId={promotion?.engagementId ?? null}
|
||||
dealName={promotion?.dealName ?? null}
|
||||
/>
|
||||
|
||||
{/*
|
||||
The §7a rule, said in full where the edit button would otherwise be.
|
||||
A disabled control with no explanation teaches the reader that the app
|
||||
is broken; this one names the reason and the move that replaces it.
|
||||
*/}
|
||||
{template.usageCount > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 rounded-xl border border-info/30 bg-info/10 p-3 text-sm">
|
||||
<History className="size-4 shrink-0 text-info" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">This template is closed to edits</p>
|
||||
<p className="mt-1 text-muted">
|
||||
{template.usageCount === 1 ? 'An engagement has' : `${template.usageCount} engagements have`}{' '}
|
||||
already instantiated it, and their artefacts record that they came from v
|
||||
{template.version}. Changing it now would rewrite their provenance. Draft a new
|
||||
version instead — the lineage keeps both.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-w-0 gap-2 sm:flex sm:flex-wrap">
|
||||
{canEdit ? (
|
||||
<Button
|
||||
variant={editing ? 'secondary' : 'outline'}
|
||||
aria-pressed={editing}
|
||||
onClick={() => setEditing((current) => !current)}
|
||||
>
|
||||
<Pencil aria-hidden />
|
||||
{editing ? 'Stop editing' : 'Edit draft'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={template.usageCount > 0 ? 'primary' : 'outline'}
|
||||
disabled={!mayWrite || fork.isPending}
|
||||
title={mayWrite ? undefined : WRITE_DENIED}
|
||||
onClick={() => fork.mutate()}
|
||||
>
|
||||
<GitBranch aria-hidden />
|
||||
{template.usageCount > 0 ? 'New version' : 'Fork to a private draft'}
|
||||
</Button>
|
||||
{canPublish ? (
|
||||
<Button variant="primary" disabled={publish.isPending} onClick={() => publish.mutate()}>
|
||||
<Send aria-hidden />
|
||||
Publish to the book
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editing && canEdit ? (
|
||||
<TemplateEditor
|
||||
initial={{
|
||||
title: template.title,
|
||||
summary: template.summary,
|
||||
body: template.body,
|
||||
stage: template.stage,
|
||||
}}
|
||||
pending={save.isPending}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={(input) => save.mutate(input)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Use this template</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Instantiating copies the body and fields into the engagement as a draft artefact, and
|
||||
records which version it came from.
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
{!mayWrite ? (
|
||||
<p className="text-sm text-muted">{WRITE_DENIED}</p>
|
||||
) : openEngagements.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
No open engagement to instantiate into.{' '}
|
||||
<Link to="/motion/engagements" className="underline underline-offset-4">
|
||||
Open one against a demand deal
|
||||
</Link>{' '}
|
||||
first.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select value={engagementId || undefined} onValueChange={setEngagementId}>
|
||||
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
|
||||
<SelectValue placeholder="Choose an engagement" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{openEngagements.map((engagement) => (
|
||||
<SelectItem key={engagement.id} value={engagement.id}>
|
||||
{engagement.dealName ?? 'Unnamed deal'}
|
||||
{engagement.accountName ? ` · ${engagement.accountName}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!engagementId || instantiate.isPending}
|
||||
onClick={() => instantiate.mutate(engagementId)}
|
||||
>
|
||||
Instantiate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>The document</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{template.body.trim() ? (
|
||||
<Markdown content={template.body} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">This version has no body.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldsView kind={template.kind} fields={template.fields} />
|
||||
|
||||
<Lineage lineage={lineage} currentId={template.id} slug={template.slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fragments
|
||||
|
||||
const WRITE_DENIED = 'Authoring in the library needs the motion:write permission.';
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
to="/motion/library"
|
||||
className="tap inline-flex min-h-11 w-fit items-center gap-2 text-sm text-muted hover:text-fg"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Back to the library
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Restricted({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<BackLink />
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState icon={icon} title={title} description={description} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this version came from, in one sentence a reader can act on.
|
||||
*
|
||||
* Three facts, and each is absent for a legitimate reason: v1 supersedes
|
||||
* nothing, an authored template was never promoted, and a promotion older than
|
||||
* the overview's window is known to exist without its engagement being named.
|
||||
*/
|
||||
function Provenance({
|
||||
template,
|
||||
supersedes,
|
||||
engagementId,
|
||||
dealName,
|
||||
}: {
|
||||
template: TemplateSummary;
|
||||
supersedes: TemplateSummary | null;
|
||||
engagementId: string | null;
|
||||
dealName: string | null;
|
||||
}) {
|
||||
const promoted = Boolean(template.originArtifactId);
|
||||
if (!supersedes && !promoted) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 text-sm">
|
||||
<GitBranch className="size-4 shrink-0 text-muted" aria-hidden />
|
||||
<p className="min-w-0 break-words">
|
||||
<span className="nums font-medium">Version {template.version}</span>
|
||||
{supersedes ? (
|
||||
<>
|
||||
, superseding{' '}
|
||||
<Link
|
||||
to={`/motion/library/${supersedes.id}`}
|
||||
className="underline underline-offset-4 hover:text-accent-fg"
|
||||
>
|
||||
v{supersedes.version}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
{promoted ? (
|
||||
<>
|
||||
, promoted from an artefact
|
||||
{engagementId ? (
|
||||
<>
|
||||
{' '}on{' '}
|
||||
<Link
|
||||
to={`/motion/engagements/${engagementId}`}
|
||||
className="underline underline-offset-4 hover:text-accent-fg"
|
||||
>
|
||||
{dealName ?? 'its engagement'}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
' proved in an engagement'
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
. <span className="text-muted">Created {shortDate(template.createdAt)}.</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lineage({
|
||||
lineage,
|
||||
currentId,
|
||||
slug,
|
||||
}: {
|
||||
lineage: TemplateSummary[];
|
||||
currentId: string;
|
||||
slug: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Version history</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Every version of <span className="font-mono text-xs">{slug}</span> you can see, oldest
|
||||
first. Versions private to somebody else are not listed.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-2">
|
||||
{lineage.map((version) => (
|
||||
<LineageRow
|
||||
key={version.id}
|
||||
version={version}
|
||||
current={version.id === currentId}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One version in the history.
|
||||
*
|
||||
* The whole row navigates rather than the title alone, which was a single line
|
||||
* of text and about 20px of target on a phone. The version already open is a
|
||||
* plain div instead: a link to where you already are is a tap that does
|
||||
* nothing, which is worse than no target at all.
|
||||
*/
|
||||
function LineageRow({ version, current }: { version: TemplateSummary; current: boolean }) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="min-w-0 break-words font-medium">{version.title}</p>
|
||||
<p className="mt-1 min-w-0 truncate text-xs text-muted">
|
||||
{MOTION_VISIBILITY_LABELS[version.visibility]} ·{' '}
|
||||
<span className="nums">{version.usageCount}</span> use
|
||||
{version.usageCount === 1 ? '' : 's'} · {relativeTime(version.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={current ? 'accent' : 'neutral'} className="nums shrink-0">
|
||||
v{version.version}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
const shape = 'flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg p-3';
|
||||
|
||||
if (current) {
|
||||
return <div className={cn(shape, 'bg-surface-2 ring-1 ring-border')}>{body}</div>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={`/motion/library/${version.id}`}
|
||||
className={cn(shape, 'tap bg-surface-2/60 hover:bg-surface-2')}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft editor, shown only while `canEdit` holds.
|
||||
*
|
||||
* No `kind` and no `visibility` field, matching the endpoint: a lineage that
|
||||
* changes kind halfway is a different template wearing the same slug, and
|
||||
* visibility moves through Publish, where the capability check lives.
|
||||
*/
|
||||
function TemplateEditor({
|
||||
initial,
|
||||
pending,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
initial: EditState;
|
||||
pending: boolean;
|
||||
onCancel(): void;
|
||||
onSave(input: EditState): void;
|
||||
}) {
|
||||
const [form, setForm] = useState(initial);
|
||||
const set = <Key extends keyof EditState>(key: Key, value: EditState[Key]) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<form
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSave(form);
|
||||
}}
|
||||
>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="template-title">Title</Label>
|
||||
<Input
|
||||
id="template-title"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(event) => set('title', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="template-stage">Stage it serves</Label>
|
||||
<Select value={form.stage} onValueChange={(value) => set('stage', value as DemandStage)}>
|
||||
<SelectTrigger id="template-stage" className="h-11 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DEMAND_STAGES.map((stage) => (
|
||||
<SelectItem key={stage} value={stage}>
|
||||
{DEMAND_STAGE_LABELS[stage]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="template-summary">Summary</Label>
|
||||
<Textarea
|
||||
id="template-summary"
|
||||
required
|
||||
rows={2}
|
||||
value={form.summary}
|
||||
onChange={(event) => set('summary', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label htmlFor="template-body">Body</Label>
|
||||
<Textarea
|
||||
id="template-body"
|
||||
rows={16}
|
||||
className="font-mono text-xs"
|
||||
value={form.body}
|
||||
onChange={(event) => set('body', event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
Markdown, with GFM tables and task lists. Structured fields are edited through the
|
||||
API for now.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
Save draft
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user