Per-stage AI cost badges + Stage 10 efficiency UI + Stage 8 fix
Three things in one commit because they share the same plumbing.
1) PER-STAGE COST DISPLAY
Migration 0006 adds cost_usd / input_tokens / output_tokens columns to
stage_artifacts. Every Claude-driven agent now stamps its own run cost
on the artifact it produces:
- intake_agent (Stage 1)
- diagnosis_agent (Stage 2)
- asset_normalizer (Stage 6)
- ai_matching (Stage 7) — accumulates across the per-asset loop and
persists a NEW summary artifact (artifact_type='matching_run')
with the run totals + assets_matched + matches_created counts
- delivery_model_agent (Stage 9)
- capability_gap_agent (Stage 12)
- support_docs_agent (Stage 13)
A new <AgentRunCost> component renders a compact pill (label, cost,
in/out tokens) sourced from the most-recent stage artifact. Embedded
in the header of every Claude-driven stage panel: 1, 2, 6, 7, 9, 12, 13.
Per-deal cumulative cost still on the Stage 8 stats card.
2) STAGE 10 FRONTEND
Wires the Stage 10 efficiency-profile endpoint shipped in 2eb0422 to
a real UI (was placeholder before). Scenario picker (conservative /
moderate / aggressive), blanket slider, per-discipline overrides
(disabled when blanket > 0), tools-applied chips, free-text notes,
live impact preview that hits team-shape with the active settings,
and a Save button that persists to the artifact. Hydrates on mount
from the most recent saved profile.
3) STAGE 8 EMPTY STATE
"No ratecard yet" is now smart: when there ARE selected matches at
Stage 7 it surfaces the count + a primary "Build ratecard from N
matches" action, instead of telling you to do something you've
already done.
Smoke-tested: opp #2 ratecard rebuilt (35 lines); Stage 9 delivery
agent re-run shows $0.0437 / 4994 in / 1915 out on the badge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
96b8e2dc3d
commit
b41e3999c6
21 changed files with 537 additions and 17 deletions
40
backend/alembic/versions/0006_artifact_cost.py
Normal file
40
backend/alembic/versions/0006_artifact_cost.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""artifact cost columns
|
||||
|
||||
Revision ID: 0006_artifact_cost
|
||||
Revises: 0005_assets_match_rc
|
||||
Create Date: 2026-04-27
|
||||
|
||||
Adds per-artifact AI cost tracking so each Claude-driven stage surfaces
|
||||
its run cost in the UI alongside the result. Existing rows get 0.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0006_artifact_cost"
|
||||
down_revision: Union[str, None] = "0005_assets_match_rc"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"stage_artifacts",
|
||||
sa.Column("cost_usd", sa.Numeric(10, 6), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"stage_artifacts",
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"stage_artifacts",
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("stage_artifacts", "output_tokens")
|
||||
op.drop_column("stage_artifacts", "input_tokens")
|
||||
op.drop_column("stage_artifacts", "cost_usd")
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, Integer, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
|
||||
from sqlalchemy import String, Text, Integer, Numeric, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
|
@ -74,6 +74,12 @@ class StageArtifact(Base):
|
|||
artifact_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
content_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
file_path: Mapped[str | None] = mapped_column(String(500))
|
||||
# Per-artifact AI cost — populated by the agent that produced this artifact.
|
||||
# Stages without a Claude call (qualification scorecard, ratecard build,
|
||||
# pitch deck stub) leave these at 0.
|
||||
cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0, nullable=False)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
opportunity: Mapped["Opportunity"] = relationship(back_populates="artifacts")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class StageArtifactOut(BaseModel):
|
|||
artifact_type: str
|
||||
content_json: dict | None
|
||||
file_path: str | None
|
||||
cost_usd: float = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -167,6 +167,11 @@ async def match_opportunity_assets(
|
|||
brief_context = await _load_brief_context(db, opportunity)
|
||||
|
||||
all_matches: list[Match] = []
|
||||
# Accumulate cost across all per-asset Claude calls for the artifact stamp.
|
||||
run_cost_usd = 0.0
|
||||
run_input_tokens = 0
|
||||
run_output_tokens = 0
|
||||
|
||||
for snap in snapshots:
|
||||
tier_hint = ""
|
||||
if snap["client_tier"]:
|
||||
|
|
@ -199,6 +204,9 @@ async def match_opportunity_assets(
|
|||
opportunity.ai_output_tokens = (opportunity.ai_output_tokens or 0) + (usage.get("output_tokens", 0) or 0)
|
||||
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
|
||||
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
|
||||
run_cost_usd += float(usage.get("cost_usd", 0) or 0)
|
||||
run_input_tokens += int(usage.get("input_tokens", 0) or 0)
|
||||
run_output_tokens += int(usage.get("output_tokens", 0) or 0)
|
||||
|
||||
result = extract_tool_result(response) or {}
|
||||
raw_matches = result.get("matches") or []
|
||||
|
|
@ -241,5 +249,23 @@ async def match_opportunity_assets(
|
|||
await db.commit()
|
||||
await db.refresh(opportunity)
|
||||
|
||||
logger.info(f"Match: {len(all_matches)} matches across {len(snapshots)} client assets")
|
||||
# Stamp a stage_artifact for the matching run so the Stage 7 panel shows
|
||||
# the run cost the same way every other agent stage does.
|
||||
summary_artifact = StageArtifact(
|
||||
opportunity_id=opportunity.id,
|
||||
stage_number=7,
|
||||
artifact_type="matching_run",
|
||||
content_json={
|
||||
"assets_matched": len(snapshots),
|
||||
"matches_created": len(all_matches),
|
||||
"auto_selected": sum(1 for m in all_matches if m.is_selected),
|
||||
},
|
||||
cost_usd=run_cost_usd,
|
||||
input_tokens=run_input_tokens,
|
||||
output_tokens=run_output_tokens,
|
||||
)
|
||||
db.add(summary_artifact)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Match: {len(all_matches)} matches across {len(snapshots)} client assets, ${run_cost_usd:.4f}")
|
||||
return all_matches
|
||||
|
|
|
|||
|
|
@ -133,12 +133,15 @@ async def run_normalize(db: AsyncSession, opportunity: Opportunity) -> dict:
|
|||
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
|
||||
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
|
||||
|
||||
# Save artifact
|
||||
# Save artifact (with per-run cost stamp).
|
||||
artifact = StageArtifact(
|
||||
opportunity_id=opportunity.id,
|
||||
stage_number=6,
|
||||
artifact_type="normalized_assets",
|
||||
content_json=result,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ async def run_capability_gaps(db: AsyncSession, opportunity: Opportunity) -> dic
|
|||
stage_number=12,
|
||||
artifact_type="capability_gaps",
|
||||
content_json=result,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
await db.flush()
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ async def run_delivery_model(db: AsyncSession, opportunity: Opportunity) -> dict
|
|||
stage_number=9,
|
||||
artifact_type="delivery_model",
|
||||
content_json=result,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
await db.flush()
|
||||
|
|
|
|||
|
|
@ -182,12 +182,15 @@ async def run_diagnosis(db: AsyncSession, opportunity: Opportunity) -> dict:
|
|||
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
|
||||
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
|
||||
|
||||
# Persist as artifact
|
||||
# Persist as artifact (with per-run cost stamp).
|
||||
artifact = StageArtifact(
|
||||
opportunity_id=opportunity.id,
|
||||
stage_number=2,
|
||||
artifact_type="brief_diagnosis",
|
||||
content_json=diagnosis,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
|
||||
|
|
|
|||
|
|
@ -134,12 +134,15 @@ async def run_intake(db: AsyncSession, opportunity: Opportunity) -> dict:
|
|||
opportunity.ai_cost_usd = float(opportunity.ai_cost_usd or 0) + float(usage.get("cost_usd", 0) or 0)
|
||||
opportunity.ai_call_count = (opportunity.ai_call_count or 0) + 1
|
||||
|
||||
# Persist as a stage artifact
|
||||
# Persist as a stage artifact (with per-run cost stamp).
|
||||
artifact = StageArtifact(
|
||||
opportunity_id=opportunity.id,
|
||||
stage_number=1,
|
||||
artifact_type="intake_metadata",
|
||||
content_json=metadata,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ async def run_support_docs(db: AsyncSession, opportunity: Opportunity) -> dict:
|
|||
stage_number=13,
|
||||
artifact_type="support_docs",
|
||||
content_json=result,
|
||||
cost_usd=float(usage.get("cost_usd", 0) or 0),
|
||||
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
db.add(artifact)
|
||||
await db.flush()
|
||||
|
|
|
|||
55
frontend/src/components/AgentRunCost.tsx
Normal file
55
frontend/src/components/AgentRunCost.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { StageArtifact } from '../types';
|
||||
|
||||
/**
|
||||
* Small cost badge that surfaces the AI cost incurred by an agent run,
|
||||
* pulled from the latest artifact for that stage. Renders compactly in a
|
||||
* stage panel header so users see the per-run cost alongside the result.
|
||||
*/
|
||||
export default function AgentRunCost({
|
||||
artifact,
|
||||
label = 'This run',
|
||||
}: {
|
||||
artifact: StageArtifact | undefined | null;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!artifact) return null;
|
||||
const cost = artifact.cost_usd ?? 0;
|
||||
const inTok = artifact.input_tokens ?? 0;
|
||||
const outTok = artifact.output_tokens ?? 0;
|
||||
if (cost === 0 && inTok === 0 && outTok === 0) return null;
|
||||
|
||||
return (
|
||||
<span style={pillStyle} title={`Run at ${new Date(artifact.created_at).toLocaleString()}`}>
|
||||
<span style={labelStyle}>{label}</span>
|
||||
<span style={costStyle}>${cost.toFixed(4)}</span>
|
||||
<span style={tokensStyle}>{inTok.toLocaleString()} in · {outTok.toLocaleString()} out</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const pillStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,196,7,0.06)',
|
||||
border: '1px solid rgba(255,196,7,0.20)',
|
||||
fontSize: 11,
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontWeight: 600,
|
||||
};
|
||||
const costStyle: React.CSSProperties = {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
|
||||
color: 'var(--color-accent)',
|
||||
fontWeight: 700,
|
||||
};
|
||||
const tokensStyle: React.CSSProperties = {
|
||||
color: 'var(--color-text-muted)',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
|
||||
};
|
||||
321
frontend/src/components/Stage10Efficiency.tsx
Normal file
321
frontend/src/components/Stage10Efficiency.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../api/client';
|
||||
import { useTeamShape } from '../api/teamShape';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
interface EfficiencyProfileContent {
|
||||
scenario?: 'conservative' | 'moderate' | 'aggressive';
|
||||
blanket_pct?: number;
|
||||
discipline_overrides?: Record<string, number>;
|
||||
tools_applied?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface EfficiencyProfileWrapper {
|
||||
artifact_id: number;
|
||||
saved_at: string;
|
||||
content: EfficiencyProfileContent;
|
||||
}
|
||||
|
||||
const SCENARIO_HINTS: Record<string, string> = {
|
||||
conservative: '5–15% per discipline · safe ramp',
|
||||
moderate: '20–40% per discipline · steady state',
|
||||
aggressive: '50%+ on adapt-heavy disciplines · proven tools live',
|
||||
};
|
||||
|
||||
export default function Stage10Efficiency({ opportunityId, canEdit }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const { data: existing, isLoading: existingLoading } = useQuery({
|
||||
queryKey: ['efficiency-profile', opportunityId],
|
||||
queryFn: async (): Promise<EfficiencyProfileWrapper | null> => {
|
||||
const res = await api.get(`/opportunities/${opportunityId}/efficiency-profile`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: opportunityId > 0,
|
||||
});
|
||||
|
||||
const [scenario, setScenario] = useState<'conservative' | 'moderate' | 'aggressive'>('moderate');
|
||||
const [blanket, setBlanket] = useState(0);
|
||||
const [overrides, setOverrides] = useState<Record<string, number>>({});
|
||||
const [tools, setTools] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Hydrate on first load of existing profile
|
||||
useEffect(() => {
|
||||
if (existing?.content) {
|
||||
const c = existing.content;
|
||||
setScenario(c.scenario ?? 'moderate');
|
||||
setBlanket(c.blanket_pct ?? 0);
|
||||
setOverrides(c.discipline_overrides ?? {});
|
||||
setTools(c.tools_applied ?? []);
|
||||
setNotes(c.notes ?? '');
|
||||
}
|
||||
}, [existing?.artifact_id]);
|
||||
|
||||
// Compute live preview using the team-shape endpoint
|
||||
const useOverrides = Object.keys(overrides).length > 0;
|
||||
const { data: teamShape } = useTeamShape(opportunityId, useOverrides ? 0 : blanket, useOverrides ? overrides : undefined);
|
||||
|
||||
const disciplines = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
(teamShape?.roles ?? []).forEach((r) => set.add(r.discipline));
|
||||
return Array.from(set).sort();
|
||||
}, [teamShape?.roles]);
|
||||
|
||||
const TOOL_OPTIONS = ['Pencil', 'Creative-X', 'Semblance', 'OMG', 'Google Vids', 'Synthesia', 'Adobe Firefly'];
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`/opportunities/${opportunityId}/efficiency-profile`, {
|
||||
scenario,
|
||||
blanket_pct: blanket,
|
||||
discipline_overrides: overrides,
|
||||
tools_applied: tools,
|
||||
notes: notes || null,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['efficiency-profile', opportunityId] });
|
||||
setSavedAt(Date.now());
|
||||
setTimeout(() => setSavedAt(null), 2500);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail ?? err?.message ?? 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Efficiency profile</h3>
|
||||
<p style={hintStyle}>
|
||||
Defines how much we expect AI tooling and process gains to reduce role hours. Stage 11 reads this profile
|
||||
when calculating FTE. Programme roles never see efficiency cuts; per-discipline overrides cap at 90%.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
<button onClick={save} disabled={!canEdit || saving} style={primaryBtnStyle}>
|
||||
{saving ? 'Saving…' : (existing ? 'Update profile' : 'Save profile')}
|
||||
</button>
|
||||
{savedAt && <span style={{ color: '#86efac', fontSize: 11 }}>✓ Saved</span>}
|
||||
{existing && (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 11 }}>
|
||||
Last saved {new Date(existing.saved_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h4 style={subTitleStyle}>Scenario</h4>
|
||||
<p style={hintStyle}>Pick a band; tweak the per-discipline sliders below for finer control.</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginTop: 8 }}>
|
||||
{(['conservative', 'moderate', 'aggressive'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScenario(s)}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
...scenarioBtnStyle,
|
||||
...(scenario === s ? scenarioBtnActiveStyle : {}),
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>{s}</div>
|
||||
<div style={{ marginTop: 4, color: 'var(--color-text-muted)', fontSize: 11 }}>{SCENARIO_HINTS[s]}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h4 style={subTitleStyle}>Blanket efficiency</h4>
|
||||
<p style={hintStyle}>
|
||||
Single number applied across all delivery roles. Disabled when any per-discipline override is set.
|
||||
</p>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span style={{ ...metaLabelStyle, marginBottom: 6 }}>{blanket}%</span>
|
||||
<input
|
||||
type="range" min={0} max={90} step={5}
|
||||
value={blanket}
|
||||
onChange={(e) => setBlanket(parseInt(e.target.value, 10))}
|
||||
disabled={!canEdit || useOverrides}
|
||||
style={sliderStyle}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<h4 style={subTitleStyle}>Per-discipline overrides</h4>
|
||||
{useOverrides && (
|
||||
<button onClick={() => setOverrides({})} style={ghostBtnStyle}>Clear all</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={hintStyle}>Per-discipline takes precedence over the blanket. Capped at 90% (one role can't be 100% AI).</p>
|
||||
{disciplines.length === 0 && (
|
||||
<p style={emptyStyle}>No disciplines yet — build the ratecard at Stage 8 first.</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, marginTop: 12 }}>
|
||||
{disciplines.map((disc) => {
|
||||
const v = overrides[disc] ?? 0;
|
||||
return (
|
||||
<div key={disc} style={overrideCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{disc}</span>
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace', color: v > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>
|
||||
{v}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={90} step={5}
|
||||
value={v}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => {
|
||||
const pct = parseInt(e.target.value, 10);
|
||||
if (pct === 0) {
|
||||
const next = { ...overrides };
|
||||
delete next[disc];
|
||||
setOverrides(next);
|
||||
} else {
|
||||
setOverrides({ ...overrides, [disc]: pct });
|
||||
}
|
||||
}}
|
||||
style={sliderStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h4 style={subTitleStyle}>Tools applied</h4>
|
||||
<p style={hintStyle}>For audit only — Stage 11 doesn't auto-derive efficiency from tools yet, but recording them makes the assumption legible.</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{TOOL_OPTIONS.map((t) => {
|
||||
const active = tools.includes(t);
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
if (!canEdit) return;
|
||||
setTools(active ? tools.filter((x) => x !== t) : [...tools, t]);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
style={{ ...chipBtnStyle, ...(active ? chipBtnActiveStyle : {}) }}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h4 style={subTitleStyle}>Notes</h4>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Anchor the assumptions — e.g. 'Year-1 ramp; aggressive on social statics; manual on motion'"
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{teamShape && teamShape.roles.length > 0 && (
|
||||
<section style={cardStyle}>
|
||||
<h4 style={subTitleStyle}>Live impact preview</h4>
|
||||
<p style={hintStyle}>What Stage 11 will show with this profile applied (does NOT save until you click "Save profile").</p>
|
||||
<div style={statsRowStyle}>
|
||||
<Stat label="Total hrs" value={teamShape.total_hours.toLocaleString()} />
|
||||
<Stat label="Total FTE" value={teamShape.total_fte.toFixed(2)} />
|
||||
<Stat label="Adjusted hrs" value={teamShape.adjusted_hours.toLocaleString()} fg="#86efac" />
|
||||
<Stat label="Adjusted FTE" value={teamShape.adjusted_fte.toFixed(2)} fg="#86efac" />
|
||||
<Stat label="Hours saved" value={teamShape.hours_saved.toLocaleString()} fg={teamShape.hours_saved > 0 ? '#86efac' : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{existingLoading && <div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>Loading saved profile…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, fg }: { label: string; value: number | string; fg?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 22, color: fg || 'var(--color-text)' }}>{value}</div>
|
||||
<div style={metaLabelStyle}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 10, padding: 18,
|
||||
};
|
||||
const titleStyle: React.CSSProperties = { margin: 0, fontSize: 15, fontWeight: 600 };
|
||||
const subTitleStyle: React.CSSProperties = { margin: 0, fontSize: 13, fontWeight: 600 };
|
||||
const hintStyle: React.CSSProperties = { marginTop: 4, marginBottom: 0, color: 'var(--color-text-muted)', fontSize: 12 };
|
||||
const emptyStyle: React.CSSProperties = { color: 'var(--color-text-muted)', fontSize: 13, marginTop: 8 };
|
||||
const statsRowStyle: React.CSSProperties = { display: 'flex', gap: 28, padding: '8px 4px', flexWrap: 'wrap' };
|
||||
const metaLabelStyle: React.CSSProperties = {
|
||||
display: 'block', fontSize: 11, color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600,
|
||||
};
|
||||
const sliderStyle: React.CSSProperties = { width: '100%', accentColor: 'var(--color-accent)', marginTop: 4 };
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
background: '#FFC407', color: '#0e0f13', border: 'none',
|
||||
padding: '10px 18px', borderRadius: 8, fontWeight: 600, fontSize: 13, cursor: 'pointer',
|
||||
};
|
||||
const ghostBtnStyle: React.CSSProperties = {
|
||||
background: 'transparent', color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border)', padding: '6px 10px', borderRadius: 6,
|
||||
fontWeight: 500, fontSize: 11, cursor: 'pointer',
|
||||
};
|
||||
const errorStyle: React.CSSProperties = {
|
||||
marginTop: 12, background: 'rgba(239,68,68,0.10)',
|
||||
border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5',
|
||||
padding: '10px 12px', borderRadius: 8, fontSize: 13,
|
||||
};
|
||||
const scenarioBtnStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-input)', color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border-light)', padding: 14, borderRadius: 8,
|
||||
textAlign: 'left', cursor: 'pointer', transition: 'border-color 120ms ease',
|
||||
};
|
||||
const scenarioBtnActiveStyle: React.CSSProperties = {
|
||||
border: '1px solid var(--color-accent)', background: 'rgba(255,196,7,0.06)',
|
||||
};
|
||||
const overrideCardStyle: React.CSSProperties = {
|
||||
padding: 12, background: 'var(--color-bg-input)',
|
||||
border: '1px solid var(--color-border-light)', borderRadius: 8,
|
||||
};
|
||||
const chipBtnStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-input)', color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border-light)', padding: '6px 12px', borderRadius: 14,
|
||||
fontSize: 12, cursor: 'pointer',
|
||||
};
|
||||
const chipBtnActiveStyle: React.CSSProperties = {
|
||||
background: 'rgba(255,196,7,0.12)', color: 'var(--color-accent)',
|
||||
border: '1px solid var(--color-accent)',
|
||||
};
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
marginTop: 6, width: '100%', padding: 10,
|
||||
background: 'var(--color-bg-input)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 6,
|
||||
color: 'var(--color-text)', fontSize: 13, fontFamily: 'inherit', resize: 'vertical',
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { useStageArtifacts } from '../api/opportunities';
|
|||
import { opportunitiesKeys } from '../api/opportunities';
|
||||
import api from '../api/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -60,7 +61,10 @@ export default function Stage12CapabilityGaps({ opportunityId, canEdit }: Props)
|
|||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Capability gaps</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={titleStyle}>Capability gaps</h3>
|
||||
<AgentRunCost artifact={latest} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Identifies work outside OLIVER core capability and recommends where to source it
|
||||
(internal SME / Brandtech partner / external vendor).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useStageArtifacts, opportunitiesKeys } from '../api/opportunities';
|
||||
import api from '../api/client';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -52,7 +53,10 @@ export default function Stage13SupportDocs({ opportunityId, canEdit }: Props) {
|
|||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Caveats / SLAs / KPIs / governance</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={titleStyle}>Caveats / SLAs / KPIs / governance</h3>
|
||||
<AgentRunCost artifact={latest} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Generates the support docs that go into the proposal so sales promises match delivery reality.
|
||||
Reads the diagnosis (Stage 2), delivery model (Stage 9), and capability gaps (Stage 12).
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
useStageArtifacts,
|
||||
} from '../api/opportunities';
|
||||
import { IntakeMetadata } from '../types';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -110,9 +111,10 @@ export default function Stage1Intake({ opportunityId, canEdit }: Props) {
|
|||
</section>
|
||||
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<h3 style={sectionTitleStyle}>Intake Agent</h3>
|
||||
{metadata && <span style={pillStyle}>Result on file</span>}
|
||||
<AgentRunCost artifact={latestIntake} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Run Claude over every uploaded document to extract client, region, brands, service types and key dates.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
useClarifications,
|
||||
} from '../api/opportunities';
|
||||
import { BriefDiagnosis, ClarificationQuestion } from '../types';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -48,9 +49,10 @@ export default function Stage2Diagnose({ opportunityId, canEdit }: Props) {
|
|||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<h3 style={sectionTitleStyle}>Diagnosis Agent</h3>
|
||||
{diagnosis && <span style={pillStyle}>Result on file</span>}
|
||||
<AgentRunCost artifact={latest} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Reads every file uploaded at Stage 1 and surfaces a structured diagnosis: deliverables, channels, markets,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
useUpdateAsset,
|
||||
} from '../api/assets';
|
||||
import { ClientAsset } from '../types';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
import { useStageArtifacts } from '../api/opportunities';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -15,6 +17,8 @@ interface Props {
|
|||
|
||||
export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
|
||||
const { data: assets, isLoading } = useClientAssets(opportunityId);
|
||||
const { data: artifacts } = useStageArtifacts(opportunityId, 6);
|
||||
const latestArtifact = artifacts?.find((a) => a.artifact_type === 'normalized_assets');
|
||||
const normalize = useRunNormalize(opportunityId);
|
||||
const create = useCreateAsset(opportunityId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -37,7 +41,10 @@ export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
|
|||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Asset Normalizer</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={titleStyle}>Asset Normalizer</h3>
|
||||
<AgentRunCost artifact={latestArtifact} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Reads the uploaded files (and the Stage 2 diagnosis if present) and produces a clean, priceable
|
||||
deliverables list. Re-running wipes existing assets and downstream matches/ratecard.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useClientAssets, useKickOffMatching, useMatches, useSelectMatch } from '../api/assets';
|
||||
import { useStageArtifacts } from '../api/opportunities';
|
||||
import { ClientAsset, Match } from '../types';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -18,6 +20,8 @@ export default function Stage7Match({ opportunityId, canEdit }: Props) {
|
|||
const { data: assets } = useClientAssets(opportunityId);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const { data: matches, isLoading } = useMatches(opportunityId, polling ? 4 : undefined);
|
||||
const { data: artifacts } = useStageArtifacts(opportunityId, 7);
|
||||
const latestRun = artifacts?.find((a) => a.artifact_type === 'matching_run');
|
||||
const kick = useKickOffMatching(opportunityId);
|
||||
const select = useSelectMatch(opportunityId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -59,7 +63,10 @@ export default function Stage7Match({ opportunityId, canEdit }: Props) {
|
|||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Match assets to GMAL</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={titleStyle}>Match assets to GMAL</h3>
|
||||
<AgentRunCost artifact={latestRun} label="Last run" />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Sends each ClientAsset to Claude with the full GMAL catalog and stores up to 3 candidates per asset
|
||||
(top match auto-selected when score ≥ 0.8). Re-run any time after editing assets.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useBuildRatecard, useRatecard } from '../api/assets';
|
||||
import { useMatches } from '../api/assets';
|
||||
import { useOpportunity } from '../api/opportunities';
|
||||
import { MODEL_TYPE_LABELS, RatecardLine } from '../types';
|
||||
|
||||
|
|
@ -11,9 +12,12 @@ interface Props {
|
|||
export default function Stage8Ratecard({ opportunityId, canEdit }: Props) {
|
||||
const { data: ratecard, isLoading } = useRatecard(opportunityId);
|
||||
const { data: opp } = useOpportunity(opportunityId);
|
||||
const { data: matches } = useMatches(opportunityId);
|
||||
const build = useBuildRatecard(opportunityId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedMatchCount = (matches ?? []).filter((m) => m.is_selected).length;
|
||||
|
||||
const groupedByAsset = useMemo(() => {
|
||||
const map: Record<number, { name: string; volume: number; lines: RatecardLine[]; total: number }> = {};
|
||||
(ratecard?.lines ?? []).forEach((l) => {
|
||||
|
|
@ -73,10 +77,22 @@ export default function Stage8Ratecard({ opportunityId, canEdit }: Props) {
|
|||
|
||||
{!isLoading && (!ratecard || ratecard.lines.length === 0) && (
|
||||
<div style={cardStyle}>
|
||||
<p style={emptyStyle}>
|
||||
No ratecard yet. Make sure each ClientAsset has a <strong>selected match</strong> at Stage 7,
|
||||
then click "Build ratecard" above.
|
||||
</p>
|
||||
{selectedMatchCount > 0 ? (
|
||||
<>
|
||||
<p style={{ ...emptyStyle, color: 'var(--color-text-secondary)' }}>
|
||||
<strong>{selectedMatchCount} selected match{selectedMatchCount === 1 ? '' : 'es'}</strong> from Stage 7
|
||||
ready to build. The ratecard wipes when you re-run normalize/match — click <strong>Build ratecard</strong> above to compute lines from the current selections.
|
||||
</p>
|
||||
<button onClick={run} disabled={!canEdit || build.isPending} style={{ ...primaryBtnStyle, marginTop: 12 }}>
|
||||
{build.isPending ? 'Building…' : `Build ratecard from ${selectedMatchCount} matches`}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={emptyStyle}>
|
||||
No ratecard yet, and no selected matches at Stage 7. Run matching at Stage 7 first, confirm a primary
|
||||
match per asset, then come back and click "Build ratecard".
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useStageArtifacts } from '../api/opportunities';
|
|||
import api from '../api/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { opportunitiesKeys } from '../api/opportunities';
|
||||
import AgentRunCost from './AgentRunCost';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
|
|
@ -61,7 +62,10 @@ export default function Stage9DeliveryModel({ opportunityId, canEdit }: Props) {
|
|||
<section style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div>
|
||||
<h3 style={titleStyle}>Delivery model</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={titleStyle}>Delivery model</h3>
|
||||
<AgentRunCost artifact={latest} />
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Reads the Stage 2 diagnosis and recommends traditional / AI-supported / hybrid, with a
|
||||
per-workflow-stage breakdown and tool caveats. Reqs Stage 2 to have run first.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Stage6Normalize from '../components/Stage6Normalize';
|
|||
import Stage7Match from '../components/Stage7Match';
|
||||
import Stage8Ratecard from '../components/Stage8Ratecard';
|
||||
import Stage9DeliveryModel from '../components/Stage9DeliveryModel';
|
||||
import Stage10Efficiency from '../components/Stage10Efficiency';
|
||||
import Stage11TeamShape from '../components/Stage11TeamShape';
|
||||
import Stage12CapabilityGaps from '../components/Stage12CapabilityGaps';
|
||||
import Stage13SupportDocs from '../components/Stage13SupportDocs';
|
||||
|
|
@ -128,6 +129,10 @@ export default function OpportunityView() {
|
|||
<Stage9DeliveryModel opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
||||
{activeStage === 10 && (
|
||||
<Stage10Efficiency opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
||||
{activeStage === 11 && (
|
||||
<Stage11TeamShape opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
|
@ -140,7 +145,7 @@ export default function OpportunityView() {
|
|||
<Stage13SupportDocs opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
||||
{activeStage > 8 && ![9, 11, 12, 13].includes(activeStage) && !GATED_STAGES.has(activeStage) && (
|
||||
{activeStage > 8 && ![9, 10, 11, 12, 13].includes(activeStage) && !GATED_STAGES.has(activeStage) && (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
This stage isn't built yet — the state machine runs but there's no agent, UI, or artifact persistence
|
||||
specific to it. Advancing here is harmless on a test opportunity.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue