From 1d920d62ccc6ec4342f87c2f8fa20d49579c5dcb Mon Sep 17 00:00:00 2001 From: DJP Date: Thu, 9 Apr 2026 14:37:56 -0400 Subject: [PATCH] Fix: Brief analysis text input, Excel formulas on all sheets - Brief Analysis now accepts pasted text OR uploaded file - Textarea for typing/pasting brief directly (no upload required) - Re-analyze button returns to input screen - Team Shape Excel sheets now use formulas: - FTE = Hours/1800 (formula) - Adjusted Hours = Original * (1-eff%) (formula) - Hours Saved = Original - Adjusted (formula) - Headcount = IF/CEILING formula - Base team shape also uses FTE + headcount formulas - All sheets are now formula-driven, Finance can edit hours and see recalculation Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/matching.py | 37 +++++++++++++----------- backend/app/services/export_excel.py | 43 ++++++++++++++++------------ frontend/src/pages/ProjectView.css | 14 +++++++++ frontend/src/pages/ProjectView.tsx | 43 ++++++++++++++++++---------- 4 files changed, 87 insertions(+), 50 deletions(-) diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py index 6bbe886..a51db22 100644 --- a/backend/app/api/matching.py +++ b/backend/app/api/matching.py @@ -139,30 +139,33 @@ async def get_brief_analysis(project_id: int, db: AsyncSession = Depends(get_db) @router.post("/{project_id}/analyze-brief") -async def analyze_brief_endpoint(project_id: int, db: AsyncSession = Depends(get_db)): - """Run AI analysis on the uploaded document.""" +async def analyze_brief_endpoint(project_id: int, data: dict | None = None, db: AsyncSession = Depends(get_db)): + """Run AI analysis on uploaded document or pasted text.""" from app.services.rfp_analysis import analyze_brief from app.services.doc_parser import extract_text_from_file project = await _get_project(project_id, db) - if not project.source_filename: - raise HTTPException(status_code=400, detail="No document uploaded yet") + body = data or {} - # Read the file from data dir - import os - from app.config import settings - filepath = os.path.join(settings.data_dir, project.source_filename) + # Option 1: Pasted text + if body.get("text"): + text = body["text"] + # Option 2: Read from uploaded file + elif project.source_filename: + import os + from app.config import settings + filepath = os.path.join(settings.data_dir, project.source_filename) + if not os.path.exists(filepath): + raise HTTPException(status_code=400, detail="Source file not found on disk. Re-upload or paste the brief text.") + with open(filepath, "rb") as f: + content = f.read() + text, _ = extract_text_from_file(content, project.source_filename) + else: + raise HTTPException(status_code=400, detail="No document uploaded and no text provided. Upload a file or paste the brief.") - # Try to read from the stored file - if not available, re-extract from any recent upload - # For now, we store the extracted text in parse_stage temporarily during upload - # We need the raw text - let's store it - if not os.path.exists(filepath): - return {"status": "error", "detail": "Source file not found on disk. Re-upload the document."} + if len(text.strip()) < 20: + raise HTTPException(status_code=400, detail="Brief text is too short to analyze.") - with open(filepath, "rb") as f: - content = f.read() - - text, metadata = extract_text_from_file(content, project.source_filename) analysis = await analyze_brief(db, project, text) return {"status": "analyzed", "analysis": analysis} diff --git a/backend/app/services/export_excel.py b/backend/app/services/export_excel.py index 02f88ab..752ea9a 100644 --- a/backend/app/services/export_excel.py +++ b/backend/app/services/export_excel.py @@ -401,29 +401,36 @@ async def _build_team_shape_sheet(ws, db, project, efficiency_pct: float = 0): ws.cell(row=row_idx, column=3, value=role_type) if has_efficiency: + # Col D: Original Hours (value) ws.cell(row=row_idx, column=4, value=t["total_hours"]).number_format = '#,##0.00' - ws.cell(row=row_idx, column=5, value=t["fte"]).number_format = '0.00' - ws.cell(row=row_idx, column=6, value=t["adjusted_hours"]).number_format = '#,##0.00' - adj_cell = ws.cell(row=row_idx, column=7, value=t["adjusted_fte"]) + # Col E: Original FTE = D/1800 (formula) + ws.cell(row=row_idx, column=5).value = f"=D{row_idx}/1800" + ws.cell(row=row_idx, column=5).number_format = '0.00' + # Col F: Adjusted Hours = D * (1 - eff%) (formula) + eff_pct = t.get("efficiency_pct", efficiency_pct) or 0 + ws.cell(row=row_idx, column=6).value = f"=D{row_idx}*(1-{eff_pct/100})" + ws.cell(row=row_idx, column=6).number_format = '#,##0.00' + # Col G: Adjusted FTE = F/1800 (formula) + adj_cell = ws.cell(row=row_idx, column=7) + adj_cell.value = f"=F{row_idx}/1800" adj_cell.number_format = '0.00' - if t["adjusted_fte"] >= 1: - adj_cell.font = FTE_FONT - saved = ws.cell(row=row_idx, column=8, value=t["hours_saved"]) + # Col H: Hours Saved = D - F (formula) + saved = ws.cell(row=row_idx, column=8) + saved.value = f"=D{row_idx}-F{row_idx}" saved.number_format = '#,##0.00' - if t["hours_saved"] > 0: - saved.font = SAVED_FONT - hc = t["adjusted_fte"] - headcount = math.ceil(hc) if hc >= 0.5 else (0.5 if hc > 0 else 0) - ws.cell(row=row_idx, column=9, value=headcount).number_format = '0.0' + saved.font = SAVED_FONT + # Col I: Headcount = CEILING or 0.5 (formula) + ws.cell(row=row_idx, column=9).value = f'=IF(G{row_idx}>=0.5,CEILING(G{row_idx},1),IF(G{row_idx}>0,0.5,0))' + ws.cell(row=row_idx, column=9).number_format = '0.0' else: + # Col D: Total Hours (value) ws.cell(row=row_idx, column=4, value=t["total_hours"]).number_format = '#,##0.00' - fte_cell = ws.cell(row=row_idx, column=5, value=t["fte"]) - fte_cell.number_format = '0.00' - if t["fte"] >= 1: - fte_cell.font = FTE_FONT - hc = t["fte"] - headcount = math.ceil(hc) if hc >= 0.5 else (0.5 if hc > 0 else 0) - ws.cell(row=row_idx, column=6, value=headcount).number_format = '0.0' + # Col E: FTE = D/1800 (formula) + ws.cell(row=row_idx, column=5).value = f"=D{row_idx}/1800" + ws.cell(row=row_idx, column=5).number_format = '0.00' + # Col F: Headcount (formula) + ws.cell(row=row_idx, column=6).value = f'=IF(E{row_idx}>=0.5,CEILING(E{row_idx},1),IF(E{row_idx}>0,0.5,0))' + ws.cell(row=row_idx, column=6).number_format = '0.0' if t["is_programme_role"]: for c in range(1, num_cols + 1): diff --git a/frontend/src/pages/ProjectView.css b/frontend/src/pages/ProjectView.css index d3dd652..f32a08c 100644 --- a/frontend/src/pages/ProjectView.css +++ b/frontend/src/pages/ProjectView.css @@ -488,6 +488,20 @@ span.conf-none { background: var(--color-danger); } } /* Brief Analysis */ +.analysis-input-section { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; +} + +.brief-textarea { + min-height: 180px; + resize: vertical; + font-size: 13px; + line-height: 1.6; +} + .analysis-content {} .analysis-section { diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index d7934ed..4248ef7 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -62,6 +62,7 @@ export default function ProjectView() { const [refineLog, setRefineLog] = useState([]); const [briefAnalysis, setBriefAnalysis] = useState(null); const [analyzing, setAnalyzing] = useState(false); + const [briefText, setBriefText] = useState(''); const [matching, setMatching] = useState(false); const [building, setBuilding] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); @@ -297,10 +298,11 @@ export default function ProjectView() { downloadFile(`/projects/${id}/ratecard/export/pdf`, `${project?.name || 'caveats'}_caveats.pdf`); } - async function handleAnalyzeBrief() { + async function handleAnalyzeBrief(mode: 'file' | 'text') { setAnalyzing(true); try { - const res = await api.post(`/projects/${id}/analyze-brief`); + const payload = mode === 'text' ? { text: briefText } : {}; + const res = await api.post(`/projects/${id}/analyze-brief`, payload); setBriefAnalysis(res.data.analysis); } catch (err: any) { alert(`Analysis failed: ${err.response?.data?.detail || err.message}`); @@ -463,18 +465,29 @@ export default function ProjectView() { {tab === 'analysis' && (
- {!project.source_filename ? ( -
- Upload a document first to analyze the brief. -
- ) : !briefAnalysis ? ( -
-

- Analyze the uploaded brief to extract structured requirements, identify gaps, and generate discovery questions. + {!briefAnalysis ? ( +

+

+ Analyze a client brief to extract structured requirements, identify gaps, and generate discovery questions. + {project.source_filename && ` You can analyze the uploaded file (${project.source_filename}) or paste a brief below.`}

- +