fix: NV review round 1 — pipeline blockers + UX feedback + hidden bug hardening

Phase 1 (pipeline blockers):
- Stage 6 normalizer: move Claude call to BackgroundTasks (fixes 504 at proxy);
  frontend polls useStageArtifacts(6) + useClientAssets every 2s with a
  2-min soft cap and a "Normalizing… ~30s" banner.
- Stage 8 ratecard: raise ValueError when no Stage-7 matches selected so
  the user gets a clear 400 instead of silent "0 lines built" success.
- Stage 4 Q&A pack: visible amber empty-state callout when no clarifications.
- Stage 1 intake: green CTA banner after metadata lands telling the user
  to scroll down and click Complete Stage 1.
- APP_PUBLIC_URL: log a warning at startup if empty / not fully-qualified
  so approval-email links don't ship as broken relative URLs.

Phase 2 (reviewer UX):
- Remove "Operating model" select from intake form (sales lead doesn't
  know the solution yet); default model_type='current_oplus'.
- Inline-edit pencil for opportunity name in OpportunityView header.
- Stage 3 TROWLS sliders default to 0/10 (was 5/10 — anchored everyone
  to "average" and the reviewer could save without engaging).
- Trim APPROVAL_ROLES to ['commercial', 'solution'] (was 5 roles).
- Stage 6 confirm dialog only fires on re-run, not first run.
- "+ Add manually" → "+ Add deliverable manually" with helper text.
- Asset normalizer prompt + post-hoc stop-list filter excluding internal
  pitch artefacts (pitch decks, response decks, win-themes, etc.) that
  were appearing as job routes in Stage 7.

Phase 3 (hardening):
- with_for_update() row locks on stage_machine.complete_stage and
  approvals.submit_decision so double-clicks can't double-advance.
- 30s idempotency window on Stage 7 matching kick-off.

Deferred (next round): paste-link upload, single-use approval tokens,
FK indexes migration, datetime.utcnow → now(timezone.utc) sweep,
notes-owner schema change, file-extraction "unsearchable" UI badge.

Source: REVIEW-SESSIONS/Sales Op Platform Feedback NV_ 060526.xlsx
(rows R6-R48, 25+ feedback items mapped to specific stages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-08 22:30:48 -04:00
parent 9b00028ca9
commit 06110d6d71
16 changed files with 400 additions and 85 deletions

View file

@ -158,15 +158,19 @@ async def submit_decision(
current=Depends(get_current_user),
):
user_id = current.get("user_id")
result = await db.execute(
select(Approval, AppUser)
.outerjoin(AppUser, Approval.approver_user_id == AppUser.id)
.where(Approval.id == approval_id)
# Lock the approval row for the transaction so concurrent decision posts
# (e.g. approver clicks Approve twice quickly) can't both pass the
# PENDING check inside record_decision and create conflicting state.
locked = await db.execute(
select(Approval).where(Approval.id == approval_id).with_for_update()
)
row = result.first()
if row is None:
approval = locked.scalar_one_or_none()
if approval is None:
raise HTTPException(status_code=404, detail=f"Approval {approval_id} not found")
approval, approver = row
approver_result = await db.execute(
select(AppUser).where(AppUser.id == approval.approver_user_id)
)
approver = approver_result.scalar_one_or_none()
# Only the assigned approver (or admin) can decide
if approval.approver_user_id and approval.approver_user_id != user_id and current.get("role") != "admin":

View file

@ -2,18 +2,19 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.database import async_session, get_db
from app.models.file import OpportunityFile
from app.models.opportunity import Opportunity
from app.models.asset import ClientAsset
from app.models.stage import StageArtifact
from app.schemas.asset import (
ClientAssetCreate,
ClientAssetOut,
ClientAssetUpdate,
NormalizeRunResponse,
)
from app.services.asset_normalizer import run_normalize
@ -114,46 +115,59 @@ async def delete_asset(opportunity_id: int, asset_id: int, db: AsyncSession = De
return {"detail": f"Asset {asset_id} deleted"}
@router.post("/{opportunity_id}/assets/normalize", response_model=NormalizeRunResponse)
async def run_normalizer(opportunity_id: int, db: AsyncSession = Depends(get_db)):
"""Re-run the Stage 6 Asset Normalizer.
async def _run_normalize_in_bg(opportunity_id: int) -> None:
"""Background task: open a fresh session, run the normalizer.
Wipes existing ClientAssets (and cascades to matches + ratecard) for
the opportunity, then inserts the freshly normalized list.
Mirrors the pattern used by Stage 7 matching (`_run_match_in_bg`). The
Claude call inside `run_normalize` regularly takes 2040s, which exceeds
most proxy/load-balancer idle timeouts (Apache default 30s), so we run
it out-of-band rather than blocking the request.
"""
async with async_session() as db:
opp_result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
opp = opp_result.scalar_one_or_none()
if opp is None:
logger.warning(f"BG normalize: opportunity {opportunity_id} not found")
return
try:
await run_normalize(db, opp)
await db.commit()
except Exception as e:
logger.error(f"BG normalize failed for {opportunity_id}: {e}", exc_info=True)
await db.rollback()
@router.post("/{opportunity_id}/assets/normalize", status_code=202)
async def run_normalizer(
opportunity_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Kick off the Stage 6 Asset Normalizer in the background.
Returns 202 immediately. The frontend polls
`GET /opportunities/{id}/stages/6/artifacts` and `GET /assets` to detect
completion (a new `normalized_assets` artifact appears, then the
`client_assets` list refreshes). Re-running wipes existing ClientAssets
(cascade to matches + ratecard).
"""
opp = await _get_opp(db, opportunity_id)
try:
result = await run_normalize(db, opp)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Normalize failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Normalize failed: {e}")
await db.commit()
# Count seeded
count_result = await db.execute(
select(ClientAsset).where(ClientAsset.opportunity_id == opportunity_id)
)
seeded = len(list(count_result.scalars().all()))
# Find latest artifact
from app.models.stage import StageArtifact
art_result = await db.execute(
select(StageArtifact)
# Cheap guardrail: at least one file with extractable text must exist —
# otherwise the bg task will fail silently and the user sees nothing.
file_check = await db.execute(
select(OpportunityFile.id)
.where(
StageArtifact.opportunity_id == opportunity_id,
StageArtifact.stage_number == 6,
StageArtifact.artifact_type == "normalized_assets",
OpportunityFile.opportunity_id == opp.id,
OpportunityFile.text_char_count > 0,
)
.order_by(StageArtifact.created_at.desc())
.limit(1)
)
artifact = art_result.scalar_one_or_none()
if file_check.scalar_one_or_none() is None:
raise HTTPException(
status_code=400,
detail="No files with extractable text — upload an RFP/brief at Stage 1 first.",
)
return NormalizeRunResponse(
assets_seeded=seeded,
artifact_id=artifact.id if artifact else 0,
raw=result,
)
background_tasks.add_task(_run_normalize_in_bg, opportunity_id)
return {"detail": "Normalize started", "opportunity_id": opportunity_id}

View file

@ -1,6 +1,7 @@
"""Stage 7 — match client assets to GMAL catalog."""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
@ -16,6 +17,11 @@ from app.services.ai_matching import match_opportunity_assets
router = APIRouter()
logger = logging.getLogger(__name__)
# Idempotency window — second matching call within this many seconds returns
# 409 instead of spawning a duplicate background task that would race on the
# same client_assets rows.
MATCH_IDEMPOTENCY_SECONDS = 30
async def _get_opp(db: AsyncSession, opportunity_id: int) -> Opportunity:
result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
@ -73,6 +79,28 @@ async def kick_off_match(
)
if asset_result.scalar_one_or_none() is None:
raise HTTPException(status_code=400, detail="No client assets — run Stage 6 (normalize) first.")
# Idempotency: a Match row created in the last MATCH_IDEMPOTENCY_SECONDS
# for this opportunity means the agent ran (or is running) very recently.
# Returning 409 stops the user double-clicking and racing two bg tasks
# against the same data. Match.created_at is stored as a naive UTC
# datetime (see app/models/asset.py), so the cutoff is naive too.
cutoff = datetime.utcnow() - timedelta(seconds=MATCH_IDEMPOTENCY_SECONDS)
recent_match_result = await db.execute(
select(Match)
.join(ClientAsset, Match.client_asset_id == ClientAsset.id)
.where(
ClientAsset.opportunity_id == opp.id,
Match.created_at >= cutoff,
)
.limit(1)
)
if recent_match_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Matching already ran within the last {MATCH_IDEMPOTENCY_SECONDS}s. Wait a moment, then refresh — results should appear shortly.",
)
background_tasks.add_task(_run_match_in_bg, opportunity_id)
return {"detail": "Matching started", "opportunity_id": opportunity_id}

View file

@ -1,5 +1,9 @@
import logging
from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://osop_user:osop_pass_2026@db:5432/oliver_sales_ops"
@ -26,3 +30,28 @@ class Settings(BaseSettings):
settings = Settings()
# Surface a clear log line at startup if the public URL isn't a fully-qualified
# https/http URL — this is the most common reason approval emails arrive with
# unclickable "Open approval page" links (mail clients refuse to render
# relative hrefs).
def _validate_public_url() -> None:
url = (settings.app_public_url or "").strip()
if not url:
logger.warning(
"APP_PUBLIC_URL is empty — approval-request emails will contain "
"broken (relative) links. Set APP_PUBLIC_URL to e.g. "
"https://optical-dev.oliver.solutions in your .env."
)
return
if not (url.startswith("http://") or url.startswith("https://")):
logger.warning(
"APP_PUBLIC_URL=%r does not start with http:// or https:// — "
"mail clients will not render the approval link as clickable. "
"Use a fully-qualified URL.",
url,
)
_validate_public_url()

View file

@ -58,10 +58,39 @@ SYSTEM_PROMPT = (
"- Capture tier letters / bands when the brief uses them. Leave client_tier blank if it doesn't.\n"
"- Volume is integer. If the brief says ranges ('100-200'), pick the midpoint. If TBC, set 1 "
"and put 'Volume TBC — confirm with client' in the description.\n"
"- Do NOT invent assets the brief doesn't mention. Be exhaustive but honest."
"- Do NOT invent assets the brief doesn't mention. Be exhaustive but honest.\n"
"- DO NOT include internal pitch artefacts: pitch decks, response decks/docs, written response "
"documents, win-theme narratives, competitive analyses, commercial models, solution design "
"docs, case study videos used to support the pitch, workflow build demos, pitch theatre, or "
"anything else the AGENCY produces in order to win the work. These are not what the CLIENT "
"is buying — exclude them entirely.\n"
"- Only extract assets the CLIENT will receive as final deliverables under the engagement."
)
# Post-hoc stop-list for safety. Any asset whose name matches one of these
# patterns is dropped before insert. Exists because the model occasionally
# slips internal pitch artefacts in despite the prompt above.
_INTERNAL_ARTEFACT_PATTERNS = (
"pitch film", "pitch theatre", "pitch deck",
"case study video", "case-study video",
"workflow build demo", "workflow build-demo",
"final response deck", "response deck", "response document", "response doc",
"written response", "win theme", "win-theme",
"competitive analysis", "competitor analysis",
"commercial model",
"solution design doc", "solution-design doc", "solution design document",
)
def _is_internal_artefact(name: str) -> bool:
"""True if `name` looks like an internal pitch artefact, not a client deliverable."""
if not name:
return False
lowered = name.lower().strip()
return any(p in lowered for p in _INTERNAL_ARTEFACT_PATTERNS)
async def run_normalize(db: AsyncSession, opportunity: Opportunity) -> dict:
"""Run the Asset Normalizer. Replaces existing ClientAssets on success.
@ -155,10 +184,17 @@ async def run_normalize(db: AsyncSession, opportunity: Opportunity) -> dict:
# Insert fresh
seeded = 0
skipped_internal = 0
for i, a in enumerate(result.get("assets", []) or []):
name = (a.get("raw_name") or "").strip()
if not name:
continue
if _is_internal_artefact(name):
logger.warning(
f"Normalizer: dropping internal pitch artefact '{name}' for opportunity {opportunity.id}"
)
skipped_internal += 1
continue
ca = ClientAsset(
opportunity_id=opportunity.id,
raw_name=name[:500],
@ -171,5 +207,8 @@ async def run_normalize(db: AsyncSession, opportunity: Opportunity) -> dict:
seeded += 1
await db.flush()
logger.info(f"Normalizer: seeded {seeded} client assets for opportunity {opportunity.id}")
logger.info(
f"Normalizer: seeded {seeded} client assets for opportunity {opportunity.id}"
+ (f" (dropped {skipped_internal} internal artefacts)" if skipped_internal else "")
)
return result

View file

@ -43,6 +43,21 @@ async def build_ratecard(db: AsyncSession, opportunity: Opportunity) -> list[Rat
if not client_assets:
raise ValueError("No client assets — run Stage 6 first.")
# Guardrail: at least one selected Match across all assets, otherwise the
# build silently produces zero lines and the user sees nothing happen.
selected_count_result = await db.execute(
select(Match)
.join(ClientAsset, Match.client_asset_id == ClientAsset.id)
.where(
ClientAsset.opportunity_id == opportunity.id,
Match.is_selected == True,
)
)
if not selected_count_result.scalars().first():
raise ValueError(
"No matches selected — pick a GMAL match for at least one asset in Stage 7 first."
)
lines: list[RatecardLine] = []
for ca in client_assets:
match_result = await db.execute(

View file

@ -71,7 +71,18 @@ async def complete_stage(
if stage_number < 1 or stage_number > TOTAL_STAGES:
raise ValueError(f"Stage number must be 1-{TOTAL_STAGES}")
stage = await get_stage(db, opportunity.id, stage_number)
# Lock the row for the duration of the transaction so concurrent
# complete-stage calls (e.g. user double-clicks the button) can't both
# race past the COMPLETED check below and double-advance the pipeline.
locked = await db.execute(
select(StageState)
.where(
StageState.opportunity_id == opportunity.id,
StageState.stage_number == stage_number,
)
.with_for_update()
)
stage = locked.scalar_one_or_none()
if stage is None:
raise ValueError(f"Stage {stage_number} not found for opportunity {opportunity.id}")

View file

@ -9,7 +9,7 @@ export const assetsKeys = {
ratecard: (oppId: number) => ['ratecard', oppId] as const,
};
export function useClientAssets(opportunityId: number | undefined) {
export function useClientAssets(opportunityId: number | undefined, refetchSeconds?: number) {
return useQuery({
queryKey: assetsKeys.list(opportunityId ?? 0),
queryFn: async (): Promise<ClientAsset[]> => {
@ -17,6 +17,7 @@ export function useClientAssets(opportunityId: number | undefined) {
return res.data;
},
enabled: opportunityId !== undefined && opportunityId > 0,
refetchInterval: refetchSeconds ? refetchSeconds * 1000 : false,
});
}
@ -61,18 +62,15 @@ export function useDeleteAsset(opportunityId: number) {
}
export function useRunNormalize(opportunityId: number) {
const qc = useQueryClient();
// The endpoint now returns 202 immediately and runs the agent in a
// background task — see backend/app/api/assets.py:_run_normalize_in_bg.
// The component polls useStageArtifacts(6) + useClientAssets to detect
// completion (a new normalized_assets artifact appears).
return useMutation({
mutationFn: async (): Promise<{ assets_seeded: number; artifact_id: number }> => {
mutationFn: async (): Promise<{ detail: string; opportunity_id: number }> => {
const res = await api.post(`/opportunities/${opportunityId}/assets/normalize`);
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: assetsKeys.list(opportunityId) });
qc.invalidateQueries({ queryKey: assetsKeys.matches(opportunityId) });
qc.invalidateQueries({ queryKey: assetsKeys.ratecard(opportunityId) });
qc.invalidateQueries({ queryKey: opportunitiesKeys.detail(opportunityId) });
},
});
}

View file

@ -82,6 +82,22 @@ export function useDeleteOpportunity() {
});
}
export function useUpdateOpportunity(opportunityId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (
payload: Partial<Pick<Opportunity, 'name' | 'client_name' | 'region' | 'brands' | 'service_types' | 'description'>>,
): Promise<Opportunity> => {
const res = await api.put(`/opportunities/${opportunityId}`, payload);
return res.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: opportunitiesKeys.detail(opportunityId) });
qc.invalidateQueries({ queryKey: opportunitiesKeys.list() });
},
});
}
export function useCompleteStage(opportunityId: number) {
const qc = useQueryClient();
return useMutation({
@ -140,7 +156,11 @@ export function useDeleteFile(opportunityId: number) {
});
}
export function useStageArtifacts(opportunityId: number | undefined, stageNumber: number) {
export function useStageArtifacts(
opportunityId: number | undefined,
stageNumber: number,
refetchSeconds?: number,
) {
return useQuery({
queryKey: opportunitiesKeys.artifacts(opportunityId ?? 0, stageNumber),
queryFn: async (): Promise<StageArtifact[]> => {
@ -148,6 +168,7 @@ export function useStageArtifacts(opportunityId: number | undefined, stageNumber
return res.data;
},
enabled: opportunityId !== undefined && opportunityId > 0,
refetchInterval: refetchSeconds ? refetchSeconds * 1000 : false,
});
}

View file

@ -154,6 +154,15 @@ export default function Stage1Intake({ opportunityId, canEdit }: Props) {
)}
</div>
)}
{metadata && canEdit && (
<div style={advancePromptStyle}>
<strong style={{ color: '#86efac' }}> Intake done.</strong>{' '}
<span style={{ color: 'var(--color-text-secondary)' }}>
Review the metadata above, then scroll to the bottom of the page and click <strong>Complete Stage 1</strong> to advance to Stage 2 (Read &amp; Diagnose Brief).
</span>
</div>
)}
</section>
</div>
);
@ -220,3 +229,8 @@ const pillStyle: React.CSSProperties = {
padding: '3px 8px', borderRadius: 12,
background: 'rgba(46,125,50,0.12)', color: '#86efac',
};
const advancePromptStyle: React.CSSProperties = {
marginTop: 14, padding: '10px 14px', borderRadius: 8,
background: 'rgba(46,125,50,0.10)', border: '1px solid rgba(46,125,50,0.4)',
fontSize: 13, lineHeight: 1.5,
};

View file

@ -8,9 +8,12 @@ interface Props {
canEdit: boolean;
}
// Start every dimension at 0 so reviewers have to actively score each one.
// Anchoring on 5/10 ("everything is average") was the V1 bug — the reviewer
// could save the scorecard without engaging with any dimension.
const ZERO_SCORES: TROWLSScores = {
timing: 5, relationship: 5, opportunity_size: 5,
what_we_know: 5, location: 5, sector: 5,
timing: 0, relationship: 0, opportunity_size: 0,
what_we_know: 0, location: 0, sector: 0,
};
const RECOMMENDATION_BADGE: Record<string, { bg: string; fg: string; label: string }> = {

View file

@ -74,6 +74,15 @@ export default function Stage4QAPack({ opportunityId }: Props) {
</div>
</div>
{!isLoading && total === 0 && (
<div style={emptyCalloutStyle}>
<strong>No Q&amp;A to export yet.</strong>{' '}
<span style={{ color: 'var(--color-text-secondary)' }}>
Clarifications come from the Stage 2 Diagnosis Agent. Go back to <strong>Stage 2</strong> and run it when it lands, the Excel and Word downloads will enable.
</span>
</div>
)}
<div style={statsRowStyle}>
<Stat label="Total" value={total} />
<Stat label="Red" value={byPriority.red.length} fg="#fca5a5" />
@ -156,6 +165,11 @@ const secondaryBtnStyle: React.CSSProperties = {
fontWeight: 500, fontSize: 12, cursor: 'pointer',
};
const emptyStyle: React.CSSProperties = { color: 'var(--color-text-muted)', fontSize: 13 };
const emptyCalloutStyle: React.CSSProperties = {
marginBottom: 14, padding: '12px 14px', borderRadius: 8,
background: 'rgba(255,196,7,0.08)', border: '1px solid rgba(255,196,7,0.4)',
fontSize: 13, lineHeight: 1.5, color: '#FFC407',
};
const listStyle: React.CSSProperties = {
listStyle: 'none', padding: 0, margin: 0,
display: 'flex', flexDirection: 'column', gap: 8,

View file

@ -1,5 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
assetsKeys,
useClientAssets,
useCreateAsset,
useDeleteAsset,
@ -15,23 +17,56 @@ interface Props {
canEdit: boolean;
}
const NORMALIZE_TIMEOUT_MS = 120_000;
const POLL_SECONDS = 2;
export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
const { data: assets, isLoading } = useClientAssets(opportunityId);
const { data: artifacts } = useStageArtifacts(opportunityId, 6);
const qc = useQueryClient();
const [runState, setRunState] = useState<{ beforeArtifactId: number } | null>(null);
const isRunning = runState !== null;
const { data: assets, isLoading } = useClientAssets(opportunityId, isRunning ? POLL_SECONDS : undefined);
const { data: artifacts } = useStageArtifacts(opportunityId, 6, isRunning ? POLL_SECONDS : undefined);
const latestArtifact = artifacts?.find((a) => a.artifact_type === 'normalized_assets');
const normalize = useRunNormalize(opportunityId);
const create = useCreateAsset(opportunityId);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// Detect completion: a new normalized_assets artifact (id > beforeArtifactId)
// means the bg task finished. Refresh the dependent caches.
useEffect(() => {
if (!runState) return;
if (latestArtifact && latestArtifact.id > runState.beforeArtifactId) {
setRunState(null);
qc.invalidateQueries({ queryKey: assetsKeys.matches(opportunityId) });
qc.invalidateQueries({ queryKey: assetsKeys.ratecard(opportunityId) });
}
}, [latestArtifact, runState, qc, opportunityId]);
// Soft 2-minute cap. If the bg task crashes the artifact will never appear,
// so without this the spinner spins forever.
useEffect(() => {
if (!isRunning) return;
const t = setTimeout(() => {
setRunState(null);
setError('Normalize is taking longer than expected. Refresh the page to check.');
}, NORMALIZE_TIMEOUT_MS);
return () => clearTimeout(t);
}, [isRunning]);
async function runNormalize() {
if (!confirm('Re-running the Asset Normalizer wipes existing assets, matches, and the ratecard for this opportunity. Continue?')) {
const isReRun = (assets?.length ?? 0) > 0;
if (isReRun && !confirm('Re-running the Asset Normalizer wipes existing assets, matches, and the ratecard for this opportunity. Continue?')) {
return;
}
setError(null);
const beforeArtifactId = latestArtifact?.id ?? 0;
setRunState({ beforeArtifactId });
try {
await normalize.mutateAsync();
} catch (err: any) {
setRunState(null);
setError(err?.response?.data?.detail ?? err?.message ?? 'Normalize failed');
}
}
@ -50,15 +85,28 @@ export default function Stage6Normalize({ opportunityId, canEdit }: Props) {
deliverables list. Re-running wipes existing assets and downstream matches/ratecard.
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={runNormalize} disabled={!canEdit || normalize.isPending} style={primaryBtnStyle}>
{normalize.isPending ? 'Running…' : (assets && assets.length > 0 ? 'Re-run normalizer' : 'Run normalizer')}
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={runNormalize} disabled={!canEdit || normalize.isPending || isRunning} style={primaryBtnStyle}>
{isRunning ? 'Normalizing… (~30s)' : (assets && assets.length > 0 ? 'Re-run normalizer' : 'Run normalizer')}
</button>
{canEdit && (
<button onClick={() => setShowAdd(true)} style={secondaryBtnStyle} disabled={isRunning}>+ Add deliverable manually</button>
)}
</div>
{canEdit && (
<button onClick={() => setShowAdd(true)} style={secondaryBtnStyle}>+ Add manually</button>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
Manual add skips the AI use it when the brief misses an asset you know about.
</span>
)}
</div>
</div>
{isRunning && (
<div style={runningBannerStyle}>
Reading your brief and producing a clean deliverables list this usually takes 2040 seconds.
You can stay on this page; the assets list refreshes automatically when it finishes.
</div>
)}
{error && <div style={errorStyle}>{error}</div>}
</section>
@ -298,3 +346,8 @@ const errorStyle: React.CSSProperties = {
border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5',
padding: '10px 12px', borderRadius: 8, fontSize: 13,
};
const runningBannerStyle: React.CSSProperties = {
marginTop: 12, background: 'rgba(255,196,7,0.10)',
border: '1px solid rgba(255,196,7,0.4)', color: '#FFC407',
padding: '10px 12px', borderRadius: 8, fontSize: 13, lineHeight: 1.5,
};

View file

@ -1,7 +1,12 @@
import { FormEvent, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useCreateOpportunity } from '../api/opportunities';
import { MODEL_TYPE_LABELS, ModelTypeKey } from '../types';
// Delivery model is decided LATER in the pipeline (Stage 8/9 + Deal Desk
// alignment). At intake time the sales lead won't know which model the
// solution will be sized against, so we default it here and let the
// downstream stages reset it as the deal shape becomes clear.
const DEFAULT_MODEL_TYPE = 'current_oplus' as const;
export default function NewOpportunity() {
const navigate = useNavigate();
@ -14,7 +19,6 @@ export default function NewOpportunity() {
const [serviceTypes, setServiceTypes] = useState('');
const [deadline, setDeadline] = useState('');
const [goLive, setGoLive] = useState('');
const [modelType, setModelType] = useState<ModelTypeKey>('current_oplus');
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: FormEvent) {
@ -29,7 +33,7 @@ export default function NewOpportunity() {
service_types: serviceTypes.trim() || undefined,
deadline: deadline || null,
go_live: goLive || null,
model_type: modelType,
model_type: DEFAULT_MODEL_TYPE,
});
navigate(`/opportunities/${opp.id}`);
} catch (err: any) {
@ -76,14 +80,6 @@ export default function NewOpportunity() {
</Field>
</div>
<Field label="Delivery model (drives GMAL hours lookups)">
<select value={modelType} onChange={(e) => setModelType(e.target.value as ModelTypeKey)} style={inputStyle}>
{(Object.keys(MODEL_TYPE_LABELS) as ModelTypeKey[]).map((k) => (
<option key={k} value={k}>{MODEL_TYPE_LABELS[k]}</option>
))}
</select>
</Field>
{error && <div style={errorStyle}>{error}</div>}
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom';
import { useOpportunity, useStages, useCompleteStage } from '../api/opportunities';
import { useOpportunity, useStages, useCompleteStage, useUpdateOpportunity } from '../api/opportunities';
import { GATED_STAGES, MODEL_TYPE_LABELS, STAGE_TITLES } from '../types';
import StageStepper from '../components/StageStepper';
import Stage1Intake from '../components/Stage1Intake';
@ -50,8 +51,8 @@ export default function OpportunityView() {
<Link to="/" style={{ color: 'var(--color-text-muted)', fontSize: 12 }}> Opportunities</Link>
<header style={{ marginTop: 12, marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 26, letterSpacing: '-0.02em' }}>{opp.name}</h1>
<div style={{ minWidth: 0, flex: 1 }}>
<EditableOpportunityName id={opportunityId} name={opp.name} />
<div style={{ color: 'var(--color-text-secondary)', marginTop: 6, fontSize: 13 }}>
{opp.client_name && <span>{opp.client_name}</span>}
{opp.region && <span> · {opp.region}</span>}
@ -183,6 +184,65 @@ export default function OpportunityView() {
);
}
function EditableOpportunityName({ id, name }: { id: number; name: string }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const update = useUpdateOpportunity(id);
async function save() {
const trimmed = draft.trim();
if (!trimmed || trimmed === name) {
setEditing(false);
setDraft(name);
return;
}
try {
await update.mutateAsync({ name: trimmed });
setEditing(false);
} catch (err: any) {
alert(err?.response?.data?.detail ?? err?.message ?? 'Rename failed');
setDraft(name);
setEditing(false);
}
}
if (!editing) {
return (
<h1 style={{ margin: 0, fontSize: 26, letterSpacing: '-0.02em', display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{name}</span>
<button
onClick={() => { setDraft(name); setEditing(true); }}
aria-label="Rename opportunity"
title="Rename opportunity"
style={editPencilStyle}
>
</button>
</h1>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') save();
else if (e.key === 'Escape') { setDraft(name); setEditing(false); }
}}
disabled={update.isPending}
style={nameInputStyle}
/>
<button onClick={save} disabled={update.isPending || !draft.trim()} style={primaryBtnStyle}>
{update.isPending ? 'Saving…' : 'Save'}
</button>
<button onClick={() => { setDraft(name); setEditing(false); }} style={ghostBtnStyle}>Cancel</button>
</div>
);
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
@ -194,6 +254,25 @@ const primaryBtnStyle: React.CSSProperties = {
fontSize: 13, cursor: 'pointer',
};
const ghostBtnStyle: React.CSSProperties = {
background: 'transparent', color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)', padding: '10px 16px', borderRadius: 8,
fontWeight: 500, fontSize: 13, cursor: 'pointer',
};
const editPencilStyle: React.CSSProperties = {
background: 'transparent', border: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer',
fontSize: 16, padding: '2px 6px', borderRadius: 4,
};
const nameInputStyle: React.CSSProperties = {
fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em',
padding: '6px 10px', borderRadius: 6,
background: 'var(--color-bg-input)', border: '1px solid var(--color-border)',
color: 'var(--color-text)', minWidth: 320,
};
const gateBadge: React.CSSProperties = {
fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
padding: '3px 8px', borderRadius: 12,

View file

@ -230,10 +230,7 @@ export interface Notification {
export const APPROVAL_ROLES = [
'commercial',
'delivery',
'solution',
'regional',
'deal_desk',
] as const;
export type ApprovalRole = typeof APPROVAL_ROLES[number];