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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-27 19:32:34 -04:00
parent 26d3435be0
commit 0a5b552ad2
5 changed files with 380 additions and 3 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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;
}

View file

@ -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<string, string> = {
exact: 'conf-exact',
@ -21,6 +39,7 @@ export default function ProjectView() {
const [assets, setAssets] = useState<ClientAsset[]>([]);
const [matches, setMatches] = useState<Match[]>([]);
const [ratecard, setRatecard] = useState<RatecardSummary | null>(null);
const [teamShape, setTeamShape] = useState<TeamShape | null>(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() {
</div>
<div className="tabs">
{(['upload', 'matches', 'ratecard'] as Tab[]).map(t => (
{(['upload', 'matches', 'ratecard', 'team'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
@ -221,6 +244,7 @@ export default function ProjectView() {
>
{t === 'upload' ? `Upload & Assets (${assets.length})` :
t === 'matches' ? `Match Review (${matches.length})` :
t === 'team' ? `Team Shape${teamShape ? ` (${teamShape.total_fte.toFixed(1)} FTE)` : ''}` :
'Ratecard'}
</button>
))}
@ -446,6 +470,84 @@ export default function ProjectView() {
)}
</div>
)}
{tab === 'team' && (
<div className="tab-content">
{!teamShape || teamShape.roles.length === 0 ? (
<div className="empty-state" style={{ padding: 40 }}>
No team shape data. Build the ratecard first.
</div>
) : (
<>
<div className="team-summary">
<div className="team-stat">
<div className="team-stat-value">{teamShape.total_fte.toFixed(2)}</div>
<div className="team-stat-label">Total FTE</div>
</div>
<div className="team-stat">
<div className="team-stat-value">{teamShape.delivery_fte.toFixed(2)}</div>
<div className="team-stat-label">Delivery FTE</div>
</div>
<div className="team-stat">
<div className="team-stat-value">{teamShape.programme_fte.toFixed(2)}</div>
<div className="team-stat-label">Programme FTE</div>
</div>
<div className="team-stat">
<div className="team-stat-value">{teamShape.total_hours.toLocaleString()}</div>
<div className="team-stat-label">Total Hours</div>
</div>
<div className="team-stat">
<div className="team-stat-value">1,800</div>
<div className="team-stat-label">Hours / FTE</div>
</div>
</div>
<div className="table-wrap">
<table className="rc-table">
<thead>
<tr>
<th>Discipline</th>
<th>Role</th>
<th>Type</th>
<th className="text-right">Total Hours</th>
<th className="text-right">FTE</th>
<th className="text-right">Headcount</th>
</tr>
</thead>
<tbody>
{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 && (
<tr key={`disc-${r.discipline}`} className="disc-row">
<td colSpan={6} className="td-disc-header">{r.discipline}</td>
</tr>
)}
<tr key={r.role_id} className={r.is_programme_role ? 'programme-row' : ''}>
<td className="td-discipline">{r.discipline}</td>
<td>{r.role_title}</td>
<td className={r.is_programme_role ? 'td-programme' : 'td-delivery'}>
{r.is_programme_role ? 'Programme' : 'Delivery'}
</td>
<td className="text-right">{r.total_hours.toFixed(2)}</td>
<td className={`text-right ${r.fte >= 1 ? 'td-fte-highlight' : ''}`}>
{r.fte.toFixed(2)}
</td>
<td className="text-right td-total">{headcount.toFixed(1)}</td>
</tr>
</>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
);
}