diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py index f9b8b9a..ea0de51 100644 --- a/backend/app/api/approvals.py +++ b/backend/app/api/approvals.py @@ -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": diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py index 6457233..dc289cc 100644 --- a/backend/app/api/assets.py +++ b/backend/app/api/assets.py @@ -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() + 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} diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py index f21545d..ab73e34 100644 --- a/backend/app/api/matching.py +++ b/backend/app/api/matching.py @@ -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} diff --git a/backend/app/config.py b/backend/app/config.py index 3d226d6..f8c50f2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/services/asset_normalizer.py b/backend/app/services/asset_normalizer.py index f98f243..02eece6 100644 --- a/backend/app/services/asset_normalizer.py +++ b/backend/app/services/asset_normalizer.py @@ -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 diff --git a/backend/app/services/ratecard_builder.py b/backend/app/services/ratecard_builder.py index 7f18b36..92b82fd 100644 --- a/backend/app/services/ratecard_builder.py +++ b/backend/app/services/ratecard_builder.py @@ -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( diff --git a/backend/app/services/stage_machine.py b/backend/app/services/stage_machine.py index 340cd3c..651ae31 100644 --- a/backend/app/services/stage_machine.py +++ b/backend/app/services/stage_machine.py @@ -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}") diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts index 649cfab..40ebc83 100644 --- a/frontend/src/api/assets.ts +++ b/frontend/src/api/assets.ts @@ -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 => { @@ -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) }); - }, }); } diff --git a/frontend/src/api/opportunities.ts b/frontend/src/api/opportunities.ts index becb946..ef7b77a 100644 --- a/frontend/src/api/opportunities.ts +++ b/frontend/src/api/opportunities.ts @@ -82,6 +82,22 @@ export function useDeleteOpportunity() { }); } +export function useUpdateOpportunity(opportunityId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ( + payload: Partial>, + ): Promise => { + 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 => { @@ -148,6 +168,7 @@ export function useStageArtifacts(opportunityId: number | undefined, stageNumber return res.data; }, enabled: opportunityId !== undefined && opportunityId > 0, + refetchInterval: refetchSeconds ? refetchSeconds * 1000 : false, }); } diff --git a/frontend/src/components/Stage1Intake.tsx b/frontend/src/components/Stage1Intake.tsx index 9c828a7..24e74ff 100644 --- a/frontend/src/components/Stage1Intake.tsx +++ b/frontend/src/components/Stage1Intake.tsx @@ -154,6 +154,15 @@ export default function Stage1Intake({ opportunityId, canEdit }: Props) { )} )} + + {metadata && canEdit && ( +
+ ✓ Intake done.{' '} + + Review the metadata above, then scroll to the bottom of the page and click Complete Stage 1 to advance to Stage 2 (Read & Diagnose Brief). + +
+ )} ); @@ -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, +}; diff --git a/frontend/src/components/Stage3Qualify.tsx b/frontend/src/components/Stage3Qualify.tsx index 7839503..6825354 100644 --- a/frontend/src/components/Stage3Qualify.tsx +++ b/frontend/src/components/Stage3Qualify.tsx @@ -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 = { diff --git a/frontend/src/components/Stage4QAPack.tsx b/frontend/src/components/Stage4QAPack.tsx index 83ec86b..a62e91f 100644 --- a/frontend/src/components/Stage4QAPack.tsx +++ b/frontend/src/components/Stage4QAPack.tsx @@ -74,6 +74,15 @@ export default function Stage4QAPack({ opportunityId }: Props) { + {!isLoading && total === 0 && ( +
+ No Q&A to export yet.{' '} + + Clarifications come from the Stage 2 Diagnosis Agent. Go back to Stage 2 and run it — when it lands, the Excel and Word downloads will enable. + +
+ )} +
@@ -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, diff --git a/frontend/src/components/Stage6Normalize.tsx b/frontend/src/components/Stage6Normalize.tsx index 25f6251..49af7ad 100644 --- a/frontend/src/components/Stage6Normalize.tsx +++ b/frontend/src/components/Stage6Normalize.tsx @@ -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(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.

-
- +
+
+ + {canEdit && ( + + )} +
{canEdit && ( - + + Manual add skips the AI — use it when the brief misses an asset you know about. + )}
+ {isRunning && ( +
+ 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. +
+ )} {error &&
{error}
} @@ -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, +}; diff --git a/frontend/src/pages/NewOpportunity.tsx b/frontend/src/pages/NewOpportunity.tsx index ff46ad7..21fe0bc 100644 --- a/frontend/src/pages/NewOpportunity.tsx +++ b/frontend/src/pages/NewOpportunity.tsx @@ -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('current_oplus'); const [error, setError] = useState(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() { - - - - {error &&
{error}
}
diff --git a/frontend/src/pages/OpportunityView.tsx b/frontend/src/pages/OpportunityView.tsx index 1dde659..1c1073c 100644 --- a/frontend/src/pages/OpportunityView.tsx +++ b/frontend/src/pages/OpportunityView.tsx @@ -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() { ← Opportunities
-
-

{opp.name}

+
+
{opp.client_name && {opp.client_name}} {opp.region && · {opp.region}} @@ -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 ( +

+ {name} + +

+ ); + } + + return ( +
+ 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} + /> + + +
+ ); +} + 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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a028d91..3e7f510 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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];