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:
parent
26d3435be0
commit
0a5b552ad2
5 changed files with 380 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
74
backend/app/services/team_shape.py
Normal file
74
backend/app/services/team_shape.py
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue