From 0a5b552ad2acd298df7d51f6d4005cfbd819616e Mon Sep 17 00:00:00 2001 From: DJP Date: Fri, 27 Mar 2026 19:32:34 -0400 Subject: [PATCH] Add Team Shape calculator (Phase 2) with FTE per role - Team shape service: total_hours / 1800 = FTE per role - Programme roles (6) flagged separately from delivery roles - New API endpoint GET /projects/{id}/team-shape - Team Shape tab in frontend with summary stats and role breakdown - Sheet 3 "Team Shape" in Excel export with discipline grouping, delivery vs programme split, FTE, rounded headcount, and summary - Full GMAL catalog matching (replaced pre-filter with compact catalog) - Upload progress stages with live polling Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/ratecard.py | 24 ++++++ backend/app/services/export_excel.py | 114 +++++++++++++++++++++++++++ backend/app/services/team_shape.py | 74 +++++++++++++++++ frontend/src/pages/ProjectView.css | 63 +++++++++++++++ frontend/src/pages/ProjectView.tsx | 108 ++++++++++++++++++++++++- 5 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/team_shape.py diff --git a/backend/app/api/ratecard.py b/backend/app/api/ratecard.py index 8a958f3..d06a518 100644 --- a/backend/app/api/ratecard.py +++ b/backend/app/api/ratecard.py @@ -12,6 +12,7 @@ from app.models.gmal import GmalAsset, Role from app.models.project import Project, ClientAsset, RatecardLine, ProjectStatus from app.schemas.project import RatecardLineOut, RatecardLineUpdate, RatecardSummary from app.services.ratecard_builder import build_ratecard +from app.services.team_shape import calculate_team_shape from app.services.export_excel import export_ratecard_excel from app.services.export_pdf import export_caveats_pdf @@ -133,6 +134,29 @@ async def update_ratecard_line( ) +@router.get("/{project_id}/team-shape") +async def get_team_shape(project_id: int, db: AsyncSession = Depends(get_db)): + """Get team shape (FTE per role) calculated from the ratecard.""" + project = await _get_project(project_id, db) + team = await calculate_team_shape(db, project) + + total_hours = sum(t["total_hours"] for t in team) + total_fte = sum(t["fte"] for t in team) + programme_fte = sum(t["fte"] for t in team if t["is_programme_role"]) + delivery_fte = total_fte - programme_fte + + return { + "project_id": project.id, + "project_name": project.name, + "hours_per_fte": 1800, + "total_hours": round(total_hours, 2), + "total_fte": round(total_fte, 4), + "delivery_fte": round(delivery_fte, 4), + "programme_fte": round(programme_fte, 4), + "roles": team, + } + + @router.get("/{project_id}/ratecard/export/excel") async def export_excel(project_id: int, db: AsyncSession = Depends(get_db)): project = await _get_project(project_id, db) diff --git a/backend/app/services/export_excel.py b/backend/app/services/export_excel.py index bf10374..5269dcd 100644 --- a/backend/app/services/export_excel.py +++ b/backend/app/services/export_excel.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload from app.models.gmal import GmalAsset, Role from app.models.project import Project, ClientAsset, Match, RatecardLine +from app.services.team_shape import calculate_team_shape logger = logging.getLogger(__name__) @@ -69,6 +70,10 @@ async def export_ratecard_excel(db: AsyncSession, project: Project) -> bytes: ws2 = wb.create_sheet("Asset Detail") await _build_asset_detail_sheet(ws2, db, project, client_assets, gmals) + # Sheet 3: Team Shape + ws3 = wb.create_sheet("Team Shape") + await _build_team_shape_sheet(ws3, db, project) + return _workbook_to_bytes(wb) @@ -210,6 +215,115 @@ async def _build_asset_detail_sheet(ws, db, project, client_assets, gmals): ws.column_dimensions[get_column_letter(i)].width = w +TEAM_HEADER_FILL = PatternFill(start_color="2E7D32", end_color="2E7D32", fill_type="solid") +PROGRAMME_FILL = PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid") +FTE_FONT = Font(bold=True, size=11, color="1B5E20") + + +async def _build_team_shape_sheet(ws, db, project): + """Build the team shape sheet: FTE per role from ratecard hours / 1800.""" + team = await calculate_team_shape(db, project) + + if not team: + ws["A1"] = "No ratecard data - build ratecard first" + return + + total_hours = sum(t["total_hours"] for t in team) + total_fte = sum(t["fte"] for t in team) + + # Title row + ws.merge_cells("A1:F1") + title_cell = ws.cell(row=1, column=1, value=f"Team Shape - {project.name}") + title_cell.font = Font(bold=True, size=14) + + ws.cell(row=2, column=1, value=f"Based on {total_hours:,.0f} total hours / 1,800 hours per FTE = {total_fte:.2f} FTE") + ws.cell(row=2, column=1).font = Font(italic=True, color="666666") + + # Headers + headers = ["Discipline", "Role", "Type", "Total Hours", "FTE", "Headcount (rounded)"] + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=4, column=col_idx, value=header) + cell.font = HEADER_FONT + cell.fill = TEAM_HEADER_FILL + + # Data rows + row_idx = 5 + current_discipline = None + + for t in team: + # Discipline grouping + if t["discipline"] != current_discipline: + current_discipline = t["discipline"] + ws.cell(row=row_idx, column=1, value=current_discipline).font = Font(bold=True) + ws.cell(row=row_idx, column=1).fill = DISCIPLINE_FILL + for c in range(1, 7): + ws.cell(row=row_idx, column=c).fill = DISCIPLINE_FILL + row_idx += 1 + + role_type = "Programme" if t["is_programme_role"] else "Delivery" + + ws.cell(row=row_idx, column=1, value=t["discipline"]) + ws.cell(row=row_idx, column=2, value=t["role_title"]) + ws.cell(row=row_idx, column=3, value=role_type) + ws.cell(row=row_idx, column=4, value=t["total_hours"]) + ws.cell(row=row_idx, column=4).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 + + # Rounded headcount (round up for >= 0.5, show 0.5 for < 0.5 but > 0) + import math + headcount = math.ceil(t["fte"]) if t["fte"] >= 0.5 else (0.5 if t["fte"] > 0 else 0) + ws.cell(row=row_idx, column=6, value=headcount) + ws.cell(row=row_idx, column=6).number_format = '0.0' + + if t["is_programme_role"]: + for c in range(1, 7): + ws.cell(row=row_idx, column=c).fill = PROGRAMME_FILL + + row_idx += 1 + + # Summary section + row_idx += 1 + ws.cell(row=row_idx, column=1, value="SUMMARY").font = Font(bold=True, size=12) + row_idx += 1 + + delivery_hours = sum(t["total_hours"] for t in team if not t["is_programme_role"]) + delivery_fte = sum(t["fte"] for t in team if not t["is_programme_role"]) + prog_hours = sum(t["total_hours"] for t in team if t["is_programme_role"]) + prog_fte = sum(t["fte"] for t in team if t["is_programme_role"]) + + summary_data = [ + ("Delivery Roles", delivery_hours, delivery_fte), + ("Programme Roles", prog_hours, prog_fte), + ("TOTAL", total_hours, total_fte), + ] + + ws.cell(row=row_idx, column=3, value="Hours").font = Font(bold=True) + ws.cell(row=row_idx, column=4, value="FTE").font = Font(bold=True) + row_idx += 1 + + for label, hours, fte in summary_data: + ws.cell(row=row_idx, column=2, value=label).font = Font(bold=(label == "TOTAL")) + ws.cell(row=row_idx, column=3, value=round(hours, 2)) + ws.cell(row=row_idx, column=3).number_format = '#,##0.00' + fte_cell = ws.cell(row=row_idx, column=4, value=round(fte, 2)) + fte_cell.number_format = '0.00' + if label == "TOTAL": + fte_cell.font = Font(bold=True, size=12) + row_idx += 1 + + # Column widths + ws.column_dimensions["A"].width = 25 + ws.column_dimensions["B"].width = 40 + ws.column_dimensions["C"].width = 12 + ws.column_dimensions["D"].width = 15 + ws.column_dimensions["E"].width = 10 + ws.column_dimensions["F"].width = 18 + + def _workbook_to_bytes(wb: Workbook) -> bytes: buf = io.BytesIO() wb.save(buf) diff --git a/backend/app/services/team_shape.py b/backend/app/services/team_shape.py new file mode 100644 index 0000000..ba739a3 --- /dev/null +++ b/backend/app/services/team_shape.py @@ -0,0 +1,74 @@ +"""Calculate team shape (FTE headcount) from ratecard data.""" + +import logging +from collections import defaultdict + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gmal import Role +from app.models.project import Project, RatecardLine + +logger = logging.getLogger(__name__) + +HOURS_PER_FTE = 1800 + + +async def calculate_team_shape(db: AsyncSession, project: Project) -> list[dict]: + """Calculate FTE headcount per role from the ratecard. + + FTE = total_hours_per_role / 1800 + + Programme roles (flagged in DB) are kept separate as they're + not per-asset but per-programme. + + Returns list of dicts sorted by discipline then role order: + [{role_id, role_title, discipline, is_programme_role, total_hours, fte}, ...] + """ + # Load ratecard lines + lines_result = await db.execute( + select(RatecardLine).where(RatecardLine.project_id == project.id) + ) + lines = lines_result.scalars().all() + + if not lines: + return [] + + # Aggregate hours per role + role_hours: dict[int, float] = defaultdict(float) + for line in lines: + effective = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0) + role_hours[line.role_id] += effective + + # Load role details + role_ids = list(role_hours.keys()) + roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids))) + roles = {r.id: r for r in roles_result.scalars().all()} + + # Build team shape + team = [] + for role_id, total_hours in role_hours.items(): + role = roles.get(role_id) + if not role or total_hours == 0: + continue + + fte = round(total_hours / HOURS_PER_FTE, 4) + + team.append({ + "role_id": role_id, + "role_title": role.role_title, + "discipline": role.discipline, + "is_programme_role": role.is_programme_role, + "sort_order": role.sort_order or 0, + "total_hours": round(total_hours, 2), + "fte": fte, + }) + + # Sort by discipline then sort_order + team.sort(key=lambda t: (t["discipline"], t["sort_order"])) + + total_hours = sum(t["total_hours"] for t in team) + total_fte = sum(t["fte"] for t in team) + logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE") + + return team diff --git a/frontend/src/pages/ProjectView.css b/frontend/src/pages/ProjectView.css index b7055c2..1029cd6 100644 --- a/frontend/src/pages/ProjectView.css +++ b/frontend/src/pages/ProjectView.css @@ -489,3 +489,66 @@ span.conf-none { background: var(--color-danger); } .text-right { text-align: right; } .text-center { text-align: center; } + +/* Team Shape */ +.team-summary { + display: flex; + gap: 14px; + margin-bottom: 20px; +} + +.team-stat { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 16px 20px; + flex: 1; + text-align: center; +} + +.team-stat-value { + font-size: 24px; + font-weight: 700; + color: #fff; + letter-spacing: -0.03em; +} + +.team-stat-label { + font-size: 11px; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 4px; +} + +.disc-row td { + background: rgba(255,255,255,0.03) !important; +} + +.td-disc-header { + font-weight: 700 !important; + color: var(--color-text) !important; + font-size: 12px; + padding: 10px 14px !important; + border-bottom: 1px solid var(--color-border) !important; +} + +.programme-row td { + background: rgba(255, 196, 7, 0.04); +} + +.td-programme { + color: var(--color-primary) !important; + font-weight: 600; + font-size: 11px; +} + +.td-delivery { + color: var(--color-text-muted) !important; + font-size: 11px; +} + +.td-fte-highlight { + color: var(--color-success) !important; + font-weight: 700; +} diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index 3eaf6f4..a8ae7fe 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -4,7 +4,25 @@ import api from '../api/client'; import { Project, ClientAsset, Match, RatecardSummary, MODEL_TYPE_LABELS, CONFIDENCE_COLORS } from '../types'; import './ProjectView.css'; -type Tab = 'upload' | 'matches' | 'ratecard'; +type Tab = 'upload' | 'matches' | 'ratecard' | 'team'; + +interface TeamRole { + role_id: number; + role_title: string; + discipline: string; + is_programme_role: boolean; + total_hours: number; + fte: number; +} + +interface TeamShape { + project_id: number; + total_hours: number; + total_fte: number; + delivery_fte: number; + programme_fte: number; + roles: TeamRole[]; +} const CONF_CLASS: Record = { exact: 'conf-exact', @@ -21,6 +39,7 @@ export default function ProjectView() { const [assets, setAssets] = useState([]); const [matches, setMatches] = useState([]); const [ratecard, setRatecard] = useState(null); + const [teamShape, setTeamShape] = useState(null); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [uploadStage, setUploadStage] = useState(''); @@ -44,8 +63,12 @@ export default function ProjectView() { if (['finalized', 'building'].includes(projRes.data.status)) { try { - const rcRes = await api.get(`/projects/${id}/ratecard`); + const [rcRes, tsRes] = await Promise.all([ + api.get(`/projects/${id}/ratecard`), + api.get(`/projects/${id}/team-shape`), + ]); setRatecard(rcRes.data); + setTeamShape(tsRes.data); } catch {} } @@ -213,7 +236,7 @@ export default function ProjectView() {
- {(['upload', 'matches', 'ratecard'] as Tab[]).map(t => ( + {(['upload', 'matches', 'ratecard', 'team'] as Tab[]).map(t => ( ))} @@ -446,6 +470,84 @@ export default function ProjectView() { )}
)} + + {tab === 'team' && ( +
+ {!teamShape || teamShape.roles.length === 0 ? ( +
+ No team shape data. Build the ratecard first. +
+ ) : ( + <> +
+
+
{teamShape.total_fte.toFixed(2)}
+
Total FTE
+
+
+
{teamShape.delivery_fte.toFixed(2)}
+
Delivery FTE
+
+
+
{teamShape.programme_fte.toFixed(2)}
+
Programme FTE
+
+
+
{teamShape.total_hours.toLocaleString()}
+
Total Hours
+
+
+
1,800
+
Hours / FTE
+
+
+ +
+ + + + + + + + + + + + + {teamShape.roles.map((r, idx) => { + const prevDisc = idx > 0 ? teamShape.roles[idx - 1].discipline : null; + const showDiscipline = r.discipline !== prevDisc; + const headcount = r.fte >= 0.5 ? Math.ceil(r.fte) : r.fte > 0 ? 0.5 : 0; + return ( + <> + {showDiscipline && ( + + + + )} + + + + + + + + + + ); + })} + +
DisciplineRoleTypeTotal HoursFTEHeadcount
{r.discipline}
{r.discipline}{r.role_title} + {r.is_programme_role ? 'Programme' : 'Delivery'} + {r.total_hours.toFixed(2)}= 1 ? 'td-fte-highlight' : ''}`}> + {r.fte.toFixed(2)} + {headcount.toFixed(1)}
+
+ + )} +
+ )} ); }