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:
parent
9b00028ca9
commit
06110d6d71
16 changed files with 400 additions and 85 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 20–40s, 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()
|
||||
|
||||
return NormalizeRunResponse(
|
||||
assets_seeded=seeded,
|
||||
artifact_id=artifact.id if artifact else 0,
|
||||
raw=result,
|
||||
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.",
|
||||
)
|
||||
|
||||
background_tasks.add_task(_run_normalize_in_bg, opportunity_id)
|
||||
return {"detail": "Normalize started", "opportunity_id": opportunity_id}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }> = {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ export default function Stage4QAPack({ opportunityId }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && total === 0 && (
|
||||
<div style={emptyCalloutStyle}>
|
||||
<strong>No Q&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,
|
||||
|
|
|
|||
|
|
@ -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', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
|
||||
<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 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}>+ Add manually</button>
|
||||
<button onClick={() => setShowAdd(true)} style={secondaryBtnStyle} disabled={isRunning}>+ Add deliverable manually</button>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<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 20–40 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue