diff --git a/backend/app/api/team_shape.py b/backend/app/api/team_shape.py new file mode 100644 index 0000000..e9a761b --- /dev/null +++ b/backend/app/api/team_shape.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index 2bcb21e..415894f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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]) diff --git a/backend/app/services/team_shape.py b/backend/app/services/team_shape.py new file mode 100644 index 0000000..45ada42 --- /dev/null +++ b/backend/app/services/team_shape.py @@ -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 diff --git a/backend/tests/test_assets.py b/backend/tests/test_assets.py new file mode 100644 index 0000000..0be0eb5 --- /dev/null +++ b/backend/tests/test_assets.py @@ -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" + ) diff --git a/backend/tests/test_matching.py b/backend/tests/test_matching.py new file mode 100644 index 0000000..fa0cc52 --- /dev/null +++ b/backend/tests/test_matching.py @@ -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. diff --git a/backend/tests/test_ratecard.py b/backend/tests/test_ratecard.py new file mode 100644 index 0000000..3ba29e5 --- /dev/null +++ b/backend/tests/test_ratecard.py @@ -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}" + ) diff --git a/frontend/src/api/teamShape.ts b/frontend/src/api/teamShape.ts new file mode 100644 index 0000000..5b6f04d --- /dev/null +++ b/frontend/src/api/teamShape.ts @@ -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 | 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, +) { + const overridesJson = disciplineOverrides ? JSON.stringify(disciplineOverrides) : ''; + return useQuery({ + queryKey: ['team-shape', opportunityId ?? 0, efficiencyPct, overridesJson], + queryFn: async (): Promise => { + const res = await api.get(`/opportunities/${opportunityId}/team-shape`, { + params: { + efficiency_pct: efficiencyPct, + discipline_overrides: overridesJson, + }, + }); + return res.data; + }, + enabled: opportunityId !== undefined && opportunityId > 0, + }); +} diff --git a/frontend/src/components/Stage11TeamShape.tsx b/frontend/src/components/Stage11TeamShape.tsx new file mode 100644 index 0000000..749527d --- /dev/null +++ b/frontend/src/components/Stage11TeamShape.tsx @@ -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(0); + const [overrides, setOverrides] = useState>({}); + const useOverrides = Object.keys(overrides).length > 0; + + const { data, isLoading } = useTeamShape( + opportunityId, + useOverrides ? 0 : blanketEff, + useOverrides ? overrides : undefined, + ); + + const grouped = useMemo(() => { + const map: Record = {}; + (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 ( +
+
+

Team shape (FTE)

+

+ 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%. +

+ + {!isLoading && data && ( +
+ + + + + {(data.hours_saved > 0 || data.fte_saved > 0) && ( + <> + + + + + )} +
+ )} +
+ +
+

Efficiency controls

+

+ Apply a blanket percentage across delivery roles, OR set per-discipline overrides + (Stage 10 efficiency profile). The per-discipline mode takes precedence. +

+ +
+ +
+ +
+ Per-discipline overrides + {useOverrides && ( + + )} +
+ {disciplines.length === 0 && ( +

No roles in the ratecard yet — build it at Stage 8.

+ )} +
+ {disciplines.map((disc) => ( + { + if (pct === 0) { + const next = { ...overrides }; + delete next[disc]; + setOverrides(next); + } else { + setOverrides({ ...overrides, [disc]: pct }); + } + }} + /> + ))} +
+
+ + {isLoading &&

Loading team shape…

} + + {!isLoading && data && data.roles.length === 0 && ( +
+

No team shape — build the ratecard at Stage 8 first.

+
+ )} + + {!isLoading && data && data.roles.length > 0 && disciplines.map((disc) => ( +
+

+ {disc} +

+
+ + + + + + + + + + + + + {grouped[disc].map((r) => ( + + + + + + + + + ))} + +
RoleTotal hrsFTEEff %Adj hrsAdj FTE
+ {r.role_title} + {r.is_programme_role && PROGRAMME} + {r.total_hours.toFixed(2)}{r.fte.toFixed(3)} 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>{r.efficiency_pct}%{r.adjusted_hours.toFixed(2)}{r.adjusted_fte.toFixed(3)}
+
+
+ ))} +
+ ); +} + +function DisciplineOverride({ discipline, value, onChange }: { discipline: string; value: number; onChange: (pct: number) => void }) { + return ( +
+
+ {discipline} + 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}> + {value}% + +
+ onChange(parseInt(e.target.value, 10))} + style={sliderStyle} + /> +
+ ); +} + +function Stat({ label, value, fg }: { label: string; value: number | string; fg?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +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', +}; diff --git a/frontend/src/pages/OpportunityView.tsx b/frontend/src/pages/OpportunityView.tsx index 0563635..0a24b93 100644 --- a/frontend/src/pages/OpportunityView.tsx +++ b/frontend/src/pages/OpportunityView.tsx @@ -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() { )} - {activeStage > 8 && !GATED_STAGES.has(activeStage) && ( + {activeStage === 11 && ( + + )} + + {activeStage > 8 && activeStage !== 11 && !GATED_STAGES.has(activeStage) && (
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.