Stage 11: Team Shape (FTE) + 27 new tests for Stages 6/7/8

Stage 11 backend (services/team_shape.py + api/team_shape.py):
- Ports V1's calculate_team_shape with the bug-4 fix already applied:
  total_hours / manual_override on RatecardLine are per-1-asset; the
  aggregator multiplies by line.volume.
- Programme roles never see efficiency cuts. Per-discipline efficiency
  is capped at MAX_EFFICIENCY (90%).
- GET /opportunities/{id}/team-shape supports two modes:
  * `efficiency_pct=N` blanket reduction across delivery roles, OR
  * `discipline_overrides=<JSON>` per-discipline rates (Stage 10
    efficiency profile output).
- Smoke-tested: 5,620.5 hrs / 3.12 FTE on the Versuni opportunity;
  applying efficiency_pct=50 correctly halves to 2,810.25 hrs / 1.56 FTE.

Stage 11 frontend (Stage11TeamShape.tsx + api/teamShape.ts):
- Stats card (total / delivery / programme / adjusted FTE).
- Blanket efficiency slider 0–90% (disabled when per-discipline mode is
  active so the two modes don't fight).
- One slider card per discipline showing live percentage; clearing all
  reverts to the blanket slider.
- Per-discipline FTE table with Total hrs / FTE / Eff% / Adjusted hrs /
  Adjusted FTE columns. Programme roles tagged with a badge.

Stage 6/7/8 backend tests (parallel test agent, +27 tests):
- test_assets.py (10): CRUD + sort_order auto-increment + cascade
  delete + 400/404 paths + skipped real-Claude normalize.
- test_matching.py (10): 400 on no-assets, GET shape + ordering,
  selection toggle deselects siblings, deselect leaves siblings alone,
  cross-opp 404, skipped real-Claude run.
- test_ratecard.py (7): 400/404 paths, end-to-end build+get with the
  bug-4 invariant explicitly asserted (line.base_hours == total_hours;
  summary.total_hours == sum(base × volume)), no-selection skip,
  rebuild idempotence, two-asset volume aggregation splits 1:2.

Suite: 100 collected, 95 passed, 5 skipped (all real-Anthropic), 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-27 14:10:48 -04:00
parent a5a50e1dd3
commit 21aecff0cb
9 changed files with 1486 additions and 2 deletions

View file

@ -0,0 +1,80 @@
"""Stage 11 — team shape (FTE per role) endpoint."""
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.opportunity import Opportunity
from app.services.team_shape import calculate_team_shape, HOURS_PER_FTE
router = APIRouter()
async def _get_opp(db: AsyncSession, opportunity_id: int) -> Opportunity:
result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
opp = result.scalar_one_or_none()
if opp is None:
raise HTTPException(status_code=404, detail=f"Opportunity {opportunity_id} not found")
return opp
@router.get("/{opportunity_id}/team-shape")
async def get_team_shape(
opportunity_id: int,
efficiency_pct: float = 0.0,
discipline_overrides: str = "",
db: AsyncSession = Depends(get_db),
):
"""Compute FTE per role from the ratecard.
Query params:
- `efficiency_pct`: blanket percentage applied to every delivery role
(programme roles excluded).
- `discipline_overrides`: JSON-encoded `{discipline: pct}` map. When
provided, takes precedence over `efficiency_pct`.
Programme roles never see efficiency cuts. Per-discipline efficiency
is capped at MAX_EFFICIENCY (90%).
"""
opp = await _get_opp(db, opportunity_id)
overrides: dict[str, float] | None = None
if discipline_overrides:
try:
parsed = json.loads(discipline_overrides)
if isinstance(parsed, dict):
overrides = {str(k): float(v) for k, v in parsed.items()}
except (json.JSONDecodeError, TypeError, ValueError):
raise HTTPException(status_code=400, detail="discipline_overrides must be a JSON object of {discipline: percent}")
team = await calculate_team_shape(
db, opp,
efficiency_pct=efficiency_pct,
discipline_overrides=overrides,
)
total_hours = sum(t["total_hours"] for t in team)
total_fte = sum(t["fte"] for t in team)
adjusted_hours = sum(t["adjusted_hours"] for t in team)
adjusted_fte = sum(t["adjusted_fte"] for t in team)
programme_fte = sum(t["fte"] for t in team if t["is_programme_role"])
return {
"project_id": opp.id,
"project_name": opp.name,
"hours_per_fte": HOURS_PER_FTE,
"efficiency_pct": efficiency_pct,
"discipline_overrides": overrides,
"total_hours": round(total_hours, 2),
"total_fte": round(total_fte, 4),
"adjusted_hours": round(adjusted_hours, 2),
"adjusted_fte": round(adjusted_fte, 4),
"delivery_fte": round(total_fte - programme_fte, 4),
"programme_fte": round(programme_fte, 4),
"hours_saved": round(total_hours - adjusted_hours, 2),
"fte_saved": round(total_fte - adjusted_fte, 4),
"roles": team,
}

View file

@ -7,7 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
from app.api import health, gmal, ingest, opportunities, approvals, notifications, users, assets, matching, ratecard
from app.api import (
health, gmal, ingest, opportunities, approvals, notifications, users,
assets, matching, ratecard, team_shape,
)
from app.middleware.auth import get_current_user
app = FastAPI(title="OLIVER Sales Ops Platform", version="0.1.0")
@ -34,6 +37,7 @@ app.include_router(opportunities.router, prefix="/api/opportunities", tags=["Opp
app.include_router(assets.router, prefix="/api/opportunities", tags=["Assets"], dependencies=[_auth])
app.include_router(matching.router, prefix="/api/opportunities", tags=["Matching"], dependencies=[_auth])
app.include_router(ratecard.router, prefix="/api/opportunities", tags=["Ratecard"], dependencies=[_auth])
app.include_router(team_shape.router, prefix="/api/opportunities", tags=["TeamShape"], dependencies=[_auth])
app.include_router(approvals.router, prefix="/api", tags=["Approvals"], dependencies=[_auth])
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"], dependencies=[_auth])
app.include_router(users.router, prefix="/api/users", tags=["Users"], dependencies=[_auth])

View file

@ -0,0 +1,111 @@
"""Calculate team shape (FTE headcount) from ratecard data — Stage 11.
Ported from V1 with the bug-4 fix already applied: total_hours / manual_override
on RatecardLine are stored per-1-asset; this aggregator multiplies by volume.
Efficiency is applied per-discipline. Programme roles are never reduced.
The total per-discipline efficiency is capped at MAX_EFFICIENCY (90%) so we
don't accidentally model away a role entirely.
"""
import logging
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import Role
from app.models.opportunity import Opportunity
from app.models.asset import RatecardLine
logger = logging.getLogger(__name__)
HOURS_PER_FTE = 1800
MAX_EFFICIENCY = 90
async def calculate_team_shape(
db: AsyncSession,
opportunity: Opportunity,
efficiency_pct: float = 0,
discipline_overrides: dict[str, float] | None = None,
) -> list[dict]:
"""Compute FTE per role for an opportunity's ratecard.
Two efficiency modes:
1. `discipline_overrides`: explicit `{discipline: pct}` map (Stage 10
efficiency profile produces this).
2. `efficiency_pct`: blanket percentage applied to every delivery role.
Programme roles never see efficiency cuts. Discipline efficiency is
capped at MAX_EFFICIENCY.
"""
lines_result = await db.execute(
select(RatecardLine).where(RatecardLine.opportunity_id == opportunity.id)
)
lines = list(lines_result.scalars().all())
if not lines:
return []
# Aggregate hours per role — multiply by volume (V1 bug-4 fix invariant).
role_hours: dict[int, float] = defaultdict(float)
for line in lines:
per_asset = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0)
role_hours[line.role_id] += per_asset * (line.volume or 1)
role_ids = list(role_hours.keys())
roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
roles = {r.id: r for r in roles_result.scalars().all()}
# Cap each override at MAX_EFFICIENCY
discipline_efficiency: dict[str, float] = {}
if discipline_overrides:
for disc, pct in discipline_overrides.items():
discipline_efficiency[disc] = min(float(pct or 0), MAX_EFFICIENCY)
team: list[dict] = []
for role_id, total_hours in role_hours.items():
role = roles.get(role_id)
if not role or total_hours == 0:
continue
fte = round(total_hours / HOURS_PER_FTE, 4)
if role.is_programme_role:
eff = 0.0
elif discipline_efficiency:
eff = float(discipline_efficiency.get(role.discipline, 0))
elif efficiency_pct > 0:
eff = float(efficiency_pct)
else:
eff = 0.0
if eff > 0:
multiplier = 1 - (eff / 100)
adjusted_hours = round(total_hours * multiplier, 2)
adjusted_fte = round(adjusted_hours / HOURS_PER_FTE, 4)
else:
adjusted_hours = round(total_hours, 2)
adjusted_fte = fte
team.append({
"role_id": role_id,
"role_title": role.role_title,
"discipline": role.discipline,
"is_programme_role": role.is_programme_role,
"sort_order": role.sort_order or 0,
"total_hours": round(total_hours, 2),
"fte": fte,
"efficiency_pct": round(eff, 1),
"adjusted_hours": adjusted_hours,
"adjusted_fte": adjusted_fte,
"hours_saved": round(total_hours - adjusted_hours, 2),
"fte_saved": round(fte - adjusted_fte, 4),
})
team.sort(key=lambda t: (t["discipline"], t["sort_order"]))
total = sum(t["total_hours"] for t in team)
total_fte = sum(t["fte"] for t in team)
logger.info(f"Team shape for opp {opportunity.id}: {len(team)} roles, {total:.0f} hours, {total_fte:.2f} FTE")
return team

View file

@ -0,0 +1,251 @@
"""Stage 6 — Asset Normalizer + ClientAsset CRUD coverage.
The real `/assets/normalize` call hits Anthropic and costs money it lives
behind a `requires_anthropic` skip marker, mirroring the pattern used in
test_intake_agent.py and test_diagnosis.py. The 400 paths fire before any
Claude call so we can exercise them safely.
ClientAsset FK has `ondelete=CASCADE` on `opportunities.id`, so the shared
`opportunity` fixture's teardown also tidies any rows we seed here.
"""
from __future__ import annotations
import httpx
import pytest
CLIENT_ASSET_REQUIRED_KEYS = {
"id",
"opportunity_id",
"raw_name",
"raw_description",
"client_tier",
"volume",
"sort_order",
"created_at",
}
# --------------------------------------------------------------------------- #
# GET /assets — empty
# --------------------------------------------------------------------------- #
async def test_list_assets_empty(client: httpx.AsyncClient, opportunity):
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
assert r.status_code == 200, r.text
assert r.json() == []
# --------------------------------------------------------------------------- #
# POST /assets — sort_order auto-assignment
# --------------------------------------------------------------------------- #
async def test_create_asset_assigns_sort_order(client: httpx.AsyncClient, opportunity):
"""First asset should land at sort_order=0, the next at sort_order=1."""
opp_id = opportunity["id"]
r = await client.post(
f"/opportunities/{opp_id}/assets",
json={
"raw_name": "PDP hero banner",
"raw_description": "Tier-A hero for product detail pages",
"client_tier": "A",
"volume": 12,
},
)
assert r.status_code == 200, r.text
first = r.json()
assert CLIENT_ASSET_REQUIRED_KEYS.issubset(first.keys())
assert first["opportunity_id"] == opp_id
assert first["raw_name"] == "PDP hero banner"
assert first["raw_description"] == "Tier-A hero for product detail pages"
assert first["client_tier"] == "A"
assert first["volume"] == 12
assert first["sort_order"] == 0, (
f"first asset on a fresh opp should be sort_order=0, got {first['sort_order']!r}"
)
r = await client.post(
f"/opportunities/{opp_id}/assets",
json={"raw_name": "Paid social statics", "volume": 60},
)
assert r.status_code == 200, r.text
second = r.json()
assert second["sort_order"] == 1, (
f"second asset should land at sort_order=1, got {second['sort_order']!r}"
)
# default volume + nulls
assert second["volume"] == 60
assert second["raw_description"] is None
assert second["client_tier"] is None
# --------------------------------------------------------------------------- #
# PUT /assets/{id}
# --------------------------------------------------------------------------- #
async def test_update_asset_updates_fields(client: httpx.AsyncClient, opportunity):
opp_id = opportunity["id"]
r = await client.post(
f"/opportunities/{opp_id}/assets",
json={"raw_name": "Original name", "volume": 1},
)
assert r.status_code == 200, r.text
asset_id = r.json()["id"]
r = await client.put(
f"/opportunities/{opp_id}/assets/{asset_id}",
json={
"raw_name": "Updated name",
"raw_description": "Now with a description",
"client_tier": "B",
"volume": 100,
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == asset_id
assert body["raw_name"] == "Updated name"
assert body["raw_description"] == "Now with a description"
assert body["client_tier"] == "B"
assert body["volume"] == 100
# Re-GET should agree.
r = await client.get(f"/opportunities/{opp_id}/assets")
assert r.status_code == 200
[row] = [a for a in r.json() if a["id"] == asset_id]
assert row["raw_name"] == "Updated name"
assert row["raw_description"] == "Now with a description"
assert row["client_tier"] == "B"
assert row["volume"] == 100
# --------------------------------------------------------------------------- #
# DELETE /assets/{id}
# --------------------------------------------------------------------------- #
async def test_delete_asset_removes_it(client: httpx.AsyncClient, opportunity):
opp_id = opportunity["id"]
r = await client.post(
f"/opportunities/{opp_id}/assets",
json={"raw_name": "Doomed asset"},
)
assert r.status_code == 200, r.text
asset_id = r.json()["id"]
r = await client.delete(f"/opportunities/{opp_id}/assets/{asset_id}")
assert r.status_code == 200, r.text
# Re-GET returns []
r = await client.get(f"/opportunities/{opp_id}/assets")
assert r.status_code == 200
assert r.json() == []
# Trying to DELETE again -> 404
r = await client.delete(f"/opportunities/{opp_id}/assets/{asset_id}")
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# Unknown opportunity_id paths -> 404
# --------------------------------------------------------------------------- #
async def test_post_asset_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.post(
"/opportunities/9999999/assets",
json={"raw_name": "Orphan"},
)
assert r.status_code == 404, r.text
async def test_put_asset_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.put(
"/opportunities/9999999/assets/1",
json={"raw_name": "Orphan"},
)
assert r.status_code == 404, r.text
async def test_delete_asset_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.delete("/opportunities/9999999/assets/1")
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# /assets/normalize — error path (no Anthropic call needed)
# --------------------------------------------------------------------------- #
async def test_normalize_no_files_400(client: httpx.AsyncClient, opportunity):
"""Running normalize on an opportunity with zero uploaded files is a 400.
The error should mention files / Stage 1 same wording the
asset_normalizer raises ValueError with.
"""
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
assert r.status_code == 400, r.text
detail = r.json().get("detail", "").lower()
assert "files" in detail or "stage 1" in detail, (
f"detail should mention files / Stage 1, got: {detail!r}"
)
async def test_normalize_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.post("/opportunities/9999999/assets/normalize")
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# Real Anthropic happy path — skipped by default.
# --------------------------------------------------------------------------- #
@pytest.mark.requires_anthropic
@pytest.mark.skip(reason="Real Anthropic call — costs money. Run manually.")
async def test_normalize_happy_path(client: httpx.AsyncClient, opportunity):
"""Round-trip the Asset Normalizer against a small synthetic brief.
Verifies:
- response shape (`assets_seeded`, `artifact_id`, `raw`)
- assets are persisted and retrievable via GET /assets
- re-running clears the prior rows (no duplicates)
"""
body = (
b"Acme Corp 2027 always-on programme. Deliverables: Tier A PDP hero (~12/month), "
b"Tier B paid social statics (~60/month), launch video (1).\n"
)
files = {"files": ("brief.txt", body, "text/plain")}
r = await client.post(f"/opportunities/{opportunity['id']}/files", files=files)
assert r.status_code == 200, r.text
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
assert r.status_code == 200, r.text
payload = r.json()
assert "assets_seeded" in payload
assert "artifact_id" in payload
assert "raw" in payload
seeded = payload["assets_seeded"]
assert isinstance(seeded, int) and seeded >= 1
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
assert r.status_code == 200
first_run = r.json()
assert len(first_run) == seeded
# Re-run wipes and reseeds.
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
assert r.status_code == 200, r.text
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
second_run = r.json()
first_ids = {a["id"] for a in first_run}
second_ids = {a["id"] for a in second_run}
assert first_ids.isdisjoint(second_ids), (
"post-rerun assets should have fresh ids; the old rows should be wiped"
)

View file

@ -0,0 +1,374 @@
"""Stage 7 — AI matching coverage.
The real `/match` call hits Anthropic and costs money; it lives behind a
`requires_anthropic` skip marker, mirroring test_diagnosis.py. We exercise
everything else by seeding ClientAsset + Match rows directly via psycopg2,
the same backdoor test_approvals.py and test_diagnosis.py use.
ClientAsset.matches has cascade='all, delete-orphan' and the FKs cascade
on opportunity delete, so the shared `opportunity` fixture's teardown
also tidies any rows we seed here.
"""
from __future__ import annotations
import os
import httpx
import psycopg2
import pytest
_DB_DSN = os.environ.get(
"OSOP_TEST_DSN",
"host=127.0.0.1 port=5435 dbname=oliver_sales_ops user=osop_user password=osop_pass_2026",
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _seed_client_asset(
opp_id: int,
raw_name: str = "Test asset",
raw_description: str | None = None,
volume: int = 1,
sort_order: int = 0,
client_tier: str | None = None,
) -> int:
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO client_assets
(opportunity_id, raw_name, raw_description, volume, sort_order, client_tier)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(opp_id, raw_name, raw_description, volume, sort_order, client_tier),
)
new_id = cur.fetchone()[0]
conn.commit()
finally:
conn.close()
return new_id
def _seed_match(
client_asset_id: int,
gmal_asset_id: int,
confidence: str = "exact",
confidence_score: float = 0.95,
is_selected: bool = False,
rank: int = 1,
ai_reasoning: str | None = None,
caveat_text: str | None = None,
) -> int:
"""Insert a Match row directly. confidence is the lowercase Python value;
Postgres stores the uppercase enum name."""
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO matches
(client_asset_id, gmal_asset_id, confidence, confidence_score,
is_selected, rank, ai_reasoning, caveat_text)
VALUES (%s, %s, %s::matchconfidence, %s, %s, %s, %s, %s)
RETURNING id
""",
(
client_asset_id,
gmal_asset_id,
confidence.upper(),
confidence_score,
is_selected,
rank,
ai_reasoning,
caveat_text,
),
)
new_id = cur.fetchone()[0]
conn.commit()
finally:
conn.close()
return new_id
def _pick_gmal_with_hour_routes() -> int:
"""Return the lowest-id GMAL asset that has hour routes. The matching
agent only considers `has_hour_routes=True` GMALs anyway."""
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM gmal_assets WHERE has_hour_routes ORDER BY id LIMIT 1"
)
row = cur.fetchone()
finally:
conn.close()
assert row is not None, "expected at least one GMAL with has_hour_routes — is the catalog seeded?"
return row[0]
def _read_match(match_id: int) -> dict:
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id, client_asset_id, gmal_asset_id, is_selected, rank "
"FROM matches WHERE id = %s",
(match_id,),
)
row = cur.fetchone()
finally:
conn.close()
assert row is not None, f"no match row {match_id}"
return {
"id": row[0],
"client_asset_id": row[1],
"gmal_asset_id": row[2],
"is_selected": row[3],
"rank": row[4],
}
MATCH_REQUIRED_KEYS = {
"id",
"client_asset_id",
"gmal_asset_id",
"gmal_id",
"gmal_name",
"gmal_unique_name",
"confidence",
"confidence_score",
"ai_reasoning",
"caveat_text",
"is_selected",
"rank",
"created_at",
}
# --------------------------------------------------------------------------- #
# /match — error paths
# --------------------------------------------------------------------------- #
async def test_match_no_assets_400(client: httpx.AsyncClient, opportunity):
"""Kicking off matching on an opportunity with zero ClientAssets is a 400."""
r = await client.post(f"/opportunities/{opportunity['id']}/match")
assert r.status_code == 400, r.text
detail = r.json().get("detail", "").lower()
assert "stage 6" in detail or "normalize" in detail, (
f"detail should mention Stage 6 / normalize, got: {detail!r}"
)
async def test_match_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.post("/opportunities/9999999/match")
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# GET /matches
# --------------------------------------------------------------------------- #
async def test_list_matches_empty(client: httpx.AsyncClient, opportunity):
r = await client.get(f"/opportunities/{opportunity['id']}/matches")
assert r.status_code == 200, r.text
assert r.json() == []
async def test_list_matches_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.get("/opportunities/9999999/matches")
assert r.status_code == 404, r.text
async def test_list_matches_returns_seeded_rows(
client: httpx.AsyncClient, opportunity
):
"""Seed 1 ClientAsset + 2 Match rows (one selected) and verify GET /matches
surfaces both with the expected join shape (gmal_id, gmal_name populated,
confidence lowercased, is_selected flags preserved). Order: by ClientAsset
sort_order, then Match.rank."""
opp_id = opportunity["id"]
gmal_id = _pick_gmal_with_hour_routes()
ca_id = _seed_client_asset(opp_id, raw_name="Hero PDP", volume=10, sort_order=0)
m1_id = _seed_match(
ca_id,
gmal_id,
confidence="exact",
confidence_score=0.95,
is_selected=True,
rank=1,
ai_reasoning="strong fit",
caveat_text="no major caveats",
)
m2_id = _seed_match(
ca_id,
gmal_id,
confidence="close",
confidence_score=0.72,
is_selected=False,
rank=2,
ai_reasoning="weaker alt",
)
r = await client.get(f"/opportunities/{opp_id}/matches")
assert r.status_code == 200, r.text
rows = r.json()
assert isinstance(rows, list)
assert len(rows) == 2
for row in rows:
assert MATCH_REQUIRED_KEYS.issubset(row.keys())
assert row["client_asset_id"] == ca_id
assert row["gmal_asset_id"] == gmal_id
# gmal_id (the GMAL101-style code) is populated by the join
assert row["gmal_id"] is not None
assert row["gmal_id"].startswith("GMAL"), (
f"gmal_id should look like 'GMAL101', got {row['gmal_id']!r}"
)
assert row["gmal_name"] is not None
# Confidence is the lowercase enum value
assert row["confidence"] in {"exact", "close", "multiple", "none"}
# Order: rank ascending within the asset (sort_order then rank).
assert [r["rank"] for r in rows] == [1, 2]
by_id = {r["id"]: r for r in rows}
assert by_id[m1_id]["is_selected"] is True
assert by_id[m1_id]["confidence"] == "exact"
assert by_id[m1_id]["confidence_score"] == 0.95
assert by_id[m1_id]["ai_reasoning"] == "strong fit"
assert by_id[m2_id]["is_selected"] is False
assert by_id[m2_id]["confidence"] == "close"
# --------------------------------------------------------------------------- #
# PUT /matches/{id}/select
# --------------------------------------------------------------------------- #
async def test_select_match_deselects_siblings(
client: httpx.AsyncClient, opportunity
):
"""is_selected=true on a match should deselect any other selected matches
for the same client_asset (one selection per asset)."""
opp_id = opportunity["id"]
gmal_id = _pick_gmal_with_hour_routes()
ca_id = _seed_client_asset(opp_id, raw_name="Asset A", sort_order=0)
selected_id = _seed_match(ca_id, gmal_id, rank=1, is_selected=True)
other_id = _seed_match(ca_id, gmal_id, rank=2, is_selected=False)
# Flip the rank-2 match to selected; the rank-1 match should auto-deselect.
r = await client.put(
f"/opportunities/{opp_id}/matches/{other_id}/select",
json={"is_selected": True},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == other_id
assert body["is_selected"] is True
assert _read_match(other_id)["is_selected"] is True
assert _read_match(selected_id)["is_selected"] is False, (
"previously-selected sibling should auto-deselect on flip"
)
async def test_deselect_match_does_not_touch_siblings(
client: httpx.AsyncClient, opportunity
):
"""is_selected=false on a match should deselect just that match, leaving
any other selected siblings alone (the route only deselects siblings on
a *positive* flip)."""
opp_id = opportunity["id"]
gmal_id = _pick_gmal_with_hour_routes()
ca_id = _seed_client_asset(opp_id, raw_name="Asset A", sort_order=0)
sel1 = _seed_match(ca_id, gmal_id, rank=1, is_selected=True)
# Pre-seed a second selected sibling — unusual but valid as input state.
sel2 = _seed_match(ca_id, gmal_id, rank=2, is_selected=True)
r = await client.put(
f"/opportunities/{opp_id}/matches/{sel1}/select",
json={"is_selected": False},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == sel1
assert body["is_selected"] is False
assert _read_match(sel1)["is_selected"] is False
assert _read_match(sel2)["is_selected"] is True, (
"deselect should NOT cascade — the other match stays selected"
)
async def test_select_cross_opportunity_404(
client: httpx.AsyncClient, opportunity, opp_payload_factory
):
"""A match belonging to opportunity A cannot be toggled via opportunity B's
URL."""
opp_a_id = opportunity["id"]
gmal_id = _pick_gmal_with_hour_routes()
ca_id = _seed_client_asset(opp_a_id, raw_name="Asset A", sort_order=0)
match_id = _seed_match(ca_id, gmal_id, rank=1, is_selected=False)
# Create a second opportunity, cleaned up explicitly.
payload = opp_payload_factory(name_suffix="(matching-cross-opp)")
create = await client.post("/opportunities", json=payload)
assert create.status_code == 200, create.text
opp_b_id = create.json()["id"]
try:
r = await client.put(
f"/opportunities/{opp_b_id}/matches/{match_id}/select",
json={"is_selected": True},
)
assert r.status_code == 404, r.text
# Sanity: the underlying row is unchanged.
assert _read_match(match_id)["is_selected"] is False
finally:
await client.delete(f"/opportunities/{opp_b_id}")
async def test_select_unknown_match_404(client: httpx.AsyncClient, opportunity):
r = await client.put(
f"/opportunities/{opportunity['id']}/matches/9999999/select",
json={"is_selected": True},
)
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# Real Anthropic happy path — skipped by default.
# --------------------------------------------------------------------------- #
@pytest.mark.requires_anthropic
@pytest.mark.skip(reason="Real Anthropic call — costs money. Run manually.")
async def test_match_happy_path(client: httpx.AsyncClient, opportunity):
"""Round-trip the AI matching agent against a single seeded ClientAsset.
Verifies:
- /match kicks off (200 with detail)
- matches eventually appear via GET /matches
- at least one rank-1 match exists per client asset
"""
opp_id = opportunity["id"]
_seed_client_asset(opp_id, raw_name="Tier-A PDP hero", volume=12, sort_order=0)
r = await client.post(f"/opportunities/{opp_id}/match")
assert r.status_code == 200, r.text
assert "Matching" in r.json().get("detail", "")
# Background task; in a real run we'd poll. Here we just sanity-check
# the kick-off — letting Claude finish in-process would slow the suite.

View file

@ -0,0 +1,387 @@
"""Stage 8 — Ratecard build coverage.
Ratecard building is pure-Python (no Claude), so this is exercised end-to-end.
We seed ClientAsset + Match rows directly via psycopg2 same backdoor used
elsewhere in the harness to drive `/ratecard/build` and `/ratecard`.
The shared `opportunity` fixture creates `model_type=ai_oplus`. The Stage 8
spec works against any model_type that has GmalHours rows; we use
`current_oplus` here, which means we mint our own opportunity rather than
re-using the fixture for the build/get tests.
ClientAsset / RatecardLine FKs cascade on opportunity delete, so the
explicit cleanup in the per-test custom-opp helper handles teardown.
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import date
import httpx
import psycopg2
import pytest
_DB_DSN = os.environ.get(
"OSOP_TEST_DSN",
"host=127.0.0.1 port=5435 dbname=oliver_sales_ops user=osop_user password=osop_pass_2026",
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _seed_client_asset(
opp_id: int,
raw_name: str = "Test asset",
volume: int = 1,
sort_order: int = 0,
) -> int:
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO client_assets
(opportunity_id, raw_name, volume, sort_order)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(opp_id, raw_name, volume, sort_order),
)
new_id = cur.fetchone()[0]
conn.commit()
finally:
conn.close()
return new_id
def _seed_match(
client_asset_id: int,
gmal_asset_id: int,
is_selected: bool = True,
confidence: str = "exact",
confidence_score: float = 0.95,
rank: int = 1,
) -> int:
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO matches
(client_asset_id, gmal_asset_id, confidence, confidence_score,
is_selected, rank)
VALUES (%s, %s, %s::matchconfidence, %s, %s, %s)
RETURNING id
""",
(
client_asset_id,
gmal_asset_id,
confidence.upper(),
confidence_score,
is_selected,
rank,
),
)
new_id = cur.fetchone()[0]
conn.commit()
finally:
conn.close()
return new_id
def _pick_gmal_for_model(model_type: str = "CURRENT_OPLUS", min_rows: int = 2) -> int:
"""Return a GMAL asset id that has at least `min_rows` GmalHours rows for
the given model_type. Stage 8 ratecard build emits one RatecardLine per
GmalHours row, so this controls the line count we expect."""
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT gmal_asset_id, COUNT(*)
FROM gmal_hours
WHERE model_type = %s::modeltype
GROUP BY gmal_asset_id
HAVING COUNT(*) >= %s
ORDER BY gmal_asset_id
LIMIT 1
""",
(model_type, min_rows),
)
row = cur.fetchone()
finally:
conn.close()
assert row is not None, (
f"expected a GMAL with {min_rows}+ {model_type} hour rows — "
"seed your gmal_hours table first"
)
return row[0]
def _count_hour_rows(gmal_asset_id: int, model_type: str = "CURRENT_OPLUS") -> int:
conn = psycopg2.connect(_DB_DSN)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM gmal_hours "
"WHERE gmal_asset_id = %s AND model_type = %s::modeltype",
(gmal_asset_id, model_type),
)
n = cur.fetchone()[0]
finally:
conn.close()
return int(n)
def _opportunity_payload(model_type: str, name_suffix: str = "") -> dict:
return {
"name": f"PYTEST Ratecard {name_suffix}".strip(),
"client_name": "Test Client Ltd",
"region": "EMEA",
"brands": "BrandA, BrandB",
"service_types": "Content Production",
"description": "Ratecard test opportunity.",
"deadline": date(2027, 1, 15).isoformat(),
"go_live": date(2027, 3, 1).isoformat(),
"model_type": model_type,
}
@asynccontextmanager
async def _custom_opp(client: httpx.AsyncClient, model_type: str, suffix: str):
"""Create+yield+delete a fresh opportunity with the given model_type.
The shared `opportunity` fixture is locked to `ai_oplus`; this helper
lets us point the ratecard tests at `current_oplus` (or any other)
without having to mutate the fixture."""
payload = _opportunity_payload(model_type, name_suffix=suffix)
r = await client.post("/opportunities", json=payload)
r.raise_for_status()
opp = r.json()
try:
yield opp
finally:
try:
await client.delete(f"/opportunities/{opp['id']}")
except Exception:
pass
# --------------------------------------------------------------------------- #
# /ratecard/build — error paths
# --------------------------------------------------------------------------- #
async def test_build_no_assets_400(client: httpx.AsyncClient, opportunity):
"""Building a ratecard on an opp with zero ClientAssets is a 400."""
r = await client.post(f"/opportunities/{opportunity['id']}/ratecard/build")
assert r.status_code == 400, r.text
detail = r.json().get("detail", "").lower()
assert "stage 6" in detail or "client assets" in detail, (
f"detail should mention Stage 6 / client assets, got: {detail!r}"
)
async def test_build_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.post("/opportunities/9999999/ratecard/build")
assert r.status_code == 404, r.text
async def test_get_ratecard_unknown_opportunity_404(client: httpx.AsyncClient):
r = await client.get("/opportunities/9999999/ratecard")
assert r.status_code == 404, r.text
# --------------------------------------------------------------------------- #
# Happy path — build + verify per-1-asset hours and volume aggregation
# --------------------------------------------------------------------------- #
async def test_build_and_get_ratecard_happy(client: httpx.AsyncClient):
"""End-to-end Stage 8: seed 1 ClientAsset (volume=100) + selected Match,
POST /build, GET /ratecard, then verify the V1 bug-4 invariant:
`total_hours == base_hours` per row (per-1-asset), and the project-wide
total equals sum(base_hours × volume)."""
async with _custom_opp(client, "current_oplus", suffix="(happy)") as opp:
opp_id = opp["id"]
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
expected_lines = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
assert expected_lines >= 2
ca_id = _seed_client_asset(opp_id, raw_name="Hero PDP", volume=100, sort_order=0)
_seed_match(ca_id, gmal_asset_id, is_selected=True, rank=1)
# Build
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
assert r.status_code == 200, r.text
body = r.json()
assert body["total_lines"] == expected_lines, (
f"build should emit one line per GmalHours row, expected "
f"{expected_lines}, got {body['total_lines']}"
)
assert "built" in body["detail"].lower()
# Read back
r = await client.get(f"/opportunities/{opp_id}/ratecard")
assert r.status_code == 200, r.text
summary = r.json()
assert summary["project_id"] == opp_id
assert summary["model_type"] == "current_oplus"
assert summary["total_assets"] == 1
assert isinstance(summary["lines"], list)
assert len(summary["lines"]) == expected_lines
# Per-line invariants
per_asset_total = 0.0
for line in summary["lines"]:
# Per-1-asset bug-4 invariant: total_hours stores the per-asset
# value (no manual_override), so it equals base_hours.
assert line["base_hours"] is not None
assert line["total_hours"] is not None
assert line["base_hours"] == line["total_hours"], (
f"per-1-asset invariant: base_hours should equal total_hours, "
f"got base={line['base_hours']} total={line['total_hours']}"
)
assert line["volume"] == 100
assert line["manual_override"] is None
assert line["client_asset_id"] == ca_id
assert line["gmal_id"] is not None
assert line["gmal_id"].startswith("GMAL")
assert line["role_title"] is not None
per_asset_total += float(line["base_hours"])
# Project-wide total should be the volume-multiplied sum (V1 bug-4 fix).
expected_total = round(per_asset_total * 100, 2)
assert summary["total_hours"] == expected_total, (
f"summary total should be sum(base_hours × volume) = {expected_total}, "
f"got {summary['total_hours']}"
)
# --------------------------------------------------------------------------- #
# No selected match -> builder skips, returns 0 lines
# --------------------------------------------------------------------------- #
async def test_build_skips_assets_without_selection(client: httpx.AsyncClient):
"""A ClientAsset whose only Match has is_selected=false should be skipped
(the builder logs a warning and continues). The build call itself succeeds
with 0 lines and the GET returns total_assets=0, lines=[]."""
async with _custom_opp(client, "current_oplus", suffix="(no-selection)") as opp:
opp_id = opp["id"]
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
ca_id = _seed_client_asset(opp_id, raw_name="Unselected asset", volume=10, sort_order=0)
_seed_match(ca_id, gmal_asset_id, is_selected=False, rank=1)
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
assert r.status_code == 200, r.text
body = r.json()
assert body["total_lines"] == 0
r = await client.get(f"/opportunities/{opp_id}/ratecard")
assert r.status_code == 200, r.text
summary = r.json()
assert summary["total_assets"] == 0
assert summary["lines"] == []
assert summary["total_hours"] == 0.0
# --------------------------------------------------------------------------- #
# Re-build wipes the prior ratecard
# --------------------------------------------------------------------------- #
async def test_build_twice_wipes_first(client: httpx.AsyncClient):
"""Building twice in a row should leave exactly N lines, not 2N — the
builder wipes prior RatecardLine rows on each call."""
async with _custom_opp(client, "current_oplus", suffix="(rebuild)") as opp:
opp_id = opp["id"]
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
expected_lines = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
ca_id = _seed_client_asset(opp_id, raw_name="Re-build asset", volume=5, sort_order=0)
_seed_match(ca_id, gmal_asset_id, is_selected=True, rank=1)
r1 = await client.post(f"/opportunities/{opp_id}/ratecard/build")
assert r1.status_code == 200
assert r1.json()["total_lines"] == expected_lines
r2 = await client.post(f"/opportunities/{opp_id}/ratecard/build")
assert r2.status_code == 200
assert r2.json()["total_lines"] == expected_lines, (
"re-build should wipe the first ratecard, not append to it"
)
# And the GET should agree.
r = await client.get(f"/opportunities/{opp_id}/ratecard")
assert r.status_code == 200
assert len(r.json()["lines"]) == expected_lines
# --------------------------------------------------------------------------- #
# Two assets — total_hours splits correctly across volumes
# --------------------------------------------------------------------------- #
async def test_build_two_assets_volume_aggregation(client: httpx.AsyncClient):
"""Seed two ClientAssets (volumes 10 and 20), each with a selected match
to the same GMAL. Verify:
- line count is 2 × N (one set per asset)
- summary.total_hours equals sum(line.base_hours × line.volume)
- the project total splits evenly across the two assets in proportion
to their volumes (10 vs 20)."""
async with _custom_opp(client, "current_oplus", suffix="(two-asset)") as opp:
opp_id = opp["id"]
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
per_asset_n = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
ca1 = _seed_client_asset(opp_id, raw_name="Asset One", volume=10, sort_order=0)
ca2 = _seed_client_asset(opp_id, raw_name="Asset Two", volume=20, sort_order=1)
_seed_match(ca1, gmal_asset_id, is_selected=True, rank=1)
_seed_match(ca2, gmal_asset_id, is_selected=True, rank=1)
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
assert r.status_code == 200, r.text
assert r.json()["total_lines"] == 2 * per_asset_n
r = await client.get(f"/opportunities/{opp_id}/ratecard")
assert r.status_code == 200, r.text
summary = r.json()
assert summary["total_assets"] == 2
assert len(summary["lines"]) == 2 * per_asset_n
ca1_lines = [l for l in summary["lines"] if l["client_asset_id"] == ca1]
ca2_lines = [l for l in summary["lines"] if l["client_asset_id"] == ca2]
assert len(ca1_lines) == per_asset_n
assert len(ca2_lines) == per_asset_n
# Volumes propagate onto the line.
assert all(l["volume"] == 10 for l in ca1_lines)
assert all(l["volume"] == 20 for l in ca2_lines)
# Per-1-asset invariant on every line.
for l in summary["lines"]:
assert l["base_hours"] == l["total_hours"]
# Total hours = sum(line.base_hours × line.volume).
expected_total = round(
sum(float(l["base_hours"]) * l["volume"] for l in summary["lines"]),
2,
)
assert summary["total_hours"] == expected_total
# And the asset-2 contribution should be exactly 2× asset-1's
# (same GMAL, same per-1-asset hours, volumes 20 vs 10).
ca1_contrib = sum(float(l["base_hours"]) * l["volume"] for l in ca1_lines)
ca2_contrib = sum(float(l["base_hours"]) * l["volume"] for l in ca2_lines)
assert pytest.approx(ca2_contrib, rel=1e-9) == ca1_contrib * 2, (
f"asset-2 (volume=20) should contribute exactly 2× asset-1 (volume=10), "
f"got {ca2_contrib} vs {ca1_contrib}"
)

View file

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import api from './client';
export interface TeamShapeRole {
role_id: number;
role_title: string;
discipline: string;
is_programme_role: boolean;
sort_order: number;
total_hours: number;
fte: number;
efficiency_pct: number;
adjusted_hours: number;
adjusted_fte: number;
hours_saved: number;
fte_saved: number;
}
export interface TeamShape {
project_id: number;
project_name: string;
hours_per_fte: number;
efficiency_pct: number;
discipline_overrides: Record<string, number> | null;
total_hours: number;
total_fte: number;
adjusted_hours: number;
adjusted_fte: number;
delivery_fte: number;
programme_fte: number;
hours_saved: number;
fte_saved: number;
roles: TeamShapeRole[];
}
export function useTeamShape(
opportunityId: number | undefined,
efficiencyPct = 0,
disciplineOverrides?: Record<string, number>,
) {
const overridesJson = disciplineOverrides ? JSON.stringify(disciplineOverrides) : '';
return useQuery({
queryKey: ['team-shape', opportunityId ?? 0, efficiencyPct, overridesJson],
queryFn: async (): Promise<TeamShape> => {
const res = await api.get(`/opportunities/${opportunityId}/team-shape`, {
params: {
efficiency_pct: efficiencyPct,
discipline_overrides: overridesJson,
},
});
return res.data;
},
enabled: opportunityId !== undefined && opportunityId > 0,
});
}

View file

@ -0,0 +1,217 @@
import { useMemo, useState } from 'react';
import { useTeamShape, TeamShapeRole } from '../api/teamShape';
interface Props {
opportunityId: number;
canEdit: boolean;
}
const FTE_HOURS = 1800;
export default function Stage11TeamShape({ opportunityId }: Props) {
const [blanketEff, setBlanketEff] = useState<number>(0);
const [overrides, setOverrides] = useState<Record<string, number>>({});
const useOverrides = Object.keys(overrides).length > 0;
const { data, isLoading } = useTeamShape(
opportunityId,
useOverrides ? 0 : blanketEff,
useOverrides ? overrides : undefined,
);
const grouped = useMemo(() => {
const map: Record<string, TeamShapeRole[]> = {};
(data?.roles ?? []).forEach((r) => {
if (!map[r.discipline]) map[r.discipline] = [];
map[r.discipline].push(r);
});
return map;
}, [data]);
const disciplines = Object.keys(grouped).sort();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<section style={cardStyle}>
<h3 style={titleStyle}>Team shape (FTE)</h3>
<p style={hintStyle}>
Aggregates ratecard hours per role and divides by {FTE_HOURS} hours/year. Programme roles never see
efficiency cuts. Per-discipline override is capped at 90%.
</p>
{!isLoading && data && (
<div style={statsRowStyle}>
<Stat label="Total hours" value={data.total_hours.toLocaleString()} fg="#FFC407" />
<Stat label="Total FTE" value={data.total_fte.toFixed(2)} fg="#FFC407" />
<Stat label="Delivery FTE" value={data.delivery_fte.toFixed(2)} />
<Stat label="Programme FTE" value={data.programme_fte.toFixed(2)} />
{(data.hours_saved > 0 || data.fte_saved > 0) && (
<>
<Stat label="Adjusted hrs" value={data.adjusted_hours.toLocaleString()} fg="#86efac" />
<Stat label="Adjusted FTE" value={data.adjusted_fte.toFixed(2)} fg="#86efac" />
<Stat label="Saved" value={`${data.hours_saved.toFixed(0)} hrs`} fg="#86efac" />
</>
)}
</div>
)}
</section>
<section style={cardStyle}>
<h3 style={titleStyle}>Efficiency controls</h3>
<p style={hintStyle}>
Apply a blanket percentage across delivery roles, OR set per-discipline overrides
(Stage 10 efficiency profile). The per-discipline mode takes precedence.
</p>
<div style={{ marginTop: 12 }}>
<label style={labelStyle}>
<span style={metaLabelStyle}>Blanket efficiency: {blanketEff}%</span>
<input
type="range" min={0} max={90} step={5}
value={blanketEff}
onChange={(e) => setBlanketEff(parseInt(e.target.value, 10))}
disabled={useOverrides}
style={sliderStyle}
/>
</label>
</div>
<div style={{ marginTop: 18, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={metaLabelStyle}>Per-discipline overrides</span>
{useOverrides && (
<button onClick={() => setOverrides({})} style={ghostBtnStyle}>Clear all</button>
)}
</div>
{disciplines.length === 0 && (
<p style={emptyStyle}>No roles in the ratecard yet build it at Stage 8.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, marginTop: 6 }}>
{disciplines.map((disc) => (
<DisciplineOverride
key={disc}
discipline={disc}
value={overrides[disc] ?? 0}
onChange={(pct) => {
if (pct === 0) {
const next = { ...overrides };
delete next[disc];
setOverrides(next);
} else {
setOverrides({ ...overrides, [disc]: pct });
}
}}
/>
))}
</div>
</section>
{isLoading && <div style={cardStyle}><p style={emptyStyle}>Loading team shape</p></div>}
{!isLoading && data && data.roles.length === 0 && (
<div style={cardStyle}>
<p style={emptyStyle}>No team shape build the ratecard at Stage 8 first.</p>
</div>
)}
{!isLoading && data && data.roles.length > 0 && disciplines.map((disc) => (
<section key={disc} style={cardStyle}>
<h4 style={{ margin: 0, marginBottom: 8, fontSize: 13, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{disc}
</h4>
<div style={tableWrapStyle}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Role</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Total hrs</th>
<th style={{ ...thStyle, textAlign: 'right' }}>FTE</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Eff %</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Adj hrs</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Adj FTE</th>
</tr>
</thead>
<tbody>
{grouped[disc].map((r) => (
<tr key={r.role_id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={tdStyle}>
{r.role_title}
{r.is_programme_role && <span style={progBadgeStyle}>PROGRAMME</span>}
</td>
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace' }}>{r.total_hours.toFixed(2)}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace' }}>{r.fte.toFixed(3)}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', color: r.efficiency_pct > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>{r.efficiency_pct}%</td>
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', color: '#86efac' }}>{r.adjusted_hours.toFixed(2)}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', fontWeight: 600 }}>{r.adjusted_fte.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
);
}
function DisciplineOverride({ discipline, value, onChange }: { discipline: string; value: number; onChange: (pct: number) => void }) {
return (
<div style={{ background: 'var(--color-bg-input)', border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600 }}>{discipline}</span>
<span style={{ fontFamily: 'ui-monospace, monospace', color: value > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>
{value}%
</span>
</div>
<input
type="range" min={0} max={90} step={5}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={sliderStyle}
/>
</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 hintStyle: React.CSSProperties = { marginTop: 4, marginBottom: 12, color: 'var(--color-text-muted)', fontSize: 12 };
const statsRowStyle: React.CSSProperties = { display: 'flex', gap: 28, padding: '8px 4px', flexWrap: 'wrap' };
const metaLabelStyle: React.CSSProperties = {
fontSize: 11, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600,
};
const labelStyle: React.CSSProperties = { display: 'block' };
const sliderStyle: React.CSSProperties = { width: '100%', accentColor: 'var(--color-accent)', marginTop: 4 };
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 emptyStyle: React.CSSProperties = { color: 'var(--color-text-muted)', fontSize: 13 };
const tableWrapStyle: React.CSSProperties = { overflowX: 'auto' };
const tableStyle: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', fontSize: 13 };
const thStyle: React.CSSProperties = {
padding: '8px 10px', textAlign: 'left',
background: 'rgba(255,255,255,0.03)',
borderBottom: '1px solid var(--color-border)',
fontWeight: 600, fontSize: 11,
color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em',
};
const tdStyle: React.CSSProperties = { padding: '8px 10px', verticalAlign: 'top', color: 'var(--color-text-secondary)' };
const progBadgeStyle: React.CSSProperties = {
marginLeft: 8, fontSize: 9, fontWeight: 700, letterSpacing: '0.05em',
padding: '2px 6px', borderRadius: 8,
background: 'rgba(255,196,7,0.10)', color: '#FFC407',
};

View file

@ -10,6 +10,7 @@ import Stage5IngestAnswers from '../components/Stage5IngestAnswers';
import Stage6Normalize from '../components/Stage6Normalize';
import Stage7Match from '../components/Stage7Match';
import Stage8Ratecard from '../components/Stage8Ratecard';
import Stage11TeamShape from '../components/Stage11TeamShape';
import StageApprovals from '../components/StageApprovals';
export default function OpportunityView() {
@ -120,7 +121,11 @@ export default function OpportunityView() {
<Stage8Ratecard opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage > 8 && !GATED_STAGES.has(activeStage) && (
{activeStage === 11 && (
<Stage11TeamShape opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
)}
{activeStage > 8 && activeStage !== 11 && !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.