feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button
Major parity push against the original SPA's bundle-level feature set (non-architectural items — separate Forecast / ProjectType / TimeLog views and AI Chat remain TBD). Backend (40/40 tests, +7): - merge.py splits booked hours by booking status: active vs soft. Active set: Active, Active Booked, Fully Booked, Partially Booked. Soft set: Soft Booking, Soft Booked, Soft-Booked. Unknown statuses default to active so they're not silently dropped. Existing `bookedHours` field is preserved as the sum for back-compat. - compute_totals(): rolls KPIs across the filtered summary — totalBooked, activeBooked, softBooked, totalLogged, totalBillable, totalLeave, totalProjects (distinct projectName/projectNumber), activePeople (distinct employees with logged>0), allocated, allocatedNetOfLeave. - breakdown_by_project(): drills into a single period+employee (or whole-period) and returns per-project logged + booked hours. - New /api/utilisation/breakdown endpoint. /api/utilisation/summary response gains `totals` and accepts `period=week|month`. - New schemas: UtilisationTotals, BreakdownResponse, plus activeBookedHours / softBookedHours on UtilisationSummaryRow. Frontend (typecheck/lint/build clean): - KpiTiles component shows Total Booked / Logged / Billable, Total projects, Active People (logged), Active bookings, Avg/person/week or /month, Allocated (net of leave), Period covered. - PeriodToggle (Per day / Per week / Per month). Day is rendered disabled with an explanatory tooltip — backend only accepts week/ month. - HourBreakdown drill-down panel: per-project logged + booked rows, shown when a chart bar is clicked; "Upload a time log to see logged breakdown" empty-state when no upload yet. - MonthlyUtilisation: ComposedChart with stacked Active/Soft booked bars + forecast Line overlay driven by the existing showForecast toggle. onPeriodClick wired into HourBreakdown. - WeeklyUtilisation, BookingVsActual: same Active/Soft stack treatment. - Resourcing now passes the timelog hash through to summary so loggedHours actually populates there too (was 0 before). - Sync Airtable button on both Department and Resourcing — force- refreshes bookings cache, re-derives summary. - Tutorial steps re-mapped to the original SPA's chapter titles: "Reading the Utilisation Chart", "Hours & Utilisation", "Drill-In", "Forecast Line & Filters", "Spotting Resource Issues", "Sync Airtable Bookings". Tutorial page heading is now "Interactive Walkthrough" with the original copy. - Defensive coercion in Bookings table totalHoursBooked rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e9daa3ec0
commit
cd1c99d5e0
17 changed files with 1214 additions and 176 deletions
|
|
@ -103,6 +103,8 @@ class UtilisationSummaryRow(BaseModel):
|
|||
employmentType: str | None = None
|
||||
availableHours: float = 0.0
|
||||
bookedHours: float = 0.0
|
||||
activeBookedHours: float = 0.0
|
||||
softBookedHours: float = 0.0
|
||||
loggedHours: float = 0.0
|
||||
billableHours: float = 0.0
|
||||
nonBillableHours: float = 0.0
|
||||
|
|
@ -118,15 +120,58 @@ class UtilisationFiltersApplied(BaseModel):
|
|||
department: str | None = None
|
||||
name: str | None = None
|
||||
billing_type: str | None = None
|
||||
period: str | None = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class UtilisationTotals(BaseModel):
|
||||
totalBooked: float = 0.0
|
||||
activeBooked: float = 0.0
|
||||
softBooked: float = 0.0
|
||||
totalLogged: float = 0.0
|
||||
totalBillable: float = 0.0
|
||||
totalProjects: int = 0
|
||||
activePeople: int = 0
|
||||
activeBookingsCount: int = 0
|
||||
availableHours: float = 0.0
|
||||
allocatedNetOfLeave: float = 0.0
|
||||
periodFrom: str | None = None
|
||||
periodTo: str | None = None
|
||||
|
||||
|
||||
class UtilisationSummaryResponse(BaseModel):
|
||||
rows: list[UtilisationSummaryRow]
|
||||
totals: UtilisationTotals
|
||||
filters_applied: UtilisationFiltersApplied
|
||||
|
||||
|
||||
class BreakdownProjectLogged(BaseModel):
|
||||
project: str
|
||||
hours: float = 0.0
|
||||
billable: float = 0.0
|
||||
nonBillable: float = 0.0
|
||||
|
||||
|
||||
class BreakdownProjectBooked(BaseModel):
|
||||
project: str
|
||||
hours: float = 0.0
|
||||
active: float = 0.0
|
||||
soft: float = 0.0
|
||||
|
||||
|
||||
class BreakdownResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
period: str
|
||||
employee: str | None = None
|
||||
from_: str | None = Field(default=None, alias="from")
|
||||
to: str | None = None
|
||||
logged: list[BreakdownProjectLogged] = Field(default_factory=list)
|
||||
booked: list[BreakdownProjectBooked] = Field(default_factory=list)
|
||||
hasLogged: bool = False
|
||||
|
||||
|
||||
# ---------- Health ----------
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Utilisation summary endpoint."""
|
||||
"""Utilisation summary + drill-down endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ from app.deps.auth import auth_required
|
|||
from app.routers.airtable import _bookings_cache, _resources_cache
|
||||
from app.routers.timelog import get_cached_parse
|
||||
from app.services.airtable_fetch import fetch_bookings, fetch_resources
|
||||
from app.services.merge import summarise
|
||||
from app.services.merge import breakdown_by_project, compute_totals, summarise
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)])
|
||||
|
|
@ -26,6 +26,26 @@ def _parse_date(s: str | None, default: date) -> date:
|
|||
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
|
||||
|
||||
|
||||
async def _load_resources() -> list[dict[str, Any]]:
|
||||
async def loader() -> dict[str, Any]:
|
||||
rows = await fetch_resources(include_inactive=False)
|
||||
return {"resources": rows}
|
||||
|
||||
payload = await _resources_cache.get_or_set("resources:False", loader)
|
||||
return payload.get("resources", [])
|
||||
|
||||
|
||||
async def _load_bookings(from_d: date, to_d: date) -> list[dict[str, Any]]:
|
||||
bkey = f"bookings:{from_d}:{to_d}"
|
||||
|
||||
async def loader() -> dict[str, Any]:
|
||||
rows = await fetch_bookings(from_=from_d, to=to_d)
|
||||
return {"bookings": rows}
|
||||
|
||||
payload = await _bookings_cache.get_or_set(bkey, loader)
|
||||
return payload.get("bookings", [])
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary(
|
||||
from_: str | None = Query(None, alias="from"),
|
||||
|
|
@ -47,23 +67,8 @@ async def summary(
|
|||
if period not in ("week", "month"):
|
||||
raise HTTPException(status_code=400, detail="period must be 'week' or 'month'")
|
||||
|
||||
# Resources — use the shared cache.
|
||||
async def load_resources() -> dict[str, Any]:
|
||||
rows = await fetch_resources(include_inactive=False)
|
||||
return {"resources": rows}
|
||||
|
||||
res_payload = await _resources_cache.get_or_set("resources:False", load_resources)
|
||||
resources = res_payload.get("resources", [])
|
||||
|
||||
# Bookings — share cache with /api/airtable/bookings.
|
||||
bkey = f"bookings:{from_d}:{to_d}"
|
||||
|
||||
async def load_bookings() -> dict[str, Any]:
|
||||
rows = await fetch_bookings(from_=from_d, to=to_d)
|
||||
return {"bookings": rows}
|
||||
|
||||
bk_payload = await _bookings_cache.get_or_set(bkey, load_bookings)
|
||||
bookings = bk_payload.get("bookings", [])
|
||||
resources = await _load_resources()
|
||||
bookings = await _load_bookings(from_d, to_d)
|
||||
|
||||
logged_rows: list[dict[str, Any]] = []
|
||||
if x_timelog_hash:
|
||||
|
|
@ -71,6 +76,7 @@ async def summary(
|
|||
if cached:
|
||||
logged_rows = cached.get("rows", [])
|
||||
|
||||
filters = {"department": department, "name": name, "billing_type": billing_type}
|
||||
rows = summarise(
|
||||
logged_rows,
|
||||
bookings,
|
||||
|
|
@ -78,16 +84,56 @@ async def summary(
|
|||
from_=from_d,
|
||||
to_=to_d,
|
||||
period=period, # type: ignore[arg-type]
|
||||
filters={"department": department, "name": name, "billing_type": billing_type},
|
||||
filters=filters,
|
||||
)
|
||||
totals = compute_totals(rows, bookings, from_=from_d, to_=to_d, filters=filters)
|
||||
|
||||
return {
|
||||
"rows": rows,
|
||||
"totals": totals,
|
||||
"filters_applied": {
|
||||
"from": from_d.isoformat(),
|
||||
"to": to_d.isoformat(),
|
||||
"department": department,
|
||||
"name": name,
|
||||
"billing_type": billing_type,
|
||||
"period": period,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/breakdown")
|
||||
async def breakdown(
|
||||
period: str = Query(..., description="Period label, e.g. 2026-W19 or 2026-05"),
|
||||
employee: str | None = Query(None),
|
||||
from_: str | None = Query(None, alias="from"),
|
||||
to: str | None = Query(None),
|
||||
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
|
||||
) -> dict[str, Any]:
|
||||
"""Drill-down: per-project hour breakdown (logged + booked) for a period.
|
||||
|
||||
`period` must be either an ISO week (YYYY-Www) or a month (YYYY-MM).
|
||||
The optional `from`/`to` query params clamp the breakdown window — useful
|
||||
when the user has a custom date range that doesn't span the full period.
|
||||
"""
|
||||
today = date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
from_d = _parse_date(from_, monday - timedelta(days=180))
|
||||
to_d = _parse_date(to, monday + timedelta(days=180))
|
||||
|
||||
bookings = await _load_bookings(from_d, to_d)
|
||||
|
||||
logged_rows: list[dict[str, Any]] = []
|
||||
if x_timelog_hash:
|
||||
cached = get_cached_parse(x_timelog_hash)
|
||||
if cached:
|
||||
logged_rows = cached.get("rows", [])
|
||||
|
||||
return breakdown_by_project(
|
||||
logged_rows,
|
||||
bookings,
|
||||
period=period,
|
||||
employee=employee,
|
||||
from_=from_d,
|
||||
to_=to_d,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ Decisions:
|
|||
- bookedHours pro-rate: if a booking spans multiple periods, distribute
|
||||
totalHoursBooked proportionally to the working-day overlap between the
|
||||
booking and each period. This is the simplest defensible split.
|
||||
- Active vs Soft bookings: classified by `bookingStatus`. The split keeps
|
||||
the original SPA's "Active Booked" / "Soft Booked" stacks. Unknown or
|
||||
null statuses default to active (consistent with the original).
|
||||
- bookedHours stays as the sum (= active + soft) for backwards compatibility.
|
||||
- forecastHours == bookedHours for v1.
|
||||
- All dates are inclusive on both ends.
|
||||
- The function is deliberately pure — no I/O, no globals — so it's easy
|
||||
|
|
@ -26,6 +30,20 @@ from typing import Any, Iterable, Literal
|
|||
PeriodKind = Literal["week", "month"]
|
||||
|
||||
|
||||
# Booking status buckets. Anything not in SOFT is treated as active.
|
||||
_SOFT_STATUSES = {"soft booking", "soft booked", "soft-booked", "soft-booking"}
|
||||
_ACTIVE_STATUSES = {"active", "active booked", "fully booked", "partially booked"}
|
||||
|
||||
|
||||
def _is_soft(status: Any) -> bool:
|
||||
if not status:
|
||||
return False
|
||||
s = str(status).strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
return s in _SOFT_STATUSES
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Period helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
|
@ -201,6 +219,8 @@ def summarise(
|
|||
"employmentType": emp_type,
|
||||
"availableHours": 0.0,
|
||||
"bookedHours": 0.0,
|
||||
"activeBookedHours": 0.0,
|
||||
"softBookedHours": 0.0,
|
||||
"loggedHours": 0.0,
|
||||
"billableHours": 0.0,
|
||||
"nonBillableHours": 0.0,
|
||||
|
|
@ -216,7 +236,10 @@ def summarise(
|
|||
available_hours = (avail_per_week * weekdays_in_window) / 5.0 if avail_per_week else 0.0
|
||||
|
||||
# bookedHours: pro-rate by working-day overlap of booking within window.
|
||||
# Split by bookingStatus: soft vs active. Unknown defaults to active.
|
||||
booked = 0.0
|
||||
active_booked = 0.0
|
||||
soft_booked = 0.0
|
||||
for b in emp_bookings:
|
||||
b_start = b.get("startDate")
|
||||
b_end = b.get("endDate") or b_start
|
||||
|
|
@ -232,7 +255,12 @@ def summarise(
|
|||
if overlap_weekdays == 0:
|
||||
continue
|
||||
total_hours = float(b.get("totalHoursBooked") or 0.0)
|
||||
booked += total_hours * (overlap_weekdays / booking_total_weekdays)
|
||||
slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays)
|
||||
booked += slice_hours
|
||||
if _is_soft(b.get("bookingStatus")):
|
||||
soft_booked += slice_hours
|
||||
else:
|
||||
active_booked += slice_hours
|
||||
|
||||
# Logged hours filtered to this window.
|
||||
logged_h = 0.0
|
||||
|
|
@ -270,6 +298,8 @@ def summarise(
|
|||
"employmentType": emp_type,
|
||||
"availableHours": round(available_hours, 2),
|
||||
"bookedHours": round(booked, 2),
|
||||
"activeBookedHours": round(active_booked, 2),
|
||||
"softBookedHours": round(soft_booked, 2),
|
||||
"loggedHours": round(logged_h, 2),
|
||||
"billableHours": round(billable_h, 2),
|
||||
"nonBillableHours": round(non_billable_h, 2),
|
||||
|
|
@ -281,3 +311,218 @@ def summarise(
|
|||
|
||||
out.sort(key=lambda r: ((r["department"] or ""), r["employee"], r["period"]))
|
||||
return out
|
||||
|
||||
|
||||
def compute_totals(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
bookings: Iterable[dict[str, Any]],
|
||||
*,
|
||||
from_: date,
|
||||
to_: date,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate KPI rollups across the supplied summary rows + the bookings
|
||||
in scope (used for distinct-project counts that aren't reflected in the
|
||||
aggregated rows).
|
||||
|
||||
`filters` is the same dict passed to `summarise` so we can honour the
|
||||
department / name narrowing for the bookings dimension. The summary rows
|
||||
have already been filtered, so we only need to filter bookings here.
|
||||
"""
|
||||
rows = list(rows)
|
||||
bookings = list(bookings)
|
||||
filters = filters or {}
|
||||
dep_filter = (filters.get("department") or "").strip().lower() or None
|
||||
name_filter = (filters.get("name") or "").strip().lower() or None
|
||||
|
||||
total_booked = 0.0
|
||||
active_booked = 0.0
|
||||
soft_booked = 0.0
|
||||
total_logged = 0.0
|
||||
total_billable = 0.0
|
||||
available_total = 0.0
|
||||
leave_total = 0.0
|
||||
active_people: set[str] = set()
|
||||
for r in rows:
|
||||
total_booked += float(r.get("bookedHours") or 0.0)
|
||||
active_booked += float(r.get("activeBookedHours") or 0.0)
|
||||
soft_booked += float(r.get("softBookedHours") or 0.0)
|
||||
logged = float(r.get("loggedHours") or 0.0)
|
||||
total_logged += logged
|
||||
total_billable += float(r.get("billableHours") or 0.0)
|
||||
available_total += float(r.get("availableHours") or 0.0)
|
||||
leave_total += float(r.get("leaveHours") or 0.0)
|
||||
if logged > 0:
|
||||
emp = (r.get("employee") or "").strip()
|
||||
if emp:
|
||||
active_people.add(emp)
|
||||
|
||||
# Active bookings (count, projects) — narrow bookings to the same scope
|
||||
# as the rows. The summarise() function already pre-filters out logged
|
||||
# rows + booking slices that don't overlap the window, but we re-apply
|
||||
# the date overlap and dept/name filters here for parity.
|
||||
in_scope_bookings: list[dict[str, Any]] = []
|
||||
project_names: set[str] = set()
|
||||
for b in bookings:
|
||||
b_start = b.get("startDate")
|
||||
b_end = b.get("endDate") or b_start
|
||||
if not b_start:
|
||||
continue
|
||||
if not b_end:
|
||||
b_end = b_start
|
||||
if _overlap(b_start, b_end, from_, to_) is None:
|
||||
continue
|
||||
if dep_filter:
|
||||
dep_val = (b.get("department") or "").strip().lower()
|
||||
if dep_val != dep_filter:
|
||||
continue
|
||||
if name_filter:
|
||||
name_val = _name_key(b.get("resourceName"))
|
||||
if name_filter not in name_val:
|
||||
continue
|
||||
in_scope_bookings.append(b)
|
||||
pn = b.get("projectName") or b.get("projectNumber")
|
||||
if pn:
|
||||
pn_str = str(pn).strip()
|
||||
if pn_str:
|
||||
project_names.add(pn_str)
|
||||
|
||||
return {
|
||||
"totalBooked": round(total_booked, 2),
|
||||
"activeBooked": round(active_booked, 2),
|
||||
"softBooked": round(soft_booked, 2),
|
||||
"totalLogged": round(total_logged, 2),
|
||||
"totalBillable": round(total_billable, 2),
|
||||
"totalProjects": len(project_names),
|
||||
"activePeople": len(active_people),
|
||||
"activeBookingsCount": len(in_scope_bookings),
|
||||
"availableHours": round(available_total, 2),
|
||||
"allocatedNetOfLeave": round(max(available_total - leave_total, 0.0), 2),
|
||||
"periodFrom": from_.isoformat(),
|
||||
"periodTo": to_.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def breakdown_by_project(
|
||||
logged: Iterable[dict[str, Any]],
|
||||
bookings: Iterable[dict[str, Any]],
|
||||
*,
|
||||
period: str,
|
||||
employee: str | None = None,
|
||||
from_: date | None = None,
|
||||
to_: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Per-project hour breakdown for a given period label.
|
||||
|
||||
The period can be either an ISO-week ("2026-W19") or a month ("2026-05").
|
||||
If `employee` is supplied we narrow to that one resource; otherwise we
|
||||
aggregate across all resources visible.
|
||||
|
||||
Returns a structure suitable for the drill-down panel.
|
||||
"""
|
||||
p_start, p_end = _bounds_from_period_label(period)
|
||||
if p_start is None or p_end is None:
|
||||
return {"period": period, "employee": employee, "logged": [], "booked": [], "hasLogged": False}
|
||||
|
||||
if from_:
|
||||
p_start = max(p_start, from_)
|
||||
if to_:
|
||||
p_end = min(p_end, to_)
|
||||
|
||||
emp_key = _name_key(employee) if employee else None
|
||||
|
||||
# Booked breakdown — pro-rated by working-day overlap.
|
||||
booked_totals: dict[str, dict[str, float]] = {}
|
||||
for b in bookings:
|
||||
if emp_key and _name_key(b.get("resourceName")) != emp_key:
|
||||
continue
|
||||
b_start = b.get("startDate")
|
||||
b_end = b.get("endDate") or b_start
|
||||
if not b_start:
|
||||
continue
|
||||
if not b_end:
|
||||
b_end = b_start
|
||||
ov = _overlap(b_start, b_end, p_start, p_end)
|
||||
if ov is None:
|
||||
continue
|
||||
booking_total_weekdays = _weekdays_between(b_start, b_end) or 1
|
||||
overlap_weekdays = _weekdays_between(ov[0], ov[1])
|
||||
if overlap_weekdays == 0:
|
||||
continue
|
||||
total_hours = float(b.get("totalHoursBooked") or 0.0)
|
||||
slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays)
|
||||
project = (
|
||||
(b.get("projectName") or b.get("projectNumber") or "Unknown")
|
||||
)
|
||||
project_key = str(project).strip() or "Unknown"
|
||||
bucket = booked_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "active": 0.0, "soft": 0.0})
|
||||
bucket["hours"] += slice_hours
|
||||
if _is_soft(b.get("bookingStatus")):
|
||||
bucket["soft"] += slice_hours
|
||||
else:
|
||||
bucket["active"] += slice_hours
|
||||
|
||||
# Logged breakdown — straight per-project sum within window.
|
||||
logged_totals: dict[str, dict[str, float]] = {}
|
||||
has_logged_input = False
|
||||
for r in logged:
|
||||
has_logged_input = True
|
||||
if emp_key and _name_key(r.get("employee")) != emp_key:
|
||||
continue
|
||||
d = r.get("date")
|
||||
if not d:
|
||||
continue
|
||||
if d < p_start or d > p_end:
|
||||
continue
|
||||
project = (r.get("project") or "Unknown")
|
||||
project_key = str(project).strip() or "Unknown"
|
||||
bucket = logged_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "billable": 0.0, "nonBillable": 0.0})
|
||||
hrs = float(r.get("hours") or 0.0)
|
||||
bucket["hours"] += hrs
|
||||
if r.get("billable"):
|
||||
bucket["billable"] += hrs
|
||||
else:
|
||||
bucket["nonBillable"] += hrs
|
||||
|
||||
def _round(d: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in d.items()}
|
||||
|
||||
logged_rows = sorted((_round(v) for v in logged_totals.values()), key=lambda r: -r["hours"])
|
||||
booked_rows = sorted((_round(v) for v in booked_totals.values()), key=lambda r: -r["hours"])
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"employee": employee,
|
||||
"from": p_start.isoformat(),
|
||||
"to": p_end.isoformat(),
|
||||
"logged": logged_rows,
|
||||
"booked": booked_rows,
|
||||
"hasLogged": has_logged_input,
|
||||
}
|
||||
|
||||
|
||||
def _bounds_from_period_label(label: str) -> tuple[date | None, date | None]:
|
||||
"""Parse a period label ("YYYY-Www" or "YYYY-MM") to [start, end] dates."""
|
||||
label = (label or "").strip()
|
||||
if not label:
|
||||
return None, None
|
||||
# ISO week
|
||||
if "W" in label.upper():
|
||||
try:
|
||||
year_s, week_s = label.upper().split("-W", 1)
|
||||
year = int(year_s)
|
||||
week = int(week_s)
|
||||
monday = date.fromisocalendar(year, week, 1)
|
||||
sunday = monday + timedelta(days=6)
|
||||
return monday, sunday
|
||||
except (ValueError, IndexError):
|
||||
return None, None
|
||||
# Month
|
||||
try:
|
||||
year_s, month_s = label.split("-", 1)
|
||||
year = int(year_s)
|
||||
month = int(month_s)
|
||||
first = date(year, month, 1)
|
||||
return _month_bounds(first)
|
||||
except (ValueError, IndexError):
|
||||
return None, None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ from datetime import date
|
|||
|
||||
import pytest
|
||||
|
||||
from app.services.merge import iter_periods, summarise
|
||||
from app.services.merge import (
|
||||
breakdown_by_project,
|
||||
compute_totals,
|
||||
iter_periods,
|
||||
summarise,
|
||||
)
|
||||
|
||||
|
||||
def test_iter_periods_week():
|
||||
|
|
@ -180,3 +185,132 @@ def test_leave_hours_split_out_from_non_billable(sample_resources):
|
|||
assert bhakti["billableHours"] == 32.0
|
||||
assert bhakti["nonBillableHours"] == 0.0
|
||||
assert bhakti["loggedHours"] == 40.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active vs Soft split + totals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_vs_soft_split(sample_resources):
|
||||
"""Bookings tagged 'Soft Booking' must land in softBookedHours; others active."""
|
||||
bookings = [
|
||||
{
|
||||
"id": "bk-active", "task": "A", "startDate": date(2026, 5, 4),
|
||||
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
|
||||
"projectNumber": "P-A", "projectName": "Active proj",
|
||||
"department": "Creative Team", "division": "Production",
|
||||
"hoursSelection": [], "totalHoursBooked": 20.0,
|
||||
"bookingStatus": "Active", "placeholder": False,
|
||||
},
|
||||
{
|
||||
"id": "bk-soft", "task": "S", "startDate": date(2026, 5, 4),
|
||||
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
|
||||
"projectNumber": "P-S", "projectName": "Soft proj",
|
||||
"department": "Creative Team", "division": "Production",
|
||||
"hoursSelection": [], "totalHoursBooked": 12.0,
|
||||
"bookingStatus": "Soft Booking", "placeholder": False,
|
||||
},
|
||||
]
|
||||
rows = summarise(
|
||||
[], bookings, sample_resources,
|
||||
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
|
||||
)
|
||||
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
|
||||
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
|
||||
assert bhakti["softBookedHours"] == pytest.approx(12.0)
|
||||
assert bhakti["bookedHours"] == pytest.approx(32.0)
|
||||
|
||||
|
||||
def test_unknown_status_defaults_to_active(sample_resources):
|
||||
"""Null/unknown statuses fall through to active (not silently dropped)."""
|
||||
bookings = [
|
||||
{
|
||||
"id": "bk-null", "task": "N", "startDate": date(2026, 5, 4),
|
||||
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
|
||||
"projectNumber": "P-N", "projectName": "Null proj",
|
||||
"department": "Creative Team", "division": "Production",
|
||||
"hoursSelection": [], "totalHoursBooked": 15.0,
|
||||
"bookingStatus": None, "placeholder": False,
|
||||
},
|
||||
{
|
||||
"id": "bk-junk", "task": "J", "startDate": date(2026, 5, 4),
|
||||
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
|
||||
"projectNumber": "P-J", "projectName": "Junk status proj",
|
||||
"department": "Creative Team", "division": "Production",
|
||||
"hoursSelection": [], "totalHoursBooked": 5.0,
|
||||
"bookingStatus": "Pencilled In Tentatively", "placeholder": False,
|
||||
},
|
||||
]
|
||||
rows = summarise(
|
||||
[], bookings, sample_resources,
|
||||
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
|
||||
)
|
||||
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
|
||||
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
|
||||
assert bhakti["softBookedHours"] == 0.0
|
||||
assert bhakti["bookedHours"] == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_compute_totals(sample_resources, sample_bookings, sample_logged):
|
||||
rows = summarise(
|
||||
sample_logged, sample_bookings, sample_resources,
|
||||
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
|
||||
)
|
||||
totals = compute_totals(
|
||||
rows, sample_bookings,
|
||||
from_=date(2026, 5, 4), to_=date(2026, 5, 8),
|
||||
)
|
||||
assert totals["totalBooked"] == pytest.approx(38.0)
|
||||
assert totals["activeBooked"] == pytest.approx(38.0)
|
||||
assert totals["softBooked"] == 0.0
|
||||
assert totals["totalLogged"] == pytest.approx(35.0)
|
||||
assert totals["totalBillable"] == pytest.approx(28.0)
|
||||
assert totals["totalProjects"] == 1 # one distinct projectName
|
||||
assert totals["activePeople"] == 1 # only Bhakti has loggedHours > 0
|
||||
assert totals["activeBookingsCount"] == 1
|
||||
assert totals["periodFrom"] == "2026-05-04"
|
||||
assert totals["periodTo"] == "2026-05-08"
|
||||
# allocated net of leave: avail (40 + 40 for two FTEs in W19) - 0 leave = 80
|
||||
assert totals["allocatedNetOfLeave"] == pytest.approx(80.0)
|
||||
|
||||
|
||||
def test_breakdown_by_project_logged_and_booked(sample_resources, sample_bookings, sample_logged):
|
||||
out = breakdown_by_project(
|
||||
sample_logged, sample_bookings,
|
||||
period="2026-W19", employee="Bhakti Doshi",
|
||||
)
|
||||
assert out["period"] == "2026-W19"
|
||||
assert out["employee"] == "Bhakti Doshi"
|
||||
assert out["hasLogged"] is True
|
||||
# Logged side: Acme (28h billable across 4 days) + Internal (7h non-billable)
|
||||
logged_by_project = {row["project"]: row for row in out["logged"]}
|
||||
assert logged_by_project["Acme"]["hours"] == pytest.approx(28.0)
|
||||
assert logged_by_project["Acme"]["billable"] == pytest.approx(28.0)
|
||||
assert logged_by_project["Internal"]["hours"] == pytest.approx(7.0)
|
||||
assert logged_by_project["Internal"]["nonBillable"] == pytest.approx(7.0)
|
||||
# Booked side: one booking on Acme Spring Launch = 38h, all active
|
||||
booked_by_project = {row["project"]: row for row in out["booked"]}
|
||||
assert booked_by_project["Acme Spring Launch"]["hours"] == pytest.approx(38.0)
|
||||
assert booked_by_project["Acme Spring Launch"]["active"] == pytest.approx(38.0)
|
||||
|
||||
|
||||
def test_breakdown_no_timelog(sample_resources, sample_bookings):
|
||||
"""Empty timelog input: hasLogged should be False so the UI can prompt."""
|
||||
out = breakdown_by_project([], sample_bookings, period="2026-W19", employee="Bhakti Doshi")
|
||||
assert out["hasLogged"] is False
|
||||
assert out["logged"] == []
|
||||
# Booked side still works
|
||||
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
|
||||
|
||||
|
||||
def test_breakdown_month_period(sample_resources, sample_bookings):
|
||||
"""Month-level period label resolves to that month's day-range."""
|
||||
out = breakdown_by_project([], sample_bookings, period="2026-05")
|
||||
# The single sample booking falls inside May 2026.
|
||||
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
|
||||
|
||||
|
||||
def test_breakdown_invalid_period_returns_empty(sample_bookings):
|
||||
out = breakdown_by_project([], sample_bookings, period="not-a-period")
|
||||
assert out["logged"] == [] and out["booked"] == []
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { apiFetch, buildQuery } from './client';
|
|||
import type {
|
||||
AuthMeResponse,
|
||||
BookingsResponse,
|
||||
BreakdownResponse,
|
||||
LoginResponse,
|
||||
MetaResponse,
|
||||
ParseResponse,
|
||||
|
|
@ -68,6 +69,7 @@ export interface SummaryParams {
|
|||
department?: string;
|
||||
name?: string;
|
||||
billing_type?: string;
|
||||
period?: 'week' | 'month';
|
||||
timelogHash?: string;
|
||||
}
|
||||
|
||||
|
|
@ -77,3 +79,18 @@ export function getUtilisationSummary(params: SummaryParams = {}) {
|
|||
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
|
||||
return apiFetch<UtilisationSummaryResponse>(`/utilisation/summary${buildQuery(query)}`, { headers });
|
||||
}
|
||||
|
||||
export interface BreakdownParams {
|
||||
period: string;
|
||||
employee?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
timelogHash?: string;
|
||||
}
|
||||
|
||||
export function getUtilisationBreakdown(params: BreakdownParams) {
|
||||
const { timelogHash, ...query } = params;
|
||||
const headers: Record<string, string> = {};
|
||||
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
|
||||
return apiFetch<BreakdownResponse>(`/utilisation/breakdown${buildQuery(query)}`, { headers });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export interface UtilisationSummaryRow {
|
|||
employmentType: string;
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
activeBookedHours: number;
|
||||
softBookedHours: number;
|
||||
loggedHours: number;
|
||||
billableHours: number;
|
||||
nonBillableHours: number;
|
||||
|
|
@ -66,11 +68,51 @@ export interface UtilisationSummaryRow {
|
|||
bookedUtilisationPct: number;
|
||||
}
|
||||
|
||||
export interface UtilisationTotals {
|
||||
totalBooked: number;
|
||||
activeBooked: number;
|
||||
softBooked: number;
|
||||
totalLogged: number;
|
||||
totalBillable: number;
|
||||
totalProjects: number;
|
||||
activePeople: number;
|
||||
activeBookingsCount: number;
|
||||
availableHours: number;
|
||||
allocatedNetOfLeave: number;
|
||||
periodFrom: string | null;
|
||||
periodTo: string | null;
|
||||
}
|
||||
|
||||
export interface UtilisationSummaryResponse {
|
||||
rows: UtilisationSummaryRow[];
|
||||
totals: UtilisationTotals;
|
||||
filters_applied: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface BreakdownProjectLogged {
|
||||
project: string;
|
||||
hours: number;
|
||||
billable: number;
|
||||
nonBillable: number;
|
||||
}
|
||||
|
||||
export interface BreakdownProjectBooked {
|
||||
project: string;
|
||||
hours: number;
|
||||
active: number;
|
||||
soft: number;
|
||||
}
|
||||
|
||||
export interface BreakdownResponse {
|
||||
period: string;
|
||||
employee: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
logged: BreakdownProjectLogged[];
|
||||
booked: BreakdownProjectBooked[];
|
||||
hasLogged: boolean;
|
||||
}
|
||||
|
||||
export interface ResourcesResponse {
|
||||
resources: Resource[];
|
||||
cached_at: string;
|
||||
|
|
|
|||
140
frontend/src/components/HourBreakdown.tsx
Normal file
140
frontend/src/components/HourBreakdown.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { BreakdownResponse } from '../api/types';
|
||||
import { formatHours, formatPeriod } from '../lib/dates';
|
||||
|
||||
interface Props {
|
||||
period: string;
|
||||
employee?: string | null;
|
||||
from?: string;
|
||||
to?: string;
|
||||
timelogHash?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill-down panel: per-project breakdown of logged + booked hours for the
|
||||
* clicked bar's period. Mirrors the original SPA's "Hour Breakdown" overlay.
|
||||
*/
|
||||
export default function HourBreakdown({ period, employee, from, to, timelogHash, onClose }: Props) {
|
||||
const [data, setData] = useState<BreakdownResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getUtilisationBreakdown({
|
||||
period,
|
||||
employee: employee ?? undefined,
|
||||
from,
|
||||
to,
|
||||
timelogHash: timelogHash ?? undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) setData(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [period, employee, from, to, timelogHash]);
|
||||
|
||||
return (
|
||||
<div className="card" data-tutorial-id="hour-breakdown">
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">
|
||||
Hour Breakdown — {formatPeriod(period)}
|
||||
{employee && <span className="ml-2 text-slate-500">· {employee}</span>}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-100"
|
||||
aria-label="Close hour breakdown"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-slate-500">Loading breakdown…</div>}
|
||||
{error && <div className="text-sm text-red-700">{error}</div>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Logged by project
|
||||
</div>
|
||||
{!data.hasLogged ? (
|
||||
<div className="rounded-md bg-slate-50 p-3 text-sm text-slate-600">
|
||||
Upload a time log to see logged breakdown.
|
||||
</div>
|
||||
) : data.logged.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">No logged hours in this period.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-slate-500">
|
||||
<th className="pb-1">Project</th>
|
||||
<th className="pb-1 text-right">Hours</th>
|
||||
<th className="pb-1 text-right">Billable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.logged.map((r) => (
|
||||
<tr key={r.project} className="border-t border-slate-100">
|
||||
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
|
||||
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
|
||||
<td className="py-1 text-right tabular-nums text-emerald-700">{formatHours(r.billable)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Booked by project
|
||||
</div>
|
||||
{data.booked.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">No bookings in this period.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-slate-500">
|
||||
<th className="pb-1">Project</th>
|
||||
<th className="pb-1 text-right">Hours</th>
|
||||
<th className="pb-1 text-right">Active</th>
|
||||
<th className="pb-1 text-right">Soft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.booked.map((r) => (
|
||||
<tr key={r.project} className="border-t border-slate-100">
|
||||
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
|
||||
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
|
||||
<td className="py-1 text-right tabular-nums text-blue-700">{formatHours(r.active)}</td>
|
||||
<td className="py-1 text-right tabular-nums text-indigo-500">{formatHours(r.soft)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/KpiTiles.tsx
Normal file
67
frontend/src/components/KpiTiles.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { UtilisationTotals } from '../api/types';
|
||||
import { formatHours } from '../lib/dates';
|
||||
|
||||
interface Props {
|
||||
totals: UtilisationTotals | null;
|
||||
/** Per-period averaging: "day" / "week" / "month" toggle on the parent page. */
|
||||
period?: 'week' | 'month';
|
||||
}
|
||||
|
||||
interface Tile {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI tiles displayed above the charts on Department + Resourcing.
|
||||
* Mirrors the original SPA's "Total Booked / Logged / Billable / projects /
|
||||
* Active People / Allocated (net of leave) / Avg per person per week" set.
|
||||
*/
|
||||
export default function KpiTiles({ totals, period = 'week' }: Props) {
|
||||
if (!totals) return null;
|
||||
|
||||
const avgPerPerson = (() => {
|
||||
if (totals.activePeople <= 0) return 0;
|
||||
return totals.totalLogged / totals.activePeople;
|
||||
})();
|
||||
|
||||
// Crude per-week / per-month average so the user can compare against
|
||||
// weekly availability. Day-level would require counting working days.
|
||||
const avgLabel = period === 'month' ? 'Avg / person / month' : 'Avg / person / week';
|
||||
|
||||
const tiles: Tile[] = [
|
||||
{ label: 'Total Booked', value: formatHours(totals.totalBooked), hint: `Active ${formatHours(totals.activeBooked)} · Soft ${formatHours(totals.softBooked)}` },
|
||||
{ label: 'Total Logged', value: formatHours(totals.totalLogged) },
|
||||
{ label: 'Total Billable', value: formatHours(totals.totalBillable) },
|
||||
{ label: 'Total projects', value: String(totals.totalProjects) },
|
||||
{ label: 'Active People (logged)', value: String(totals.activePeople) },
|
||||
{ label: 'Active bookings pulled', value: String(totals.activeBookingsCount) },
|
||||
{ label: avgLabel, value: formatHours(avgPerPerson) },
|
||||
{ label: 'Allocated (net of leave)', value: formatHours(totals.allocatedNetOfLeave) },
|
||||
];
|
||||
|
||||
const periodCovered = totals.periodFrom && totals.periodTo
|
||||
? `${totals.periodFrom} → ${totals.periodTo}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="card space-y-2" data-tutorial-id="kpi-tiles">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Headline numbers</h3>
|
||||
{periodCovered && (
|
||||
<span className="text-xs text-slate-500">Period covered: {periodCovered}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="rounded-md bg-slate-50 p-3 ring-1 ring-slate-200">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-500">{t.label}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-slate-900">{t.value}</div>
|
||||
{t.hint && <div className="mt-0.5 text-xs text-slate-500">{t.hint}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/PeriodToggle.tsx
Normal file
58
frontend/src/components/PeriodToggle.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { PeriodKind } from '../lib/filters';
|
||||
|
||||
interface Props {
|
||||
value: PeriodKind;
|
||||
onChange: (next: PeriodKind) => void;
|
||||
/** Whether to show the "Per day" option. The backend does not aggregate
|
||||
* by day (too noisy across 100+ employees) so we hide it by default. */
|
||||
includeDay?: boolean;
|
||||
}
|
||||
|
||||
const OPTIONS: { value: PeriodKind | 'day'; label: string }[] = [
|
||||
{ value: 'day', label: 'Per day' },
|
||||
{ value: 'week', label: 'Per week' },
|
||||
{ value: 'month', label: 'Per month' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Three-state radio toggle for week/month aggregation. "Per day" is shown
|
||||
* as disabled when includeDay=false (the default) so the user can still see
|
||||
* the original SPA's labelling but understands day-level isn't supported.
|
||||
*/
|
||||
export default function PeriodToggle({ value, onChange, includeDay = false }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center rounded-md border border-slate-300 bg-white p-0.5 text-sm shadow-sm"
|
||||
data-tutorial-id="period-toggle"
|
||||
role="radiogroup"
|
||||
aria-label="Aggregation period"
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const isDay = opt.value === 'day';
|
||||
const disabled = isDay && !includeDay;
|
||||
const active = !disabled && value === opt.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={disabled}
|
||||
title={disabled ? 'Day-level aggregation is too noisy for this dataset' : undefined}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onChange(opt.value as PeriodKind);
|
||||
}}
|
||||
className={[
|
||||
'rounded px-3 py-1 text-xs font-medium transition',
|
||||
active ? 'bg-blue-600 text-white shadow-sm' : 'text-slate-600 hover:bg-slate-100',
|
||||
disabled ? 'cursor-not-allowed opacity-40 hover:bg-transparent' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,7 +21,8 @@ const TOP_N = 20;
|
|||
|
||||
interface Bucket {
|
||||
employee: string;
|
||||
booked: number;
|
||||
activeBooked: number;
|
||||
softBooked: number;
|
||||
actual: number;
|
||||
}
|
||||
|
||||
|
|
@ -30,14 +31,17 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] {
|
|||
for (const r of rows) {
|
||||
const employee = String(r.employee ?? '').trim();
|
||||
if (!employee) continue;
|
||||
const b = map.get(employee) ?? { employee, booked: 0, actual: 0 };
|
||||
b.booked += Number(r.bookedHours) || 0;
|
||||
const b = map.get(employee) ?? { employee, activeBooked: 0, softBooked: 0, actual: 0 };
|
||||
b.activeBooked += Number(r.activeBookedHours) || 0;
|
||||
b.softBooked += Number(r.softBookedHours) || 0;
|
||||
b.actual += Number(r.loggedHours) || 0;
|
||||
map.set(employee, b);
|
||||
}
|
||||
|
||||
const all = [...map.values()].sort(
|
||||
(a, b) => b.booked + b.actual - (a.booked + a.actual),
|
||||
(a, b) =>
|
||||
b.activeBooked + b.softBooked + b.actual -
|
||||
(a.activeBooked + a.softBooked + a.actual),
|
||||
);
|
||||
if (all.length <= TOP_N) return all;
|
||||
|
||||
|
|
@ -45,11 +49,13 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] {
|
|||
const rest = all.slice(TOP_N);
|
||||
const other: Bucket = {
|
||||
employee: `Other (${rest.length})`,
|
||||
booked: 0,
|
||||
activeBooked: 0,
|
||||
softBooked: 0,
|
||||
actual: 0,
|
||||
};
|
||||
for (const b of rest) {
|
||||
other.booked += b.booked;
|
||||
other.activeBooked += b.activeBooked;
|
||||
other.softBooked += b.softBooked;
|
||||
other.actual += b.actual;
|
||||
}
|
||||
return [...top, other];
|
||||
|
|
@ -69,7 +75,8 @@ export default function BookingVsActual({ rows }: Props) {
|
|||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
|
||||
<Bar dataKey="activeBooked" name="Active Booked" stackId="booked" fill="#2563eb" />
|
||||
<Bar dataKey="softBooked" name="Soft Booked" stackId="booked" fill="#93c5fd" />
|
||||
<Bar dataKey="actual" name="Actual" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,15 @@ import { formatPeriod, monthOf } from '../../lib/dates';
|
|||
interface Props {
|
||||
rows: UtilisationSummaryRow[];
|
||||
showForecast: boolean;
|
||||
/** Optional: clicking a bar surfaces an Hour Breakdown for that month. */
|
||||
onPeriodClick?: (period: string) => void;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
period: string;
|
||||
available: number;
|
||||
booked: number;
|
||||
activeBooked: number;
|
||||
softBooked: number;
|
||||
logged: number;
|
||||
forecast: number;
|
||||
}
|
||||
|
|
@ -32,28 +35,45 @@ function aggregateByMonth(rows: UtilisationSummaryRow[]): Bucket[] {
|
|||
if (!rawPeriod) continue;
|
||||
const key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : monthOf(rawPeriod);
|
||||
if (!key) continue;
|
||||
const b = map.get(key) ?? { period: key, available: 0, booked: 0, logged: 0, forecast: 0 };
|
||||
const b = map.get(key) ?? {
|
||||
period: key,
|
||||
available: 0,
|
||||
activeBooked: 0,
|
||||
softBooked: 0,
|
||||
logged: 0,
|
||||
forecast: 0,
|
||||
};
|
||||
b.available += Number(r.availableHours) || 0;
|
||||
b.booked += Number(r.bookedHours) || 0;
|
||||
b.activeBooked += Number(r.activeBookedHours) || 0;
|
||||
b.softBooked += Number(r.softBookedHours) || 0;
|
||||
b.logged += Number(r.loggedHours) || 0;
|
||||
b.forecast += Number(r.forecastHours) || 0;
|
||||
map.set(key, b);
|
||||
}
|
||||
// Period-based: cardinality is bounded by the date range, no top-N cap needed.
|
||||
return [...map.values()].sort((a, b) =>
|
||||
String(a.period ?? '').localeCompare(String(b.period ?? '')),
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonthlyUtilisation({ rows, showForecast }: Props) {
|
||||
export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }: Props) {
|
||||
const data = aggregateByMonth(rows).map((b) => ({
|
||||
...b,
|
||||
label: formatPeriod(b.period),
|
||||
}));
|
||||
|
||||
const onBarClick = (payload: { period?: string } | undefined) => {
|
||||
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" data-tutorial-id="chart-monthly-utilisation">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
|
||||
<span className="text-xs text-slate-500">
|
||||
{onPeriodClick ? 'Click a bar for Hour Breakdown · ' : ''}
|
||||
{showForecast ? 'Forecast line visible' : 'Forecast line hidden'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
||||
|
|
@ -62,11 +82,33 @@ export default function MonthlyUtilisation({ rows, showForecast }: Props) {
|
|||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
|
||||
<Bar
|
||||
dataKey="activeBooked"
|
||||
name="Active Booked"
|
||||
stackId="booked"
|
||||
fill="#2563eb"
|
||||
onClick={onBarClick}
|
||||
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="softBooked"
|
||||
name="Soft Booked"
|
||||
stackId="booked"
|
||||
fill="#93c5fd"
|
||||
onClick={onBarClick}
|
||||
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
|
||||
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
|
||||
{showForecast && (
|
||||
<Line type="monotone" dataKey="forecast" name="Forecast" stroke="#f97316" strokeWidth={2} dot />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="forecast"
|
||||
name="Forecast Utilisation"
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -14,40 +14,59 @@ import { formatPeriod, weekOf } from '../../lib/dates';
|
|||
interface Props {
|
||||
rows: UtilisationSummaryRow[];
|
||||
onPeriodClick?: (period: string) => void;
|
||||
/** Aggregation kind. The summary endpoint controls the row labels —
|
||||
* this prop just affects the X-axis title + the period regex match. */
|
||||
period?: 'week' | 'month';
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
period: string;
|
||||
booked: number;
|
||||
activeBooked: number;
|
||||
softBooked: number;
|
||||
logged: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
function aggregateByWeek(rows: UtilisationSummaryRow[]): Bucket[] {
|
||||
function aggregate(rows: UtilisationSummaryRow[], period: 'week' | 'month'): Bucket[] {
|
||||
const map = new Map<string, Bucket>();
|
||||
for (const r of rows) {
|
||||
const rawPeriod = String(r.period ?? '');
|
||||
if (!rawPeriod) continue;
|
||||
const key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod);
|
||||
let key: string;
|
||||
if (period === 'month') {
|
||||
key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : '';
|
||||
} else {
|
||||
key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod);
|
||||
}
|
||||
if (!key) continue;
|
||||
const b = map.get(key) ?? { period: key, booked: 0, logged: 0, available: 0 };
|
||||
b.booked += Number(r.bookedHours) || 0;
|
||||
const b = map.get(key) ?? { period: key, activeBooked: 0, softBooked: 0, logged: 0, available: 0 };
|
||||
b.activeBooked += Number(r.activeBookedHours) || 0;
|
||||
b.softBooked += Number(r.softBookedHours) || 0;
|
||||
b.logged += Number(r.loggedHours) || 0;
|
||||
b.available += Number(r.availableHours) || 0;
|
||||
map.set(key, b);
|
||||
}
|
||||
// Period-based: cardinality is bounded by the date range, no top-N cap needed.
|
||||
return [...map.values()].sort((a, b) =>
|
||||
String(a.period ?? '').localeCompare(String(b.period ?? '')),
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) {
|
||||
const data = aggregateByWeek(rows).map((b) => ({ ...b, label: formatPeriod(b.period) }));
|
||||
export default function WeeklyUtilisation({ rows, onPeriodClick, period = 'week' }: Props) {
|
||||
const data = aggregate(rows, period).map((b) => ({ ...b, label: formatPeriod(b.period) }));
|
||||
const title = period === 'month' ? 'Monthly Utilisation (per-week chart in month mode)' : 'Weekly Utilisation';
|
||||
|
||||
const onBarClick = (payload: { period?: string } | undefined) => {
|
||||
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" data-tutorial-id="chart-weekly-utilisation">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">Weekly Utilisation</h3>
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
|
||||
{onPeriodClick && (
|
||||
<span className="text-xs text-slate-500">Click a bar for Hour Breakdown</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
||||
|
|
@ -57,12 +76,19 @@ export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) {
|
|||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="booked"
|
||||
name="Booked"
|
||||
dataKey="activeBooked"
|
||||
name="Active Booked"
|
||||
stackId="booked"
|
||||
fill="#2563eb"
|
||||
onClick={(payload: { period?: string }) => {
|
||||
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
|
||||
}}
|
||||
onClick={onBarClick}
|
||||
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="softBooked"
|
||||
name="Soft Booked"
|
||||
stackId="booked"
|
||||
fill="#93c5fd"
|
||||
onClick={onBarClick}
|
||||
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
|
||||
|
|
|
|||
|
|
@ -1,45 +1,64 @@
|
|||
export interface TutorialStep {
|
||||
/** matches the `data-tutorial-id` attribute on the target element */
|
||||
selector: string;
|
||||
/** chapter label, mirrors the original SPA */
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The original SPA exposed a chapter list ("Chart overview",
|
||||
* "Reading the Utilisation Chart", "Forecast Line & Filters", "Drill-In",
|
||||
* "Spotting Resource Issues", etc.). The selectors below map each chapter
|
||||
* onto the matching data-tutorial-id on screen. Steps whose target isn't
|
||||
* on the current page are filtered out by TutorialOverlay at runtime.
|
||||
*/
|
||||
|
||||
export const departmentSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'navbar',
|
||||
title: 'Top navigation',
|
||||
description: 'Switch between Department, Resourcing, Bookings and Tutorial tabs from here.',
|
||||
title: 'How to Use the Department Tab',
|
||||
description: 'Navigate to the Department Tab from here. The rest of the tour walks through every chart on this page.',
|
||||
},
|
||||
{
|
||||
selector: 'upload-zone',
|
||||
title: 'Upload your timelog',
|
||||
description: 'Drag a Harvest/Toggl export here, or click to choose a file. .xlsx and .csv supported.',
|
||||
description: 'Drag a Zoho / Harvest / Toggl export here, or click to choose a file. .xlsx and .csv supported. The file stays in this session only.',
|
||||
},
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Filter the view',
|
||||
description: 'Pick a date range preset (or use a custom range), and narrow down by department, name or billing type.',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'Pick a date range preset (or use a custom range), narrow by department, name or billing type, and toggle the forecast overlay.',
|
||||
},
|
||||
{
|
||||
selector: 'forecast-toggle',
|
||||
title: 'Forecast line',
|
||||
description: 'Toggle the forecast overlay on or off — useful for spotting upcoming over- or under-bookings.',
|
||||
selector: 'kpi-tiles',
|
||||
title: 'Hours & Utilisation',
|
||||
description: 'Headline KPIs — Total Booked / Logged / Billable, distinct projects, active people and net-of-leave capacity for the chosen period.',
|
||||
},
|
||||
{
|
||||
selector: 'sync-airtable',
|
||||
title: 'Airtable sync',
|
||||
description: 'Force-refresh the bookings cache to pick up brand-new Airtable changes. Use sparingly — counts against API rate limits.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-monthly-utilisation',
|
||||
title: 'Monthly utilisation',
|
||||
description: 'Booked, logged and available hours grouped by calendar month.',
|
||||
title: 'Reading the Utilisation Chart',
|
||||
description: 'Booked, logged and available hours grouped by month. Active vs Soft bookings are stacked. Click bars & toggle forecast line.',
|
||||
},
|
||||
{
|
||||
selector: 'hour-breakdown',
|
||||
title: 'Drill-In',
|
||||
description: 'Click any Booked bar to open the Hour Breakdown — a per-project split of logged and booked hours for that period.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-booking-vs-actual',
|
||||
title: 'Booking vs Actual',
|
||||
description: 'See whose bookings match their logged hours and whose drift. Capped at the top 20 busiest people; the rest roll up into "Other".',
|
||||
title: 'Chart overview — Booking vs Actual',
|
||||
description: 'See whose bookings match their logged hours and whose drift. Active vs Soft are stacked; capped at the top 20 busiest people.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-billability-breakdown',
|
||||
title: 'Billability breakdown',
|
||||
description: 'Per-person split of billable, non-billable, leave and idle hours. Capped at the top 20 by available + billable.',
|
||||
title: 'Spotting Resource Issues',
|
||||
description: 'Per-person split of billable, non-billable, leave and idle hours. Spot errors like a wall of non-billable on a "busy" person.',
|
||||
},
|
||||
{
|
||||
selector: 'export-csv',
|
||||
|
|
@ -51,23 +70,43 @@ export const departmentSteps: TutorialStep[] = [
|
|||
export const resourcingSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Filter the view',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'The same filter controls you used on Department — date range, department and name all flow through to the Airtable bookings query.',
|
||||
},
|
||||
{
|
||||
selector: 'kpi-tiles',
|
||||
title: 'Hours & Utilisation',
|
||||
description: 'Headline KPIs across the people in scope: total booked (active + soft), logged, billable, distinct projects and net-of-leave capacity.',
|
||||
},
|
||||
{
|
||||
selector: 'period-toggle',
|
||||
title: 'Chart overview — Per week / Per month',
|
||||
description: 'Switch the chart aggregation between week and month. Day-level isn\'t supported (too noisy across 100+ employees).',
|
||||
},
|
||||
{
|
||||
selector: 'sync-airtable',
|
||||
title: 'Sync Airtable Bookings',
|
||||
description: 'Force-refresh the bookings cache so the charts reflect the latest Airtable state.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-weekly-utilisation',
|
||||
title: 'Weekly utilisation',
|
||||
description: 'Click a Booked bar to drill into a specific week.',
|
||||
title: 'Reading the Utilisation Chart',
|
||||
description: 'Active Booked + Soft Booked stack together; Logged and Available sit alongside. Click bars & toggle forecast line.',
|
||||
},
|
||||
{
|
||||
selector: 'hour-breakdown',
|
||||
title: 'Drill-In',
|
||||
description: 'After clicking a bar, this panel shows the per-project split for the selected period (logged + booked).',
|
||||
},
|
||||
{
|
||||
selector: 'chart-project-load',
|
||||
title: 'Project load per person',
|
||||
title: 'Chart overview — Project Load',
|
||||
description: 'Stacked bars show which projects are loading up each resource. Capped at the top 10 projects by hours; the rest roll up into "Other".',
|
||||
},
|
||||
{
|
||||
selector: 'chart-fte-vs-freelancer',
|
||||
title: 'FTE vs Freelancer',
|
||||
description: 'Compare utilisation between salaried staff and contractors. Each side caps at the top 20.',
|
||||
title: 'Spotting Resource Issues',
|
||||
description: 'Compare utilisation between salaried staff and contractors side by side. Use this to spot under-used FTEs or over-loaded freelancers.',
|
||||
},
|
||||
{
|
||||
selector: 'export-csv',
|
||||
|
|
@ -79,17 +118,17 @@ export const resourcingSteps: TutorialStep[] = [
|
|||
export const bookingsSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Filter the view',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'Date range, department and name filters narrow the table below.',
|
||||
},
|
||||
{
|
||||
selector: 'bookings-table',
|
||||
title: 'Bookings table',
|
||||
title: 'Reading the bookings table',
|
||||
description: 'A virtualised view of every booking returned by Airtable for the active filters.',
|
||||
},
|
||||
{
|
||||
selector: 'bookings-refresh',
|
||||
title: 'Refresh from Airtable',
|
||||
title: 'Sync Airtable Bookings',
|
||||
description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { DatePreset, DateRange } from './dates';
|
||||
import { dateRangePreset } from './dates';
|
||||
|
||||
export type PeriodKind = 'week' | 'month';
|
||||
|
||||
export interface FilterState {
|
||||
preset: DatePreset;
|
||||
range: DateRange | null;
|
||||
|
|
@ -8,6 +10,7 @@ export interface FilterState {
|
|||
names: string[];
|
||||
billingType: string | null;
|
||||
showForecast: boolean;
|
||||
period: PeriodKind;
|
||||
}
|
||||
|
||||
export const initialFilterState: FilterState = {
|
||||
|
|
@ -17,6 +20,7 @@ export const initialFilterState: FilterState = {
|
|||
names: [],
|
||||
billingType: null,
|
||||
showForecast: true,
|
||||
period: 'week',
|
||||
};
|
||||
|
||||
export type FilterAction =
|
||||
|
|
@ -25,6 +29,7 @@ export type FilterAction =
|
|||
| { type: 'set-departments'; departments: string[] }
|
||||
| { type: 'set-names'; names: string[] }
|
||||
| { type: 'set-billing-type'; billingType: string | null }
|
||||
| { type: 'set-period'; period: PeriodKind }
|
||||
| { type: 'toggle-forecast' }
|
||||
| { type: 'reset' };
|
||||
|
||||
|
|
@ -42,6 +47,8 @@ export function filterReducer(state: FilterState, action: FilterAction): FilterS
|
|||
return { ...state, names: action.names };
|
||||
case 'set-billing-type':
|
||||
return { ...state, billingType: action.billingType };
|
||||
case 'set-period':
|
||||
return { ...state, period: action.period };
|
||||
case 'toggle-forecast':
|
||||
return { ...state, showForecast: !state.showForecast };
|
||||
case 'reset':
|
||||
|
|
@ -59,5 +66,6 @@ export function filtersToQuery(state: FilterState): Record<string, string | unde
|
|||
department: state.departments.length ? state.departments.join(',') : undefined,
|
||||
name: state.names.length ? state.names.join(',') : undefined,
|
||||
billing_type: state.billingType ?? undefined,
|
||||
period: state.period,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import UploadButton from '../components/UploadButton';
|
||||
import KpiTiles from '../components/KpiTiles';
|
||||
import PeriodToggle from '../components/PeriodToggle';
|
||||
import HourBreakdown from '../components/HourBreakdown';
|
||||
import MonthlyUtilisation from '../components/charts/MonthlyUtilisation';
|
||||
import BookingVsActual from '../components/charts/BookingVsActual';
|
||||
import BillabilityBreakdown from '../components/charts/BillabilityBreakdown';
|
||||
|
|
@ -14,49 +17,65 @@ import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filter
|
|||
import { downloadCsv, rowsToCsv } from '../lib/csv';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { UtilisationSummaryRow } from '../api/types';
|
||||
import type { UtilisationSummaryRow, UtilisationTotals } from '../api/types';
|
||||
|
||||
export default function Department() {
|
||||
const airtable = useAirtableData(false);
|
||||
const tl = useTimelog();
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
|
||||
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
const [summaryError, setSummaryError] = useState<string | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const names = useMemo(
|
||||
() => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)),
|
||||
[airtable.resources],
|
||||
);
|
||||
|
||||
const loadSummary = useMemo(
|
||||
() => async () => {
|
||||
setSummaryLoading(true);
|
||||
setSummaryError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const res = await api.getUtilisationSummary({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
timelogHash: tl.hash ?? undefined,
|
||||
});
|
||||
setSummary(res.rows);
|
||||
} catch (err) {
|
||||
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
},
|
||||
[filters, tl.hash],
|
||||
);
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryLoading(true);
|
||||
setSummaryError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const res = await api.getUtilisationSummary({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
period: filters.period,
|
||||
timelogHash: tl.hash ?? undefined,
|
||||
});
|
||||
setSummary(res.rows);
|
||||
setTotals(res.totals ?? null);
|
||||
} catch (err) {
|
||||
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [filters, tl.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
// Force-refresh bookings cache, then re-derive summary.
|
||||
const q = filtersToQuery(filters);
|
||||
await api.getBookings({ from: q.from, to: q.to, refresh: true });
|
||||
await loadSummary();
|
||||
} catch (err) {
|
||||
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [filters, loadSummary]);
|
||||
|
||||
const handleExport = () => {
|
||||
if (summary.length === 0) return;
|
||||
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
|
||||
|
|
@ -71,6 +90,7 @@ export default function Department() {
|
|||
<li>Upload your timelog export (.xlsx or .csv). It stays in this session only.</li>
|
||||
<li>Pick a date preset (or custom range) and narrow by department or name.</li>
|
||||
<li>The charts below recompute automatically. Toggle the forecast line to compare scenarios.</li>
|
||||
<li>Click a bar to drill into a per-project Hour Breakdown.</li>
|
||||
<li>Use <em>Export CSV</em> to share the summary outside the app.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
|
@ -95,16 +115,35 @@ export default function Department() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={summary.length === 0}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="export-csv"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden /> Export CSV
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<PeriodToggle
|
||||
value={filters.period}
|
||||
onChange={(p) => {
|
||||
dispatch({ type: 'set-period', period: p });
|
||||
// Clear drill-down: period labels won't match across week/month.
|
||||
setSelectedPeriod(null);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSync()}
|
||||
disabled={syncing || summaryLoading}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="sync-airtable"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} aria-hidden /> Sync Airtable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={summary.length === 0}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="export-csv"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{airtable.error && <ErrorBox message={airtable.error} onRetry={airtable.refresh} />}
|
||||
|
|
@ -112,17 +151,39 @@ export default function Department() {
|
|||
|
||||
{(airtable.loading || summaryLoading) && <Loading label="Crunching numbers…" />}
|
||||
|
||||
{!summaryError && summary.length > 0 && (
|
||||
{!summaryError && (
|
||||
<>
|
||||
<ErrorBoundary label="Monthly Utilisation">
|
||||
<MonthlyUtilisation rows={summary} showForecast={filters.showForecast} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary label="Booking vs Actual">
|
||||
<BookingVsActual rows={summary} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary label="Billability Breakdown">
|
||||
<BillabilityBreakdown rows={summary} />
|
||||
<ErrorBoundary label="KPI Tiles">
|
||||
<KpiTiles totals={totals} period={filters.period} />
|
||||
</ErrorBoundary>
|
||||
{summary.length > 0 && (
|
||||
<>
|
||||
<ErrorBoundary label="Monthly Utilisation">
|
||||
<MonthlyUtilisation
|
||||
rows={summary}
|
||||
showForecast={filters.showForecast}
|
||||
onPeriodClick={setSelectedPeriod}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{selectedPeriod && (
|
||||
<ErrorBoundary label="Hour Breakdown">
|
||||
<HourBreakdown
|
||||
period={selectedPeriod}
|
||||
from={filters.range?.from}
|
||||
to={filters.range?.to}
|
||||
timelogHash={tl.hash}
|
||||
onClose={() => setSelectedPeriod(null)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary label="Booking vs Actual">
|
||||
<BookingVsActual rows={summary} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary label="Billability Breakdown">
|
||||
<BillabilityBreakdown rows={summary} />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import KpiTiles from '../components/KpiTiles';
|
||||
import PeriodToggle from '../components/PeriodToggle';
|
||||
import HourBreakdown from '../components/HourBreakdown';
|
||||
import WeeklyUtilisation from '../components/charts/WeeklyUtilisation';
|
||||
import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson';
|
||||
import FTEvsFreelancer from '../components/charts/FTEvsFreelancer';
|
||||
|
|
@ -8,18 +11,22 @@ import Loading from '../components/Loading';
|
|||
import ErrorBox from '../components/ErrorBox';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import { useAirtableData } from '../hooks/useAirtableData';
|
||||
import { useTimelog } from '../hooks/useTimelog';
|
||||
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
|
||||
import { downloadCsv, rowsToCsv } from '../lib/csv';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { Booking, UtilisationSummaryRow } from '../api/types';
|
||||
import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/types';
|
||||
|
||||
export default function Resourcing() {
|
||||
const airtable = useAirtableData(false);
|
||||
const tl = useTimelog();
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
|
||||
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -42,39 +49,55 @@ export default function Resourcing() {
|
|||
});
|
||||
}, [bookings, filters.departments, filters.names]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const [bk, sm] = await Promise.all([
|
||||
api.getBookings({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
}),
|
||||
api.getUtilisationSummary({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
}),
|
||||
]);
|
||||
setBookings(bk.bookings);
|
||||
setSummary(sm.rows);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
const load = useCallback(
|
||||
async (refresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const [bk, sm] = await Promise.all([
|
||||
api.getBookings({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
refresh,
|
||||
}),
|
||||
api.getUtilisationSummary({
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
period: filters.period,
|
||||
timelogHash: tl.hash ?? undefined,
|
||||
}),
|
||||
]);
|
||||
setBookings(bk.bookings);
|
||||
setSummary(sm.rows);
|
||||
setTotals(sm.totals ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filters, tl.hash],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await load(true);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const handleExport = () => {
|
||||
if (summary.length === 0) return;
|
||||
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
|
||||
|
|
@ -92,36 +115,70 @@ export default function Resourcing() {
|
|||
showForecastToggle={false}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{selectedPeriod ? (
|
||||
<div className="text-sm text-slate-600">
|
||||
Drilled into period <strong>{selectedPeriod}</strong>{' '}
|
||||
<button onClick={() => setSelectedPeriod(null)} className="ml-2 text-blue-600 hover:underline">
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={summary.length === 0}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="export-csv"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden /> Export CSV
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<PeriodToggle
|
||||
value={filters.period}
|
||||
onChange={(p) => {
|
||||
dispatch({ type: 'set-period', period: p });
|
||||
setSelectedPeriod(null);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPeriod && (
|
||||
<div className="text-sm text-slate-600">
|
||||
Drilled into <strong>{selectedPeriod}</strong>{' '}
|
||||
<button onClick={() => setSelectedPeriod(null)} className="ml-1 text-blue-600 hover:underline">
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSync()}
|
||||
disabled={syncing || loading}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="sync-airtable"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} aria-hidden /> Sync Airtable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={summary.length === 0}
|
||||
className="btn-secondary"
|
||||
data-tutorial-id="export-csv"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBox message={error} onRetry={load} />}
|
||||
{error && <ErrorBox message={error} onRetry={() => load(false)} />}
|
||||
{loading && <Loading label="Loading resourcing data…" />}
|
||||
|
||||
{!error && (
|
||||
<>
|
||||
<ErrorBoundary label="Weekly Utilisation">
|
||||
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
|
||||
<ErrorBoundary label="KPI Tiles">
|
||||
<KpiTiles totals={totals} period={filters.period} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary label="Weekly Utilisation">
|
||||
<WeeklyUtilisation
|
||||
rows={summary}
|
||||
onPeriodClick={setSelectedPeriod}
|
||||
period={filters.period}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{selectedPeriod && (
|
||||
<ErrorBoundary label="Hour Breakdown">
|
||||
<HourBreakdown
|
||||
period={selectedPeriod}
|
||||
from={filters.range?.from}
|
||||
to={filters.range?.to}
|
||||
timelogHash={tl.hash}
|
||||
onClose={() => setSelectedPeriod(null)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary label="Project Load per Person">
|
||||
<ProjectLoadPerPerson bookings={visibleBookings} />
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,14 @@ export default function Tutorial() {
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card">
|
||||
<h1 className="text-lg font-semibold text-slate-900">Tutorial</h1>
|
||||
<h1 className="text-lg font-semibold text-slate-900">Tutorial — Interactive Walkthrough</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
A walkthrough of every tab. Click <em>Replay</em> to relaunch the guided tour for that section — it only
|
||||
highlights elements that are currently visible, so navigate to the matching tab first.
|
||||
A walkthrough of every tab. Use the chapter buttons to replay any section — the overlay only highlights
|
||||
elements that are currently visible, so navigate to the matching tab first, then click <em>Replay</em>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Note: the original Video Walkthrough has been retired; the interactive tour below covers the same ground.
|
||||
Jump to any step using the in-overlay arrow controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -50,7 +54,7 @@ export default function Tutorial() {
|
|||
</button>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-slate-500">
|
||||
{allSteps[s.key].map((step) => (
|
||||
<li key={step.selector}>
|
||||
<li key={`${s.key}-${step.selector}-${step.title}`}>
|
||||
<strong className="text-slate-700">{step.title}:</strong> {step.description}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue