feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC

Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).

Backend — new endpoints + services:
- POST /api/deliverable/parse        — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse     — parse Project Summary CSV/XLSX
- GET  /api/timelog/rows             — paginated, searchable, sortable view
                                        over the parsed Zoho upload
- GET  /api/forecast                 — 4-week pipeline + capacity decision
- GET  /api/project-types            — hours/asset, duration, concentration
                                        per project type + auto-insights
- POST /api/chat                     — Claude API proxy. 503s gracefully
                                        when ANTHROPIC_API_KEY is unset.
                                        Prompt-cached system prompt;
                                        rate-limited 20/min/IP.
- GET  /api/auth/me now returns role.

Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
  projectType, assetCount, projectStatus, project start/end dates,
  userAgency, employingCompany, sageJobProfile, …) with back-compat
  aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
  by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
  baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.

Frontend — new pages + components:
- pages/Forecast.tsx                 — ComposedChart (stacked bars + line)
                                        + capacity-decision banner + table
- pages/ProjectTypeSummary.tsx       — sortable table + small trend chart
- pages/TimeLogDetail.tsx            — virtualised, searchable, sortable
                                        view over all parsed timelog rows
- components/ChatView.tsx            — floating side panel with Claude.
                                        6 preset prompts mirroring the
                                        original. Visible only for roles
                                        with chat access.
- components/ChatToggle.tsx          — bottom-right FAB.
- components/StatsBar.tsx            — always-visible: Time Entries /
                                        People / Projects / Total Hours /
                                        Date Range.
- hooks/useDataContext.tsx           — single source of truth for filter
                                        state + parsed upload + filter
                                        dimensions (brands/divs/hubs/
                                        roles derived from uploads).

Frontend — modified:
- App.tsx, Navbar.tsx                — 7 tabs + role gating per the
                                        original TAB_ACCESS matrix.
- hooks/useAuth.tsx                  — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx      — Brand / Division / Hub / Role
                                        multiselects added (additive — keep
                                        Department / Name / Billing).
- pages/Department, Resourcing,
  Bookings, Tutorial.tsx             — wired into DataContext; tutorial
                                        is now a single 9-step global tour
                                        mirroring the original's narrative.

Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-17 21:40:03 -04:00
parent cd1c99d5e0
commit 993e370cea
46 changed files with 3880 additions and 295 deletions

View file

@ -20,6 +20,19 @@ ADMIN_PASSWORD_BCRYPT=''
# AUTH_MODE: local (v1) or azure (v2 — not yet implemented).
AUTH_MODE=local
# Role for the local-admin user. Drives tab visibility per the RBAC matrix.
# One of: global-lead | dept-lead | forecast
# global-lead → full access (all tabs + AI chat)
# dept-lead → Department + Tutorial + AI chat
# forecast → Forecast tab only, no AI chat (external/client view)
ADMIN_ROLE=global-lead
# --- Anthropic (AI chat) ---
# Leave ANTHROPIC_API_KEY empty to disable chat (the /api/chat endpoint
# returns a friendly 503 in that case). Get a key from console.anthropic.com.
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6
# Skip auth entirely in local dev. NEVER set true in production.
DEV_AUTH_BYPASS=false

View file

@ -36,9 +36,9 @@ def verify_password(username: str, password: str) -> bool:
def verify_session(request: Request) -> dict[str, Any]:
"""FastAPI dependency: returns {"username": ..., "mode": ...} or 401."""
"""FastAPI dependency: returns {"username": ..., "mode": ..., "role": ...} or 401."""
if settings.DEV_AUTH_BYPASS:
return {"username": "dev", "mode": "bypass"}
return {"username": "dev", "mode": "bypass", "role": settings.ADMIN_ROLE}
cookie = request.cookies.get(COOKIE_NAME)
if not cookie:
@ -48,4 +48,4 @@ def verify_session(request: Request) -> dict[str, Any]:
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return {"username": username, "mode": "local"}
return {"username": username, "mode": "local", "role": settings.ADMIN_ROLE}

View file

@ -35,9 +35,18 @@ class Settings(BaseSettings):
SESSION_SECRET: str = "dev-insecure-secret-change-me"
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD_BCRYPT: str = ""
# Single role for the local-admin user. Tabs are gated on the
# frontend per this value. MSAL → Azure groups will replace this
# mapping when that lands; for v1 we keep it env-driven.
ADMIN_ROLE: Literal["global-lead", "dept-lead", "forecast"] = "global-lead"
AUTH_MODE: Literal["local", "azure"] = "local"
DEV_AUTH_BYPASS: bool = False
# Anthropic — empty by default so the /api/chat endpoint can return
# a helpful 503 telling the user to configure the key.
ANTHROPIC_API_KEY: str = ""
ANTHROPIC_MODEL: str = "claude-sonnet-4-6"
# Cache TTLs (seconds)
CACHE_TTL_RESOURCES: int = 600
CACHE_TTL_BOOKINGS: int = 60

View file

@ -26,6 +26,7 @@ def auth_required(request: Request) -> dict[str, Any]:
# Re-check bypass at each request: settings.DEV_AUTH_BYPASS may have
# been toggled by tests. Production deployments do not touch this.
from app.config import get_settings
if get_settings().DEV_AUTH_BYPASS:
return {"username": "dev", "mode": "bypass"}
s = get_settings()
if s.DEV_AUTH_BYPASS:
return {"username": "dev", "mode": "bypass", "role": s.ADMIN_ROLE}
return _verifier(request)

View file

@ -28,7 +28,12 @@ from app.config import settings
from app.deps.airtable import airtable_client
from app.routers import airtable as airtable_router
from app.routers import auth as auth_router
from app.routers import chat as chat_router
from app.routers import deliverable as deliverable_router
from app.routers import forecast as forecast_router
from app.routers import health as health_router
from app.routers import project_summary as project_summary_router
from app.routers import project_types as project_types_router
from app.routers import timelog as timelog_router
from app.routers import utilisation as utilisation_router
@ -102,6 +107,11 @@ def create_app() -> FastAPI:
app.include_router(airtable_router.router)
app.include_router(timelog_router.router)
app.include_router(utilisation_router.router)
app.include_router(deliverable_router.router)
app.include_router(project_summary_router.router)
app.include_router(forecast_router.router)
app.include_router(project_types_router.router)
app.include_router(chat_router.router)
return app

View file

@ -23,6 +23,7 @@ class LoginRequest(BaseModel):
class MeResponse(BaseModel):
username: str
mode: str # "local" | "azure" | "bypass"
role: str = "global-lead"
# ---------- Airtable ----------

View file

@ -64,7 +64,7 @@ def _log_attempt(request: Request, username: str, outcome: str) -> None:
async def login(request: Request, response: Response, body: LoginRequest = Body(...)):
if settings.DEV_AUTH_BYPASS:
_log_attempt(request, body.username, "bypass")
return {"ok": True, "username": "dev", "mode": "bypass"}
return {"ok": True, "username": "dev", "mode": "bypass", "role": settings.ADMIN_ROLE}
if not verify_password(body.username, body.password):
_log_attempt(request, body.username, "fail")
@ -72,7 +72,7 @@ async def login(request: Request, response: Response, body: LoginRequest = Body(
set_session_cookie(response, body.username)
_log_attempt(request, body.username, "success")
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE}
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE, "role": settings.ADMIN_ROLE}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
@ -85,4 +85,8 @@ async def logout(response: Response) -> Response:
@router.get("/me", response_model=MeResponse)
async def me(user: dict = Depends(auth_required)) -> MeResponse:
return MeResponse(username=user["username"], mode=user["mode"])
return MeResponse(
username=user["username"],
mode=user["mode"],
role=user.get("role", settings.ADMIN_ROLE),
)

189
backend/app/routers/chat.py Normal file
View file

@ -0,0 +1,189 @@
"""Claude API proxy with prompt caching.
Decisions:
- Single non-streaming endpoint (POST /api/chat). Streaming via SSE adds
complexity that wasn't worth shipping in v1 — the model + caching are
what matter for response time on long context blocks.
- Prompt caching: the system prompt + the dashboard-context block are
marked with cache_control breakpoints. Together they're typically
~5-50k tokens depending on dataset size; caching them halves the
cost of repeat questions and dramatically reduces TTFT.
- 503 when ANTHROPIC_API_KEY is empty so the UI can show a helpful
message instead of a generic upstream error.
- Rate-limit at 20/min/IP via slowapi (separate from the auth router's
limiter; both share the same Limiter instance).
- Forecast role has no chat access gated on the frontend (`showChat`
in App.tsx). Backend allows any authenticated user; restricting it
here would mean adding role checks to every router, which we'll do
centrally if/when Azure AD lands.
- Request logging: writes timestamp + user + token count to a
RotatingFileHandler at /app/logs/chat.log. Message bodies are NOT
logged they may contain employee names / project codes.
- We intentionally DO NOT use `from __future__ import annotations` in
this module: slowapi's decorator breaks FastAPI forward-ref resolution
for the request body model under Python 3.14 / pydantic 2.x. The
auth router has the same caveat.
"""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from app.config import get_settings
from app.deps.auth import auth_required
from app.routers.auth import limiter # share the same slowapi limiter
from app.services.ai_context import build_dashboard_context
from app.services.parse_store import get_project_summary, get_timelog
logger = logging.getLogger(__name__)
# ---- chat.log file handler ----
chat_logger = logging.getLogger("ud.chat")
if not chat_logger.handlers:
log_dir = Path("/app/logs")
try:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "chat.log",
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
except (PermissionError, OSError):
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)sZ %(message)s"))
chat_logger.addHandler(handler)
chat_logger.setLevel(logging.INFO)
chat_logger.propagate = False
router = APIRouter(prefix="/api", tags=["chat"], dependencies=[Depends(auth_required)])
SYSTEM_PROMPT = (
"You are an AI assistant embedded in the L'Oréal Utilisation Dashboard "
"— an internal capacity planning tool for an Oliver agency team.\n\n"
"You have access to live dashboard data provided at the end of this prompt. "
"Use it to answer questions about:\n"
"- Team capacity and utilisation (weekly hours, headcount, % utilisation)\n"
"- Active project pipeline: what's in flight, upcoming, or exiting this week\n"
"- Benchmark hours per asset by project type (from historical completed projects)\n"
"- Weekly throughput forecasts and asset counts\n"
"- Specific project status, dates, or asset numbers\n\n"
"Guidelines:\n"
"- Be concise and reference specific numbers from the data\n"
"- If a question is about something not in the data, say so clearly\n"
"- When comparing types, call out the biggest differences\n"
"- Dates are in YYYY-MM-DD unless stated otherwise"
)
class ChatMessage(BaseModel):
role: str # "user" | "assistant"
content: str
class ChatContext(BaseModel):
timelogHash: str | None = None
projectSummaryHash: str | None = None
includeBookings: bool = False
includeResources: bool = False
class ChatRequest(BaseModel):
messages: list[ChatMessage]
context: ChatContext | None = None
max_tokens: int = 1024
@router.post("/chat")
@limiter.limit("20/minute")
async def chat(request: Request, body: ChatRequest, user: dict = Depends(auth_required)) -> Any:
settings = get_settings()
if not body.messages:
raise HTTPException(status_code=400, detail="messages must be non-empty")
if not settings.ANTHROPIC_API_KEY:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI chat is not configured. Add ANTHROPIC_API_KEY to backend .env.",
)
# Build the cached context block from whatever uploads the user has.
logs: list[dict[str, Any]] = []
project_summary: list[dict[str, Any]] = []
ctx = body.context or ChatContext()
if ctx.timelogHash:
cached = get_timelog(ctx.timelogHash)
if cached:
logs = cached.get("rows", []) or []
if ctx.projectSummaryHash:
cached = get_project_summary(ctx.projectSummaryHash)
if cached:
project_summary = cached.get("rows", []) or []
dashboard_block = build_dashboard_context(logs=logs, project_summary=project_summary or None)
# System prompt as a list with two cache_control breakpoints — Anthropic
# caches everything up to and including each breakpoint. Two stable
# blocks (the static guide + the dashboard data) keep cache hits high
# across follow-ups.
system = [
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": f"---\n\n## LIVE DASHBOARD DATA\n\n{dashboard_block}",
"cache_control": {"type": "ephemeral"},
},
]
payload = {
"model": settings.ANTHROPIC_MODEL, # type: ignore[union-attr]
"max_tokens": min(max(body.max_tokens, 256), 4096),
"system": system,
"messages": [{"role": m.role, "content": m.content} for m in body.messages],
}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
try:
res = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": settings.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json=payload,
)
except httpx.HTTPError as e:
logger.exception("anthropic upstream error")
raise HTTPException(status_code=502, detail=f"Upstream error: {e}") from e
if res.status_code >= 400:
# Surface upstream status verbatim so the UI can show a useful message.
try:
body_json = res.json()
except ValueError:
body_json = {"error": res.text}
raise HTTPException(status_code=res.status_code, detail=body_json)
data = res.json()
usage = data.get("usage", {}) or {}
in_tok = usage.get("input_tokens", 0)
out_tok = usage.get("output_tokens", 0)
cache_create = usage.get("cache_creation_input_tokens", 0)
cache_read = usage.get("cache_read_input_tokens", 0)
chat_logger.info(
"user=%s in=%s out=%s cache_create=%s cache_read=%s",
user.get("username", "?"), in_tok, out_tok, cache_create, cache_read,
)
return data

View file

@ -0,0 +1,34 @@
"""Deliverable Summary upload + parse."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.config import settings
from app.deps.auth import auth_required
from app.services import deliverable_parse
from app.services.parse_store import remember_deliverable
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/deliverable", tags=["deliverable"], dependencies=[Depends(auth_required)])
@router.post("/parse")
async def parse_deliverable(file: UploadFile = File(...)) -> dict[str, Any]:
content = await file.read()
if len(content) > settings.MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="File too large")
if not content:
raise HTTPException(status_code=400, detail="Empty file")
try:
parsed = deliverable_parse.parse(file.filename or "", content)
except Exception as e:
logger.exception("Deliverable parse failed")
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
remember_deliverable(parsed)
return parsed

View file

@ -0,0 +1,77 @@
"""Forecast view — 4-week capacity outlook."""
from __future__ import annotations
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from app.deps.auth import auth_required
from app.services.forecast import build_forecast
from app.services.parse_store import get_deliverable, get_project_summary, get_timelog
from app.services.timelog_filters import apply_filters
router = APIRouter(prefix="/api/forecast", tags=["forecast"], dependencies=[Depends(auth_required)])
def _parse_date(s: str | None) -> date | None:
if not s:
return None
try:
return date.fromisoformat(s)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
@router.get("")
async def forecast(
weeks_ahead: int = Query(4, ge=1, le=12, alias="weeks_ahead"),
from_: str | None = Query(None, alias="from"),
timelog_hash: str | None = Query(None, alias="timelogHash"),
deliverable_hash: str | None = Query(None, alias="deliverableHash"),
project_summary_hash: str | None = Query(None, alias="projectSummaryHash"),
brands: str | None = Query(None),
divisions: str | None = Query(None),
hubs: str | None = Query(None),
user_roles: str | None = Query(None, alias="userRoles"),
departments: str | None = Query(None),
names: str | None = Query(None),
) -> dict[str, Any]:
logs: list[dict[str, Any]] = []
if timelog_hash:
cached = get_timelog(timelog_hash)
if cached:
logs = cached.get("rows", []) or []
deliverables: list[dict[str, Any]] = []
if deliverable_hash:
cached = get_deliverable(deliverable_hash)
if cached:
deliverables = cached.get("rows", []) or []
project_summary: list[dict[str, Any]] = []
if project_summary_hash:
cached = get_project_summary(project_summary_hash)
if cached:
project_summary = cached.get("rows", []) or []
logs = apply_filters(
logs,
brands=brands,
divisions=divisions,
hubs=hubs,
user_roles=user_roles,
departments=departments,
names=names,
)
out = build_forecast(
logs=logs,
deliverables=deliverables or None,
project_summary=project_summary or None,
weeks_ahead=weeks_ahead,
from_date=_parse_date(from_),
)
return out

View file

@ -0,0 +1,34 @@
"""Project Summary upload + parse."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.config import settings
from app.deps.auth import auth_required
from app.services import project_summary_parse
from app.services.parse_store import remember_project_summary
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projectsummary", tags=["projectsummary"], dependencies=[Depends(auth_required)])
@router.post("/parse")
async def parse_project_summary(file: UploadFile = File(...)) -> dict[str, Any]:
content = await file.read()
if len(content) > settings.MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="File too large")
if not content:
raise HTTPException(status_code=400, detail="Empty file")
try:
parsed = project_summary_parse.parse(file.filename or "", content)
except Exception as e:
logger.exception("Project Summary parse failed")
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
remember_project_summary(parsed)
return parsed

View file

@ -0,0 +1,59 @@
"""Per-project-type stats endpoint + filter-dimensions for the FilterBar."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.deps.auth import auth_required
from app.services.parse_store import get_project_summary, get_timelog
from app.services.project_types import build_project_types
from app.services.timelog_filters import apply_filters, distinct_dimensions
router = APIRouter(prefix="/api", tags=["project-types"], dependencies=[Depends(auth_required)])
@router.get("/project-types")
async def project_types(
timelog_hash: str | None = Query(None, alias="timelogHash"),
project_summary_hash: str | None = Query(None, alias="projectSummaryHash"),
brands: str | None = Query(None),
divisions: str | None = Query(None),
hubs: str | None = Query(None),
user_roles: str | None = Query(None, alias="userRoles"),
) -> dict[str, Any]:
logs: list[dict[str, Any]] = []
if timelog_hash:
cached = get_timelog(timelog_hash)
if cached:
logs = cached.get("rows", []) or []
project_summary: list[dict[str, Any]] = []
if project_summary_hash:
cached = get_project_summary(project_summary_hash)
if cached:
project_summary = cached.get("rows", []) or []
logs = apply_filters(
logs,
brands=brands, divisions=divisions, hubs=hubs, user_roles=user_roles,
)
return build_project_types(logs=logs, project_summary=project_summary or None)
@router.get("/timelog/dimensions")
async def timelog_dimensions(
timelog_hash: str | None = Query(None, alias="timelogHash"),
) -> dict[str, Any]:
"""Distinct brands / divisions / hubs / userRoles from the parsed
timelog sourced from the upload, not Airtable. Powers the new
FilterBar v2 multiselects on every data page.
"""
logs: list[dict[str, Any]] = []
if timelog_hash:
cached = get_timelog(timelog_hash)
if cached:
logs = cached.get("rows", []) or []
return distinct_dimensions(logs)

View file

@ -4,20 +4,22 @@ Decisions:
- We store the most recent parsed result in an in-memory dict keyed by
the content sha256. /api/utilisation/summary can then accept an
X-Timelog-Hash header and recover the rows without the user re-uploading.
- The cache has a small LRU bound (we keep the last 8 uploads).
- The cache has a small LRU bound (we keep the last 8 uploads) and now
lives in `services.parse_store` so the new forecast / project-types /
rows endpoints can read the same parsed payload.
"""
from __future__ import annotations
import logging
from collections import OrderedDict
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from app.config import settings
from app.deps.auth import auth_required
from app.services import zoho_parse
from app.services.parse_store import get_timelog, remember_timelog
logger = logging.getLogger(__name__)
@ -25,23 +27,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/timelog", tags=["timelog"], dependencies=[Depends(auth_required)])
# In-memory store of recent parses, keyed by content_hash.
# Bounded LRU so memory can't grow unbounded.
_MAX_CACHED_PARSES = 8
_parse_store: "OrderedDict[str, dict[str, Any]]" = OrderedDict()
def get_cached_parse(content_hash: str) -> dict[str, Any] | None:
"""Public accessor used by utilisation router."""
return _parse_store.get(content_hash)
def _remember(parsed: dict[str, Any]) -> None:
h = parsed["content_hash"]
_parse_store[h] = parsed
_parse_store.move_to_end(h)
while len(_parse_store) > _MAX_CACHED_PARSES:
_parse_store.popitem(last=False)
"""Back-compat shim — utilisation router still imports this name."""
return get_timelog(content_hash)
@router.post("/parse")
@ -58,5 +46,87 @@ async def parse_timelog(file: UploadFile = File(...)) -> dict[str, Any]:
logger.exception("Zoho parse failed")
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
_remember(parsed)
remember_timelog(parsed)
return parsed
def _row_matches_query(row: dict[str, Any], q: str) -> bool:
if not q:
return True
fields = (
row.get("submitter"), row.get("submitterEmail"), row.get("projectTitle"),
row.get("projectType"), row.get("projectNumber"), row.get("userRole"),
row.get("brand"), row.get("hub"), row.get("division"),
row.get("taskDescription"),
)
for f in fields:
if f and q in str(f).lower():
return True
d = row.get("date")
if d is not None and q in str(d):
return True
return False
@router.get("/rows")
async def list_rows(
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=1000, alias="pageSize"),
search: str = Query(""),
sort: str = Query("date:desc"),
timelog_hash: str | None = Query(None, alias="timelogHash"),
) -> dict[str, Any]:
"""Paginated, sortable, searchable view over the parsed timelog rows.
The frontend's TimeLogDetail page hits this once on mount and per
page/search change. We hold the full row list in the in-process
cache (keyed on hash) keeping all the filtering server-side lets
the JS bundle stay tiny.
"""
parsed: dict[str, Any] | None = None
if timelog_hash:
parsed = get_timelog(timelog_hash)
if not parsed:
return {"rows": [], "total": 0, "page": page, "pageSize": page_size}
rows = parsed.get("rows", []) or []
q = (search or "").strip().lower()
if q:
rows = [r for r in rows if _row_matches_query(r, q)]
# Sort: "field:dir"
field, _, direction = (sort or "date:desc").partition(":")
reverse = direction.lower() != "asc"
# Defensive default — fall back to date desc on an unknown field.
valid_fields = {
"date", "submitter", "userRole", "brand", "division", "hub",
"hoursLogged", "projectNumber", "projectTitle", "projectType",
}
if field not in valid_fields:
field = "date"
def _sort_key(r: dict[str, Any]):
v = r.get(field)
if v is None:
return (1, "")
if isinstance(v, (int, float)):
return (0, v)
# Dates sort lexicographically when isoformat-ed.
return (0, str(v))
try:
rows = sorted(rows, key=_sort_key, reverse=reverse)
except TypeError:
pass
total = len(rows)
start = (page - 1) * page_size
page_rows = rows[start:start + page_size]
return {
"rows": page_rows,
"total": total,
"page": page,
"pageSize": page_size,
}

View file

@ -9,9 +9,10 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query
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 breakdown_by_project, compute_totals, summarise
from app.services.parse_store import get_timelog
from app.services.timelog_filters import apply_filters
router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)])
@ -54,6 +55,11 @@ async def summary(
name: str | None = Query(None),
billing_type: str | None = Query(None),
period: str = Query("week"),
# FilterBar v2: timelog-sourced dimensions.
brands: str | None = Query(None),
divisions: str | None = Query(None),
hubs: str | None = Query(None),
user_roles: str | None = Query(None, alias="userRoles"),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
today = date.today()
@ -72,10 +78,16 @@ async def summary(
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_cached_parse(x_timelog_hash)
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
# Apply FilterBar v2 dimensions to logs before summarising.
logged_rows = apply_filters(
logged_rows,
brands=brands, divisions=divisions, hubs=hubs, user_roles=user_roles,
)
filters = {"department": department, "name": name, "billing_type": billing_type}
rows = summarise(
logged_rows,
@ -98,6 +110,10 @@ async def summary(
"name": name,
"billing_type": billing_type,
"period": period,
"brands": brands,
"divisions": divisions,
"hubs": hubs,
"userRoles": user_roles,
},
}
@ -110,12 +126,7 @@ async def breakdown(
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.
"""
"""Drill-down: per-project hour breakdown (logged + booked) for a period."""
today = date.today()
monday = today - timedelta(days=today.weekday())
from_d = _parse_date(from_, monday - timedelta(days=180))
@ -125,7 +136,7 @@ async def breakdown(
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_cached_parse(x_timelog_hash)
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])

View file

@ -0,0 +1,102 @@
"""Builds the dashboard-context system prompt the AI chat reads from.
Mirrors `src/lib/aiContextBuilder.ts` in the original SPA kept small
and human-readable so the LLM can cite specific numbers back to the
user.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Iterable
from app.services.project_types import build_project_types
def _is_complete(s: str | None) -> bool:
if not s:
return False
n = s.upper()
return n in {"APPROVED", "COMPLETE", "CANCELLED", "REJECTED", "DECLINED"}
def build_dashboard_context(
*,
logs: list[dict[str, Any]],
project_summary: list[dict[str, Any]] | None = None,
) -> str:
today = date.today().isoformat()
lines: list[str] = [f"Today: {today}", ""]
# Time-log summary.
people: set[str] = set()
projects: set[str] = set()
total_hours = 0.0
dates: list[str] = []
for r in logs:
ident = r.get("submitterEmail") or r.get("submitter") or ""
if ident:
people.add(str(ident))
title = r.get("projectTitle") or r.get("projectNumber") or ""
if title:
projects.add(str(title))
total_hours += float(r.get("hoursLogged") or r.get("hours") or 0)
d = r.get("date")
if d:
dates.append(str(d))
dates.sort()
lines.append("## TIME LOG")
lines.append(f"{len(logs):,} entries | {len(people)} people | {len(projects)} projects | {total_hours:.0f}h total")
if dates:
lines.append(f"Date range: {dates[0]} to {dates[-1]}")
lines.append("")
# Benchmark by project type (top types).
types = build_project_types(logs=logs, project_summary=project_summary or None)
stats = types.get("stats", []) or []
if stats:
lines.append(f"## BENCHMARK ({sum(s['projectCount'] for s in stats)} projects across {len(stats)} types)")
for s in stats[:12]:
lines.append(
f" {s['projectType']}: "
f"{s['avgHoursPerAsset']:.1f}h/asset | "
f"{s['projectCount']} projects | "
f"{s['totalAssets']:.0f} assets | "
f"avg {s['avgDurationDays']:.0f}d"
)
lines.append("")
# Active pipeline from Project Summary.
if project_summary:
active = [ps for ps in project_summary
if not _is_complete(ps.get("projectStatus"))
and ps.get("projectStartDate")
and ps["projectStartDate"] <= today
and (not ps.get("projectEndDate") or ps["projectEndDate"] >= today)]
upcoming = [ps for ps in project_summary
if not _is_complete(ps.get("projectStatus"))
and (ps.get("projectStartDate") or "") > today]
# Exiting this week (Mon-Fri).
d = date.today()
mon = d - timedelta(days=d.weekday())
fri = mon + timedelta(days=4)
exiting = [ps for ps in project_summary
if not _is_complete(ps.get("projectStatus"))
and ps.get("projectEndDate")
and mon.isoformat() <= ps["projectEndDate"] <= fri.isoformat()]
total_active_assets = sum(float(ps.get("assetCount") or 0) for ps in active)
lines.append("## ACTIVE PIPELINE")
lines.append(
f"Active: {len(active)} projects ({total_active_assets:,.0f} assets) | "
f"Upcoming: {len(upcoming)} | Exiting this week: {len(exiting)}"
)
if exiting:
lines.append("Exiting this week:")
for ps in exiting[:20]:
lines.append(
f" {ps.get('projectNumber','')} | {ps.get('projectTitle','')} | "
f"{(ps.get('assetCount') or 0):.0f} assets | ends {ps.get('projectEndDate')}"
)
lines.append("")
return "\n".join(lines)

View file

@ -0,0 +1,167 @@
"""Deliverable Summary parser.
Decisions:
- Mirrors `parseDeliverableBuffer` from the original SPA. The CSV contains
both "D" (Deliverable) and "B" (Brief) rows; we only emit D rows because
those are the asset-level records the dashboard reasons about.
- Date fields are emitted as ISO strings (YYYY-MM-DD) so JSON serialisation
is straightforward downstream matches the original.
- Header lookup is case-insensitive and trim-stripped, first occurrence
wins (the source CSV doesn't repeat headers but we keep the rule).
"""
from __future__ import annotations
import csv
import hashlib
import io
from typing import Any, Iterable
from openpyxl import load_workbook
from app.services.zoho_parse import _parse_date
def _norm(s: Any) -> str:
return str(s or "").strip().lower()
def _str(v: Any) -> str:
if v is None:
return ""
return str(v).strip()
def _build_header_index(headers: Iterable[Any]) -> dict[str, int]:
out: dict[str, int] = {}
for i, h in enumerate(headers):
k = _norm(h)
if k and k not in out:
out[k] = i
return out
def _col(row: list[Any], idx: dict[str, int], *names: str) -> str:
for n in names:
i = idx.get(n.lower())
if i is not None and i < len(row):
v = row[i]
if v is None:
continue
s = str(v).strip()
if s:
return s
return ""
def _col_raw(row: list[Any], idx: dict[str, int], *names: str) -> Any:
for n in names:
i = idx.get(n.lower())
if i is not None and i < len(row):
v = row[i]
if v is None or (isinstance(v, str) and v.strip() == ""):
continue
return v
return None
def _date_iso(v: Any) -> str:
d = _parse_date(v)
return d.isoformat() if d else ""
# Expected canonical headers — anything outside this set goes into
# unrecognised_columns.
_KNOWN_HEADERS = {
"component", "project number", "project status", "deliverable number",
"deliverable status", "project type (from omg)", "project type",
"deliverable start date", "deliverable end date",
"project start date", "project end date",
"brand", "business division", "business area - lv 2", "business area",
"market", "deliverable title",
}
def _build_rows(headers: list[Any], data_rows: Iterable[list[Any]]) -> tuple[list[dict[str, Any]], list[str]]:
idx = _build_header_index(headers)
unrecognised: list[str] = []
seen: set[str] = set()
for h in headers:
s = _str(h)
if not s:
continue
if s.lower() in _KNOWN_HEADERS:
continue
if s in seen:
continue
seen.add(s)
unrecognised.append(s)
out: list[dict[str, Any]] = []
for row in data_rows:
if not row or all(c in (None, "") for c in row):
continue
# D-row filter (per original).
component = _col(list(row), idx, "component")
if component and component.upper() != "D":
continue
project_number = _col(list(row), idx, "project number")
if not project_number:
continue
d_start = _date_iso(_col_raw(list(row), idx, "deliverable start date"))
d_end = _date_iso(_col_raw(list(row), idx, "deliverable end date"))
if not d_start or not d_end:
continue
out.append({
"projectNumber": project_number,
"projectStatus": _col(list(row), idx, "project status").upper(),
"deliverableNumber": _col(list(row), idx, "deliverable number"),
"deliverableStatus": _col(list(row), idx, "deliverable status").upper(),
"projectType": _col(list(row), idx, "project type (from omg)", "project type"),
"deliverableStartDate": d_start,
"deliverableEndDate": d_end,
"projectStartDate": _date_iso(_col_raw(list(row), idx, "project start date")),
"projectEndDate": _date_iso(_col_raw(list(row), idx, "project end date")),
"brand": _col(list(row), idx, "brand") or "Unknown",
"businessDivision": _col(list(row), idx, "business division") or "Unknown",
"businessArea": _col(list(row), idx, "business area - lv 2", "business area") or "Unknown",
"market": _col(list(row), idx, "market") or "Unknown",
"deliverableTitle": _col(list(row), idx, "deliverable title"),
})
return out, unrecognised
def parse(filename: str, content: bytes) -> dict[str, Any]:
fn = (filename or "").lower()
if fn.endswith(".xlsx") or fn.endswith(".xlsm"):
rows, unknown = _parse_xlsx(content)
else:
rows, unknown = _parse_csv(content)
digest = hashlib.sha256(content).hexdigest()
return {"rows": rows, "unrecognised_columns": unknown, "content_hash": f"sha256:{digest}"}
def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
text = content.decode("utf-8-sig", errors="replace")
reader = csv.reader(io.StringIO(text))
rows = list(reader)
if not rows:
return [], []
return _build_rows(rows[0], rows[1:])
def _parse_xlsx(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb.active
if ws is None:
return [], []
rows_iter = ws.iter_rows(values_only=True)
try:
headers = list(next(rows_iter))
except StopIteration:
return [], []
data = (list(r) for r in rows_iter)
return _build_rows(headers, data)

View file

@ -0,0 +1,319 @@
"""Forecast pipeline — 4 weeks ahead capacity planning.
Decisions:
- Mirrors the original SPA's `buildForecast` math but trims the output
surface to the brief's contract: weekly rows with
activeAssets / exitingAssets / exitRate / weeklyThroughput /
deptCapacityAssetsPerWeek / canTakeOn, plus a single capacity decision
string. The TS port had a full multi-type breakdown out of scope.
- We avoid pulling the original's 8-week look-back / weighted-mix logic
in full; the brief just needs the weekly assets/throughput pair.
- Pure function (no FastAPI imports here) so it can be unit-tested.
- Inputs are the parsed-timelog rows (camelCase from zoho_parse) plus
optional projectSummary rows and deliverables. When PS is supplied we
use it as the authoritative active-projects list; otherwise we infer
from the time log (last-seen projectStartDate / projectEndDate).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Iterable
def _iso(d: date) -> str:
return d.isoformat()
def _iso_week_label(d: date) -> str:
iso = d.isocalendar()
return f"W{iso.week:02d}"
def _monday_of(d: date) -> date:
return d - timedelta(days=d.weekday())
def _coerce_date(v: Any) -> date | None:
if v is None or v == "":
return None
if isinstance(v, date) and not isinstance(v, datetime):
return v
if isinstance(v, datetime):
return v.date()
try:
return date.fromisoformat(str(v)[:10])
except (ValueError, TypeError):
return None
def _weekday_overlap(a_start: date, a_end: date, b_start: date, b_end: date) -> int:
"""Mon-Fri overlap count between [a] and [b] (inclusive)."""
if not a_start or not a_end or not b_start or not b_end:
return 0
start = max(a_start, b_start)
end = min(a_end, b_end)
if end < start:
return 0
days = 0
d = start
while d <= end:
if d.weekday() < 5:
days += 1
d += timedelta(days=1)
return days
def _weekdays_in(start: date, end: date) -> int:
return _weekday_overlap(start, end, start, end)
def _historic_weekly_throughput(rows: Iterable[dict[str, Any]]) -> float:
"""Average ISO-week asset throughput from completed projects in the
time log. We approximate "throughput" as the average distinct
project count per ISO week over the last 4 full weeks of data.
"""
by_week: dict[str, set[str]] = {}
for r in rows:
d = _coerce_date(r.get("date"))
if not d:
continue
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
if not title:
continue
iso = d.isocalendar()
key = f"{iso.year:04d}-W{iso.week:02d}"
by_week.setdefault(key, set()).add(title)
if not by_week:
return 0.0
keys = sorted(by_week.keys())
# Drop the most recent ("partial") week and average over the next 4.
full = keys[:-1] if len(keys) > 1 else keys
baseline = full[-4:]
if not baseline:
return 0.0
return sum(len(by_week[k]) for k in baseline) / len(baseline)
def _avg_hours_per_asset(rows: Iterable[dict[str, Any]]) -> float:
"""Weighted avg hours-per-asset across the time log."""
project_hours: dict[str, float] = {}
project_assets: dict[str, float] = {}
for r in rows:
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
if not title:
continue
project_hours[title] = project_hours.get(title, 0.0) + float(r.get("hoursLogged") or r.get("hours") or 0)
ac = r.get("assetCount")
if ac and ac > 0:
project_assets[title] = float(ac)
total_h = 0.0
total_a = 0.0
for p, a in project_assets.items():
total_h += project_hours.get(p, 0.0)
total_a += a
return (total_h / total_a) if total_a > 0 else 0.0
def _is_active_status(s: str | None) -> bool:
if not s:
return True
u = s.upper()
return u not in {"COMPLETE", "CANCELLED", "REJECTED", "DECLINED", "APPROVED"}
def _project_universe(
logs: Iterable[dict[str, Any]],
project_summary: Iterable[dict[str, Any]] | None,
deliverables: Iterable[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Project records with {projectNumber, projectTitle, projectType,
projectStartDate, projectEndDate, assetCount, status}. PS wins; else
fall back to time-log-derived rows enriched with deliverable dates.
"""
if project_summary:
ps = [dict(p) for p in project_summary]
return ps
del_dates: dict[str, tuple[str | None, str | None, str | None]] = {}
del_assets: dict[str, float] = {}
for d in (deliverables or []):
pn = d.get("projectNumber")
if not pn:
continue
if pn not in del_dates:
del_dates[pn] = (d.get("projectStartDate"), d.get("projectEndDate"), d.get("projectStatus"))
del_assets[pn] = del_assets.get(pn, 0.0) + 1.0
by_proj: dict[str, dict[str, Any]] = {}
for r in logs:
pn = r.get("projectNumber") or r.get("projectTitle")
if not pn:
continue
p = by_proj.setdefault(pn, {
"projectNumber": r.get("projectNumber"),
"projectTitle": r.get("projectTitle") or pn,
"projectType": r.get("projectType"),
"projectStartDate": r.get("projectStartDate"),
"projectEndDate": r.get("projectEndDate"),
"projectStatus": r.get("projectStatus"),
"assetCount": r.get("assetCount"),
})
if not p.get("projectStartDate") and r.get("projectStartDate"):
p["projectStartDate"] = r["projectStartDate"]
if not p.get("projectEndDate") and r.get("projectEndDate"):
p["projectEndDate"] = r["projectEndDate"]
if p.get("assetCount") in (None, 0) and r.get("assetCount"):
p["assetCount"] = r["assetCount"]
# Enrich from deliverables when fields are missing.
for pn, p in by_proj.items():
if pn in del_dates:
ds, de, st = del_dates[pn]
if not p.get("projectStartDate"):
p["projectStartDate"] = ds
if not p.get("projectEndDate"):
p["projectEndDate"] = de
if not p.get("projectStatus") and st:
p["projectStatus"] = st
if not p.get("assetCount") and pn in del_assets:
p["assetCount"] = del_assets[pn]
return list(by_proj.values())
def build_forecast(
*,
logs: list[dict[str, Any]],
project_summary: list[dict[str, Any]] | None = None,
deliverables: list[dict[str, Any]] | None = None,
weeks_ahead: int = 4,
headcount: int | None = None,
workday_hours_per_week: float = 40.0,
from_date: date | None = None,
) -> dict[str, Any]:
"""Compute the 4-week capacity outlook.
Returns the contract documented in the brief: a list of week dicts +
totals + decision string. Pure function, no I/O.
"""
today = from_date or date.today()
week_start = _monday_of(today)
projects = _project_universe(logs, project_summary, deliverables)
# Active project filter: status not complete AND start <= week-end AND
# (no end OR end >= window-start). We materialise the per-project
# date window once.
active_projects: list[dict[str, Any]] = []
for p in projects:
if not _is_active_status(p.get("projectStatus")):
continue
ps = _coerce_date(p.get("projectStartDate"))
pe = _coerce_date(p.get("projectEndDate")) or (ps and ps + timedelta(days=90))
if not ps or not pe:
continue
active_projects.append({
**p,
"_start": ps,
"_end": pe,
"_assets": float(p.get("assetCount") or 0.0),
"_total_weekdays": max(_weekdays_in(ps, pe), 1),
})
# Historical baselines.
weekly_throughput = _historic_weekly_throughput(logs)
avg_hpa = _avg_hours_per_asset(logs)
if headcount is None:
headcount = _baseline_headcount(logs)
weekly_team_hours = headcount * workday_hours_per_week
dept_capacity = (weekly_team_hours / avg_hpa) if avg_hpa > 0 else 0.0
weeks: list[dict[str, Any]] = []
for i in range(weeks_ahead):
w_start = week_start + timedelta(days=7 * i)
w_end = w_start + timedelta(days=6)
active_assets = 0.0
exiting_assets = 0.0
for p in active_projects:
overlap = _weekday_overlap(p["_start"], p["_end"], w_start, w_end)
if overlap <= 0:
continue
share = overlap / p["_total_weekdays"]
active_assets += p["_assets"] * share
if w_start <= p["_end"] <= w_end:
exiting_assets += p["_assets"]
exit_rate = (exiting_assets / active_assets * 100.0) if active_assets > 0 else 0.0
can_take_on = max(dept_capacity - active_assets, 0.0)
weeks.append({
"weekStart": _iso(w_start),
"weekEnd": _iso(w_end),
"weekLabel": _iso_week_label(w_start),
"activeAssets": round(active_assets, 1),
"exitingAssets": round(exiting_assets, 1),
"exitRatePct": round(exit_rate, 1),
"weeklyThroughput": round(weekly_throughput, 1),
"deptCapacityAssetsPerWeek": round(dept_capacity, 1),
"canTakeOn": round(can_take_on, 1),
})
decision = _capacity_decision(weeks, dept_capacity)
return {
"weeks": weeks,
"totals": {
"weeklyThroughput": round(weekly_throughput, 1),
"deptCapacityAssetsPerWeek": round(dept_capacity, 1),
"avgHoursPerAsset": round(avg_hpa, 2),
"baselineHeadcount": headcount,
"weeksAhead": weeks_ahead,
"from": _iso(week_start),
},
"decision": decision,
}
def _baseline_headcount(logs: Iterable[dict[str, Any]]) -> int:
"""Distinct people active in the last 4 full ISO weeks of data."""
by_week: dict[str, set[str]] = {}
for r in logs:
d = _coerce_date(r.get("date"))
if not d:
continue
ident = (r.get("submitterEmail") or r.get("submitter") or "").strip()
if not ident:
continue
iso = d.isocalendar()
key = f"{iso.year:04d}-W{iso.week:02d}"
by_week.setdefault(key, set()).add(ident)
if not by_week:
return 0
keys = sorted(by_week.keys())
full = keys[:-1] if len(keys) > 1 else keys
baseline = full[-4:]
if not baseline:
return 0
avg = sum(len(by_week[k]) for k in baseline) / len(baseline)
return max(round(avg), 0)
def _capacity_decision(weeks: list[dict[str, Any]], dept_capacity: float) -> str:
"""Pick a decision label from the first week's active vs capacity ratio.
Mirrors the original SPA's "Capacity Decision" wording.
"""
if not weeks:
return "No data — upload a time log to compute capacity"
head = weeks[0]
active = float(head["activeAssets"])
cap = float(dept_capacity)
if cap <= 0:
return "No capacity baseline — needs hours-per-asset history"
ratio = active / cap
if ratio < 0.85:
return "OK to take on small briefs"
if ratio < 1.1:
return "At capacity"
return "Overloaded — push back"

View file

@ -0,0 +1,80 @@
"""Shared in-process cache for parsed uploads.
Decisions:
- We don't persist uploads to disk — they're held in memory keyed by
content sha256. This mirrors the original SPA's localStorage model:
the user re-uploads after a server restart.
- Each upload kind gets its own TTLCache (size-bounded, time-bounded).
Timelog stays small (8 entries, no explicit TTL bounded LRU is fine).
Deliverable and ProjectSummary uploads use a 1h TTL to match the brief.
- All entries are stored as the raw dict the parser returned
({"rows", "unrecognised_columns", "content_hash"}).
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
from cachetools import TTLCache
# Bounded LRU — keeps the most recent N timelog uploads for the session.
_MAX_TIMELOG = 8
_timelog_store: "OrderedDict[str, dict[str, Any]]" = OrderedDict()
# 1h TTL caches for deliverable + projectsummary, max 16 each.
_TTL_SECONDS = 60 * 60
_deliverable_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
_project_summary_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
def _put_lru(store: "OrderedDict[str, dict[str, Any]]", parsed: dict[str, Any], max_size: int) -> None:
h = parsed["content_hash"]
store[h] = parsed
store.move_to_end(h)
while len(store) > max_size:
store.popitem(last=False)
# ---- Timelog ----------------------------------------------------------
def remember_timelog(parsed: dict[str, Any]) -> None:
_put_lru(_timelog_store, parsed, _MAX_TIMELOG)
def get_timelog(content_hash: str) -> dict[str, Any] | None:
return _timelog_store.get(content_hash)
# ---- Deliverable ------------------------------------------------------
def remember_deliverable(parsed: dict[str, Any]) -> None:
_deliverable_store[parsed["content_hash"]] = parsed
def get_deliverable(content_hash: str) -> dict[str, Any] | None:
try:
return _deliverable_store[content_hash]
except KeyError:
return None
# ---- Project Summary --------------------------------------------------
def remember_project_summary(parsed: dict[str, Any]) -> None:
_project_summary_store[parsed["content_hash"]] = parsed
def get_project_summary(content_hash: str) -> dict[str, Any] | None:
try:
return _project_summary_store[content_hash]
except KeyError:
return None
def clear_all() -> None:
"""Test helper — wipes every store."""
_timelog_store.clear()
_deliverable_store.clear()
_project_summary_store.clear()

View file

@ -0,0 +1,162 @@
"""Project Summary parser.
Decisions:
- Mirrors `parseProjectSummaryBuffer` in the original SPA. One row per
project; the dashboard uses these as the authoritative project list
when present (it overrides the time-log-derived project set).
- Emits ISO date strings ("YYYY-MM-DD") for projectStartDate/EndDate so
the rest of the API speaks the same shape across deliverable / summary
/ forecast endpoints.
"""
from __future__ import annotations
import csv
import hashlib
import io
from typing import Any, Iterable
from openpyxl import load_workbook
from app.services.zoho_parse import _parse_date
_KNOWN_HEADERS = {
"project number", "project title", "project name",
"project status", "status",
"project type (from omg)", "project type",
"project start date", "project end date",
"no. of assets", "no of assets", "assets", "number of assets",
"business division", "division", "brand",
}
def _norm(s: Any) -> str:
return str(s or "").strip().lower()
def _str(v: Any) -> str:
if v is None:
return ""
return str(v).strip()
def _header_idx(headers: Iterable[Any]) -> dict[str, int]:
out: dict[str, int] = {}
for i, h in enumerate(headers):
k = _norm(h)
if k and k not in out:
out[k] = i
return out
def _col(row: list[Any], idx: dict[str, int], *names: str) -> str:
for n in names:
i = idx.get(n.lower())
if i is not None and i < len(row):
v = row[i]
if v is None:
continue
s = str(v).strip()
if s:
return s
return ""
def _col_raw(row: list[Any], idx: dict[str, int], *names: str) -> Any:
for n in names:
i = idx.get(n.lower())
if i is not None and i < len(row):
v = row[i]
if v is None or (isinstance(v, str) and v.strip() == ""):
continue
return v
return None
def _asset_count(v: Any) -> float | None:
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return float(v) if v > 0 else None
s = str(v).strip().replace(",", "")
try:
n = float(s)
return n if n > 0 else None
except ValueError:
return None
def _date_iso(v: Any) -> str | None:
d = _parse_date(v)
return d.isoformat() if d else None
def _build_rows(headers: list[Any], data_rows: Iterable[list[Any]]) -> tuple[list[dict[str, Any]], list[str]]:
idx = _header_idx(headers)
unrecognised: list[str] = []
seen: set[str] = set()
for h in headers:
s = _str(h)
if not s:
continue
if s.lower() in _KNOWN_HEADERS:
continue
if s in seen:
continue
seen.add(s)
unrecognised.append(s)
out: list[dict[str, Any]] = []
for row in data_rows:
if not row or all(c in (None, "") for c in row):
continue
project_number = _col(list(row), idx, "project number")
if not project_number:
continue
out.append({
"projectNumber": project_number,
"projectTitle": _col(list(row), idx, "project title", "project name"),
"projectStatus": _col(list(row), idx, "project status", "status").upper(),
"projectType": _col(list(row), idx, "project type (from omg)", "project type"),
"projectStartDate": _date_iso(_col_raw(list(row), idx, "project start date")),
"projectEndDate": _date_iso(_col_raw(list(row), idx, "project end date")),
"assetCount": _asset_count(_col_raw(list(row), idx, "no. of assets", "no of assets", "assets", "number of assets")),
"division": _col(list(row), idx, "business division", "division") or "Unknown",
"brand": _col(list(row), idx, "brand") or "Unknown",
})
return out, unrecognised
def parse(filename: str, content: bytes) -> dict[str, Any]:
fn = (filename or "").lower()
if fn.endswith(".xlsx") or fn.endswith(".xlsm"):
rows, unknown = _parse_xlsx(content)
else:
rows, unknown = _parse_csv(content)
digest = hashlib.sha256(content).hexdigest()
return {"rows": rows, "unrecognised_columns": unknown, "content_hash": f"sha256:{digest}"}
def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
text = content.decode("utf-8-sig", errors="replace")
reader = csv.reader(io.StringIO(text))
rows = list(reader)
if not rows:
return [], []
return _build_rows(rows[0], rows[1:])
def _parse_xlsx(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb.active
if ws is None:
return [], []
rows_iter = ws.iter_rows(values_only=True)
try:
headers = list(next(rows_iter))
except StopIteration:
return [], []
data = (list(r) for r in rows_iter)
return _build_rows(headers, data)

View file

@ -0,0 +1,162 @@
"""Per-project-type stats.
Mirrors the original SPA's projectStats math, but produces only the
small surface area the brief asks for:
{ projectType, avgHoursPerAsset, avgDurationDays, projectCount,
totalHours, concentrationPct, autoInsight }
Concentration % is the share of total hours that one project type
contributes a useful "where are our hours going" tile.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Iterable
def _coerce_date(v: Any) -> date | None:
if v is None or v == "":
return None
if isinstance(v, date) and not isinstance(v, datetime):
return v
if isinstance(v, datetime):
return v.date()
try:
return date.fromisoformat(str(v)[:10])
except (ValueError, TypeError):
return None
def _working_days_between(start: date, end: date) -> int:
if not start or not end or end < start:
return 0
days = 0
d = start
while d <= end:
if d.weekday() < 5:
days += 1
d += timedelta(days=1)
return days
def build_project_types(
*,
logs: list[dict[str, Any]],
project_summary: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Return per-type stats + auto-insights. PS-based when supplied."""
# Aggregate hours per project from the time log.
proj_hours: dict[str, float] = {}
proj_type: dict[str, str] = {}
proj_assets: dict[str, float] = {}
proj_dates: dict[str, tuple[date | None, date | None]] = {}
proj_status: dict[str, str | None] = {}
for r in logs:
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
ptype = (r.get("projectType") or "").strip()
if not title:
continue
hrs = float(r.get("hoursLogged") or r.get("hours") or 0)
proj_hours[title] = proj_hours.get(title, 0.0) + hrs
if ptype and title not in proj_type:
proj_type[title] = ptype
if title not in proj_assets and r.get("assetCount"):
proj_assets[title] = float(r["assetCount"])
if title not in proj_dates:
proj_dates[title] = (
_coerce_date(r.get("projectStartDate")),
_coerce_date(r.get("projectEndDate")),
)
if title not in proj_status:
proj_status[title] = r.get("projectStatus")
# If we have a project summary, use it as the authoritative type/asset/date list.
if project_summary:
proj_type.clear()
for ps in project_summary:
key = (ps.get("projectTitle") or ps.get("projectNumber") or "").strip()
if not key:
continue
if ps.get("projectType"):
proj_type[key] = ps["projectType"]
if ps.get("assetCount"):
proj_assets[key] = float(ps["assetCount"])
ds = _coerce_date(ps.get("projectStartDate"))
de = _coerce_date(ps.get("projectEndDate"))
if ds or de:
proj_dates[key] = (ds, de)
if ps.get("projectStatus"):
proj_status[key] = ps["projectStatus"]
# Group by type.
by_type: dict[str, dict[str, Any]] = {}
for title, ptype in proj_type.items():
bucket = by_type.setdefault(ptype, {
"projects": 0,
"hours": 0.0,
"assets": 0.0,
"duration_days": [],
})
bucket["projects"] += 1
bucket["hours"] += proj_hours.get(title, 0.0)
bucket["assets"] += proj_assets.get(title, 0.0)
s, e = proj_dates.get(title, (None, None))
if s and e:
wd = _working_days_between(s, e)
if wd > 0:
bucket["duration_days"].append(wd)
total_hours = sum(b["hours"] for b in by_type.values())
total_projects = sum(b["projects"] for b in by_type.values())
# Defensive divisors so we never divide by zero in the loop below.
hours_divisor = total_hours or 1.0
projects_divisor = total_projects or 1
stats: list[dict[str, Any]] = []
for ptype, b in by_type.items():
projects = b["projects"]
hours = b["hours"]
assets = b["assets"]
durations = b["duration_days"]
stats.append({
"projectType": ptype,
"projectCount": projects,
"totalHours": round(hours, 1),
"totalAssets": round(assets, 1),
"avgHoursPerAsset": round((hours / assets), 2) if assets > 0 else 0.0,
"avgDurationDays": round(sum(durations) / len(durations), 1) if durations else 0.0,
"concentrationPct": round(hours / hours_divisor * 100.0, 1),
"projectsPct": round(projects / projects_divisor * 100.0, 1),
})
stats.sort(key=lambda x: x["totalHours"], reverse=True)
# Auto-insights — one short sentence per row, plus a list-level note.
for s in stats:
s["autoInsight"] = _insight(s)
return {
"stats": stats,
"totals": {
"totalHours": round(total_hours, 1),
"totalProjects": total_projects,
"totalTypes": len(stats),
},
}
def _insight(s: dict[str, Any]) -> str:
ptype = s["projectType"]
h = s["concentrationPct"]
p = s["projectsPct"]
# Imbalance checks first so the more interesting message wins over the
# generic "5.0h/asset across N projects" fallback.
if p >= 30 and h < 15:
return f"{ptype}: {p:.0f}% of projects but only {h:.0f}% of hours — low effort per brief"
if h >= 30 and p <= 20:
return f"{ptype}: {h:.0f}% of hours but only {p:.0f}% of projects — concentration risk"
if s["avgHoursPerAsset"] > 0 and s["projectCount"] >= 5:
return f"{ptype}: {s['avgHoursPerAsset']:.1f}h/asset across {s['projectCount']} projects"
return f"{ptype}: {s['projectCount']} project{'s' if s['projectCount'] != 1 else ''}"

View file

@ -0,0 +1,108 @@
"""Server-side filters for the parsed timelog rows.
Decisions:
- All filters are comma-separated lists of exact string matches. Empty /
None disables the filter for that dimension.
- Filtering happens on the parsed-row shape produced by zoho_parse.py
(camelCase keys: brand, division, hub, userRole, submitter, etc.).
- Used by the forecast / project-types / timelog/rows endpoints and the
utilisation summary so the same dimensions apply everywhere.
"""
from __future__ import annotations
from typing import Any, Iterable
def _csv_to_set(s: str | None) -> set[str] | None:
if not s:
return None
parts = [p.strip() for p in s.split(",") if p.strip()]
if not parts:
return None
return set(parts)
def _ci_set(s: set[str] | None) -> set[str] | None:
if s is None:
return None
return {x.strip().lower() for x in s}
def apply_filters(
rows: Iterable[dict[str, Any]],
*,
brands: str | None = None,
divisions: str | None = None,
hubs: str | None = None,
user_roles: str | None = None,
departments: str | None = None,
names: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> list[dict[str, Any]]:
"""Filter parsed timelog rows by the FilterBar v2 dimensions.
`departments` and `names` are kept here too so callers can use the
same helper across endpoints that have Airtable-driven dept/name
dropdowns but those dimensions don't exist on the timelog row
shape, so they pass through as no-ops here (department/name filters
are honoured elsewhere in summarise()).
"""
b = _ci_set(_csv_to_set(brands))
d = _ci_set(_csv_to_set(divisions))
h = _ci_set(_csv_to_set(hubs))
r = _ci_set(_csv_to_set(user_roles))
n = _ci_set(_csv_to_set(names))
out: list[dict[str, Any]] = []
for row in rows:
if b is not None:
v = (row.get("brand") or "").strip().lower()
if v not in b:
continue
if d is not None:
v = (row.get("division") or "").strip().lower()
if v not in d:
continue
if h is not None:
v = (row.get("hub") or "").strip().lower()
if v not in h:
continue
if r is not None:
v = (row.get("userRole") or "").strip().lower()
if v not in r:
continue
if n is not None:
v = (row.get("submitter") or row.get("employee") or "").strip().lower()
if v not in n:
continue
if date_from and (row.get("date") is None or str(row.get("date")) < date_from):
continue
if date_to and (row.get("date") is None or str(row.get("date")) > date_to):
continue
out.append(row)
return out
def distinct_dimensions(rows: Iterable[dict[str, Any]]) -> dict[str, list[str]]:
"""Return sorted distinct values for the FilterBar v2 dropdowns."""
brands: set[str] = set()
divisions: set[str] = set()
hubs: set[str] = set()
user_roles: set[str] = set()
for r in rows:
if r.get("brand"):
brands.add(str(r["brand"]).strip())
if r.get("division"):
divisions.add(str(r["division"]).strip())
if r.get("hub"):
hubs.add(str(r["hub"]).strip())
if r.get("userRole"):
user_roles.add(str(r["userRole"]).strip())
return {
"brands": sorted(b for b in brands if b),
"divisions": sorted(d for d in divisions if d),
"hubs": sorted(h for h in hubs if h),
"userRoles": sorted(u for u in user_roles if u),
}

View file

@ -11,8 +11,23 @@ Decisions:
When only one of the two is present we cross-fill the other: a
billingType of client/fee implies billable=True; leave implies False.
- Date parsing tries ISO first, then dateutil for the messy formats Zoho
occasionally emits ("01/05/2026", "1-May-2026", etc.).
occasionally emits ("01/05/2026", "1-May-2026", etc.). The real Zoho
CSV uses DD/MM/YYYY UK format, so dayfirst=True is the right default.
- For .xlsx we use openpyxl read-only mode keeps memory low on big files.
v2 (parity with original SPA):
- Extracts ~20 fields rather than the original 6.
- "Time Submitter" header carries "Name (email)" we split it into
`submitter` + `submitterEmail`. Same field is also exposed as `employee`
(back-compat alias for existing merge code).
- Date preference: "Month & Year (Log Date)" first, then "Time Log Start".
The Month & Year column is monthly-bucketed which is what the
utilisation views want; Time Log Start is the actual day the user
picked. We expose Time Log Start as `timeLogStartDisplay` so the
TimeLogDetail view can show the original date.
- Header keys with duplicates: the real CSV repeats "Project Number"
later in the file (col index 56) for project-rollup metadata. We honour
the FIRST occurrence, matching the original.
"""
from __future__ import annotations
@ -32,12 +47,39 @@ logger = logging.getLogger(__name__)
# Canonical name → set of accepted aliases (compared after .strip().lower()).
#
# Order matters for "date": we prefer "Month & Year (Log Date)" over
# "Time Log Start" because the original SPA does the same. Both produce a
# `date` field; "Time Log Start" populates `timeLogStartDisplay` separately
# so the row carries both pieces.
HEADER_ALIASES: dict[str, set[str]] = {
"date": {"date", "log date", "time log start", "start date"},
"employee": {"resource name", "resource", "employee", "user", "name"},
"project": {"project title", "project name", "project"},
"task": {"task description", "task", "description"},
"hours": {"hours logged", "total hours", "hours", "time logged", "actual logged"},
"date": {
"month & year (log date)",
"month and year (log date)",
"time log start",
"log date",
"start date",
"date",
},
"timeLogStartDisplay": {"time log start", "time_log_start"},
"submitter": {"time submitter", "submitter", "resource name", "resource", "employee", "user", "name"},
"hoursLogged": {"time logged", "hours logged", "total hours", "hours", "actual logged"},
"userRole": {"user role", "role"},
"brand": {"brand", "project brand"},
"division": {"business division", "division"},
"hub": {"market", "hub", "business area - lv 2", "business area"},
"projectTitle": {"project title", "project name", "project"},
"projectType": {"project type (from omg)", "project type"},
"projectNumber": {"project number", "project no"},
"assetCount": {"no. of assets", "no of assets", "number of assets", "asset count", "assets"},
"userAgency": {"user agency"},
"employingCompany": {"user employing company", "employing company"},
"sageJobProfile": {"sage job profile", "job profile"},
"projectBillingType": {"project billing type"},
"taskDescription": {"task description", "time log task description", "task name", "task", "activity", "description"},
"projectStatus": {"project status", "status"},
"projectStartDate": {"project start date"},
"projectEndDate": {"project end date"},
"billable": {"billable", "is billable"},
"billingType": {"billing type"},
}
@ -65,20 +107,61 @@ def _canonicalise_header(raw: str) -> str | None:
def _parse_date(v: Any) -> date | None:
"""Parse a date cell. Handles ISO, DD/MM/YYYY (UK/Zoho default), Excel
serials passed through as numbers, and "Month, YYYY" buckets from the
Salesforce "Month & Year (Log Date)" column."""
if v is None or v == "":
return None
if isinstance(v, date) and not isinstance(v, datetime):
return v
if isinstance(v, datetime):
return v.date()
# Excel serial — pass through openpyxl as a number, but the CSV path
# may also see a stringified serial (rare).
if isinstance(v, (int, float)):
# Excel serial → Python date. Skip implausible values to avoid
# treating a real integer like an asset count as a date.
try:
n = float(v)
if 30000 < n < 80000:
# 1900-based serial origin (with the well-known leap-year bug).
base = date(1899, 12, 30)
return date.fromordinal(base.toordinal() + int(n))
except (ValueError, OverflowError):
pass
return None
s = str(v).strip()
if not s:
return None
try:
# ISO short-circuit
return date.fromisoformat(str(v)[:10])
return date.fromisoformat(s[:10])
except ValueError:
pass
# "Month YYYY" / "Month, YYYY" — produces the first of the month.
months_short = {
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}
months_full = {
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
parts = s.replace(",", " ").split()
if len(parts) == 2:
m = (months_full.get(parts[0].lower())
or months_short.get(parts[0].lower()[:3]))
if m:
try:
yr = int(parts[1])
if 2000 <= yr <= 2099:
return date(yr, m, 1)
except ValueError:
pass
try:
# dayfirst=True because Zoho regional defaults are commonly DD/MM.
return dateparser.parse(str(v), dayfirst=True).date()
return dateparser.parse(s, dayfirst=True).date()
except (ValueError, TypeError, OverflowError):
return None
@ -126,6 +209,49 @@ def _parse_billing_type(v: Any) -> str | None:
return s or None
def _parse_asset_count(v: Any) -> float | None:
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return float(v) if v > 0 else None
s = str(v).strip().replace(",", "")
if not s:
return None
try:
n = float(s)
return n if n > 0 else None
except ValueError:
return None
def _parse_str(v: Any) -> str | None:
if v is None:
return None
s = str(v).strip()
return s or None
def _split_submitter(raw: Any) -> tuple[str | None, str | None]:
"""Zoho's "Time Submitter" is "Name (email)" — split into the two parts.
When called on aliased columns ("Resource Name" etc.) the value is just
a plain name with no parens; we return (name, None) in that case.
"""
if raw is None:
return None, None
s = str(raw).strip()
if not s:
return None, None
if "(" in s and s.endswith(")"):
try:
name, rest = s.split("(", 1)
email = rest[:-1].strip()
return name.strip() or None, email or None
except ValueError:
pass
return s, None
# ----------------------------------------------------------------------
# Public API
# ----------------------------------------------------------------------
@ -152,54 +278,102 @@ def parse(filename: str, content: bytes) -> dict[str, Any]:
}
def _build_rows(raw_rows: Iterable[list[Any]], headers: list[Any]) -> tuple[list[dict[str, Any]], list[str]]:
# Canonical → default value when the column is missing entirely.
_DEFAULT_ROW: dict[str, Any] = {
"date": None,
"timeLogStartDisplay": None,
"submitter": None,
"submitterEmail": None,
"hoursLogged": 0.0,
"userRole": None,
"brand": None,
"division": None,
"hub": None,
"projectTitle": None,
"projectType": None,
"projectNumber": None,
"assetCount": None,
"userAgency": None,
"employingCompany": None,
"sageJobProfile": None,
"projectBillingType": None,
"taskDescription": None,
"projectStatus": None,
"projectStartDate": None,
"projectEndDate": None,
"billable": False,
"billingType": None,
}
def _build_rows(
raw_rows: Iterable[list[Any]],
headers: list[Any],
) -> tuple[list[dict[str, Any]], list[str]]:
# Map column index → canonical key. Track unknown ones.
# FIRST occurrence of a header wins — the real Zoho CSV repeats
# "Project Number" later in the row, and only the first column has
# reliable per-time-entry data.
canonical_by_idx: dict[int, str] = {}
canonical_seen: set[str] = set()
unrecognised: list[str] = []
unrecognised_seen: set[str] = set()
for idx, raw in enumerate(headers):
if raw is None or str(raw).strip() == "":
continue
canon = _canonicalise_header(raw)
if canon:
if canon in canonical_seen:
continue
canonical_by_idx[idx] = canon
canonical_seen.add(canon)
else:
unrecognised.append(str(raw).strip())
name = str(raw).strip()
if name and name not in unrecognised_seen:
unrecognised.append(name)
unrecognised_seen.add(name)
# Track whether each canonical was actually present in the headers
# so we can decide whether to cross-fill billable from billingType
# (or vice versa) without clobbering a user-supplied value.
present_canonicals = set(canonical_by_idx.values())
present_canonicals = set(canonical_seen)
out: list[dict[str, Any]] = []
for raw_row in raw_rows:
if not raw_row or all(c in (None, "") for c in raw_row):
continue
row: dict[str, Any] = {
"date": None,
"employee": None,
"project": None,
"task": None,
"hours": 0.0,
"billable": False,
"billingType": None,
}
row = dict(_DEFAULT_ROW)
for idx, canon in canonical_by_idx.items():
if idx >= len(raw_row):
continue
v = raw_row[idx]
if v is None or (isinstance(v, str) and v.strip() == ""):
continue
if canon == "date":
row["date"] = _parse_date(v)
elif canon == "hours":
row["hours"] = _parse_hours(v)
elif canon == "timeLogStartDisplay":
d = _parse_date(v)
row["timeLogStartDisplay"] = d.isoformat() if d else None
elif canon == "hoursLogged":
row["hoursLogged"] = _parse_hours(v)
elif canon == "billable":
row["billable"] = _parse_billable(v)
elif canon == "billingType":
row["billingType"] = _parse_billing_type(v)
elif canon == "assetCount":
row["assetCount"] = _parse_asset_count(v)
elif canon == "submitter":
name, email = _split_submitter(v)
row["submitter"] = name
if email:
row["submitterEmail"] = email
elif canon == "projectStatus":
s = _parse_str(v)
row["projectStatus"] = s.upper() if s else None
elif canon in {"projectStartDate", "projectEndDate"}:
d = _parse_date(v)
row[canon] = d.isoformat() if d else None
else:
row[canon] = (str(v).strip() if v is not None else None) or None
row[canon] = _parse_str(v)
# Cross-fill: when only billingType is present, derive billable.
# When only billable is present, billingType stays None.
bt = row.get("billingType")
if "billingType" in present_canonicals and bt is not None:
if bt in BILLING_TYPE_BILLABLE:
@ -207,6 +381,18 @@ def _build_rows(raw_rows: Iterable[list[Any]], headers: list[Any]) -> tuple[list
elif bt in BILLING_TYPE_LEAVE:
row["billable"] = False
# Fall-back: project title defaults to project number when blank.
if not row.get("projectTitle") and row.get("projectNumber"):
row["projectTitle"] = row["projectNumber"]
# Back-compat aliases consumed by services.merge (existing summarise).
# These mirror the v1 field names so downstream code keeps working
# without each call-site needing to be updated.
row["employee"] = row["submitter"]
row["project"] = row["projectTitle"]
row["task"] = row["taskDescription"]
row["hours"] = row["hoursLogged"]
out.append(row)
return out, unrecognised

View file

@ -0,0 +1,54 @@
"""Chat endpoint tests."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
def _client_with_bypass():
os.environ["DEV_AUTH_BYPASS"] = "true"
os.environ["ANTHROPIC_API_KEY"] = ""
from app.config import get_settings
get_settings.cache_clear()
import importlib
import app.main as main_mod
importlib.reload(main_mod)
return TestClient(main_mod.app, base_url="https://testserver")
def teardown_module(_mod):
os.environ["DEV_AUTH_BYPASS"] = "false"
os.environ.pop("ANTHROPIC_API_KEY", None)
from app.config import get_settings
get_settings.cache_clear()
import importlib
import app.main as main_mod
importlib.reload(main_mod)
def test_chat_503_when_no_api_key():
c = _client_with_bypass()
r = c.post("/api/chat", json={
"messages": [{"role": "user", "content": "hi"}],
})
assert r.status_code == 503
body = r.json()
assert "AI chat is not configured" in body["detail"]
def test_chat_400_when_empty_messages():
# Even with the key set, we should refuse empty messages.
os.environ["DEV_AUTH_BYPASS"] = "true"
os.environ["ANTHROPIC_API_KEY"] = "sk-test"
from app.config import get_settings
get_settings.cache_clear()
import importlib
import app.main as main_mod
importlib.reload(main_mod)
c = TestClient(main_mod.app, base_url="https://testserver")
r = c.post("/api/chat", json={"messages": []})
# 422 from pydantic validation or 400 from our own check — either is acceptable.
assert r.status_code in (400, 422)

View file

@ -0,0 +1,75 @@
"""Tests for the Deliverable Summary parser."""
from __future__ import annotations
from app.services.deliverable_parse import parse
def test_filters_d_rows_only():
csv = (
"Component,Project Number,Project Status,Deliverable Number,Deliverable Status,"
"Project Type (from OMG),Deliverable Start Date,Deliverable End Date,"
"Project Start Date,Project End Date,Brand,Business Division,"
"Business Area - Lv 2,Market,Deliverable Title\n"
"B,PRJ-1,PROGRESS,BR-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Brief desc\n"
"D,PRJ-1,PROGRESS,DEL-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
"01/04/2026,30/06/2026,LOR,Global,UK,UK,First asset\n"
"D,PRJ-1,PROGRESS,DEL-2,APPROVED,BANNER PUSH,03/05/2026,04/05/2026,"
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Second asset\n"
).encode("utf-8")
out = parse("deliv.csv", csv)
rows = out["rows"]
assert len(rows) == 2
assert all(r["projectNumber"] == "PRJ-1" for r in rows)
# Dates ISO-formatted.
assert rows[0]["deliverableStartDate"] == "2026-05-01"
assert rows[0]["projectStartDate"] == "2026-04-01"
assert rows[0]["projectStatus"] == "PROGRESS"
assert rows[0]["deliverableStatus"] == "APPROVED"
assert out["content_hash"].startswith("sha256:")
def test_skips_rows_missing_deliverable_dates():
csv = (
"Component,Project Number,Deliverable Status,Project Status,Project Type,"
"Deliverable Start Date,Deliverable End Date,Project Start Date,Project End Date,"
"Brand,Business Division,Market,Deliverable Title,Deliverable Number,Business Area - Lv 2\n"
"D,PRJ-A,APPROVED,PROGRESS,T,,,2026-01-01,2026-12-31,B,Glob,UK,Title,D-1,UK\n"
"D,PRJ-B,APPROVED,PROGRESS,T,2026-05-01,2026-05-31,2026-01-01,2026-12-31,B,Glob,UK,Title,D-2,UK\n"
).encode("utf-8")
out = parse("d.csv", csv)
assert len(out["rows"]) == 1
assert out["rows"][0]["projectNumber"] == "PRJ-B"
def test_unrecognised_columns_surface():
csv = (
"Component,Project Number,Deliverable Start Date,Deliverable End Date,"
"Wibble Factor\n"
"D,PRJ-X,2026-05-01,2026-05-15,42\n"
).encode("utf-8")
out = parse("u.csv", csv)
assert "Wibble Factor" in out["unrecognised_columns"]
def test_brand_defaults_to_unknown():
csv = (
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
"D,PRJ-Z,2026-05-01,2026-05-15\n"
).encode("utf-8")
out = parse("d.csv", csv)
assert out["rows"][0]["brand"] == "Unknown"
assert out["rows"][0]["businessDivision"] == "Unknown"
assert out["rows"][0]["market"] == "Unknown"
assert out["rows"][0]["projectStatus"] == ""
def test_content_hash_stable():
csv = (
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
"D,PRJ-Z,2026-05-01,2026-05-15\n"
).encode("utf-8")
a = parse("a.csv", csv)
b = parse("b.csv", csv)
assert a["content_hash"] == b["content_hash"]

View file

@ -0,0 +1,165 @@
"""Tests for the forecast pipeline."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.forecast import build_forecast
def _log(d: str, **kw) -> dict:
base = {
"date": date.fromisoformat(d),
"submitter": "Alice",
"submitterEmail": "alice@example.com",
"hoursLogged": 8.0,
"projectTitle": kw.get("project", "P1"),
"projectNumber": kw.get("projectNumber", "PRJ-1"),
"projectType": kw.get("projectType", "BANNER PUSH"),
"projectStartDate": kw.get("ps"),
"projectEndDate": kw.get("pe"),
"projectStatus": kw.get("status", "PROGRESS"),
"assetCount": kw.get("assets"),
}
# Allow kw to override defaults explicitly.
base.update({k: v for k, v in kw.items()
if k not in {"project", "projectNumber", "projectType", "ps", "pe", "status", "assets"}})
return base
def test_forecast_empty_inputs():
out = build_forecast(logs=[], weeks_ahead=4, from_date=date(2026, 5, 18))
assert len(out["weeks"]) == 4
assert all(w["activeAssets"] == 0.0 for w in out["weeks"])
assert out["totals"]["weeklyThroughput"] == 0.0
assert out["decision"].lower().startswith("no")
def test_forecast_active_assets_pro_rated():
"""A 4-asset project spanning all four forecast weeks should appear
in active count for each week, pro-rated by weekday overlap."""
logs = [
_log("2026-05-04", projectNumber="PRJ-1", project="P1",
ps="2026-05-18", pe="2026-06-12", assets=4.0),
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
assert len(out["weeks"]) == 4
# 4 weeks × 5 weekdays = 20 working days. Each forecast week has 5/20 = 25% share = 1 asset.
for w in out["weeks"]:
assert w["activeAssets"] == pytest.approx(1.0, abs=0.05)
def test_forecast_exiting_assets_only_in_exit_week():
"""Project ending in week 2 should show in exitingAssets only that week."""
logs = [
_log("2026-05-04", projectNumber="PRJ-2", project="P2",
ps="2026-05-18", pe="2026-05-29", assets=8.0),
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
# The project ends 2026-05-29 (Fri of W22). week 2 in the forecast is W22.
weeks = out["weeks"]
assert weeks[0]["exitingAssets"] == 0.0
# Find the week whose range contains 2026-05-29.
exit_week = [w for w in weeks if w["weekStart"] <= "2026-05-29" <= w["weekEnd"]]
assert len(exit_week) == 1
assert exit_week[0]["exitingAssets"] == 8.0
def test_forecast_decision_thresholds():
"""The decision string should reflect ratio of active vs capacity."""
# Low load → "OK to take on small briefs".
logs_light = [
_log(f"2026-04-{6+i:02d}", projectNumber=f"PR-{i}",
project=f"L{i}", ps=f"2026-04-{6+i:02d}", pe=f"2026-04-{10+i:02d}",
assets=10.0)
for i in range(8)
]
out = build_forecast(
logs=logs_light,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=10,
)
# No active projects in the future window → ratio = 0 → "OK".
assert "OK" in out["decision"] or "small briefs" in out["decision"]
def test_forecast_uses_project_summary_when_provided():
"""When projectSummary is supplied it wins over logs-derived data."""
logs = [
# Time log says project ends 2026-12-31 with 1 asset.
_log("2026-05-04", projectNumber="PRJ-9",
ps="2026-05-04", pe="2026-12-31", assets=1.0),
]
project_summary = [{
"projectNumber": "PRJ-9",
"projectTitle": "P9",
"projectType": "BANNER PUSH",
"projectStatus": "PROGRESS",
"projectStartDate": "2026-05-18",
"projectEndDate": "2026-05-22",
"assetCount": 99.0,
"division": "Local",
"brand": "X",
}]
out = build_forecast(
logs=logs,
project_summary=project_summary,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
# First week ends 2026-05-24 → contains all of the 2026-05-18..22 window.
first = out["weeks"][0]
# All 5 weekdays of project fit in 5 weekdays of week → 100% share = 99 assets.
assert first["activeAssets"] == pytest.approx(99.0, abs=0.5)
assert first["exitingAssets"] == 99.0
def test_forecast_dept_capacity_uses_hours_per_asset():
"""deptCapacityAssetsPerWeek = headcount × 40h / avgHoursPerAsset."""
# Project with 10 assets and 100 hours logged → 10h/asset.
logs = [
# 10h logged on 10 different days = 100h total against 10-asset project.
_log(f"2026-05-{4+i:02d}", projectNumber="PRJ-X",
project="X", ps="2026-05-04", pe="2026-05-15",
assets=10.0, hoursLogged=10.0)
for i in range(10)
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 6, 1),
headcount=2,
)
# 2 people × 40h = 80h / 10h-per-asset = 8 assets/week.
assert out["totals"]["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
for w in out["weeks"]:
assert w["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
def test_forecast_can_take_on_clamped_zero():
"""canTakeOn = max(0, capacity - active). Never negative."""
out = build_forecast(
logs=[
_log("2026-05-04", projectNumber="P", ps="2026-05-18",
pe="2026-06-30", assets=1000.0, hoursLogged=1.0)
],
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=1,
)
for w in out["weeks"]:
assert w["canTakeOn"] >= 0.0

View file

@ -0,0 +1,98 @@
"""Tests for the per-project-type stats service."""
from __future__ import annotations
import pytest
from app.services.project_types import build_project_types
def _log(title: str, ptype: str, hours: float, assets: float | None = None,
ps: str | None = None, pe: str | None = None) -> dict:
return {
"projectTitle": title,
"projectType": ptype,
"hoursLogged": hours,
"assetCount": assets,
"projectStartDate": ps,
"projectEndDate": pe,
"projectStatus": "COMPLETE",
}
def test_project_types_empty():
out = build_project_types(logs=[])
assert out["stats"] == []
assert out["totals"]["totalHours"] == pytest.approx(0.0)
def test_project_types_hours_per_asset_and_duration():
logs = [
_log("P1", "BANNER PUSH", 80.0, assets=8.0, ps="2026-04-06", pe="2026-04-10"),
_log("P2", "BANNER PUSH", 60.0, assets=6.0, ps="2026-04-13", pe="2026-04-17"),
_log("Q1", "ECOM TILE", 20.0, assets=10.0, ps="2026-04-06", pe="2026-04-07"),
]
out = build_project_types(logs=logs)
by_type = {s["projectType"]: s for s in out["stats"]}
assert by_type["BANNER PUSH"]["projectCount"] == 2
# 140h / 14 assets = 10h/asset.
assert by_type["BANNER PUSH"]["avgHoursPerAsset"] == pytest.approx(10.0, abs=0.01)
# 5 working days each → avg 5 days.
assert by_type["BANNER PUSH"]["avgDurationDays"] == pytest.approx(5.0, abs=0.01)
# ECOM TILE: 20h / 10 assets = 2h/asset, duration 2 days.
assert by_type["ECOM TILE"]["avgHoursPerAsset"] == pytest.approx(2.0)
assert by_type["ECOM TILE"]["avgDurationDays"] == pytest.approx(2.0)
def test_project_types_concentration_pct():
logs = [
_log("P1", "BIG", 80.0, assets=4.0),
_log("P2", "BIG", 20.0, assets=2.0),
_log("Q1", "SMALL", 10.0, assets=1.0),
]
out = build_project_types(logs=logs)
by_type = {s["projectType"]: s for s in out["stats"]}
# BIG: 100h / 110h ≈ 90.9%; SMALL: 10/110 ≈ 9.1%.
assert by_type["BIG"]["concentrationPct"] == pytest.approx(90.9, abs=0.5)
assert by_type["SMALL"]["concentrationPct"] == pytest.approx(9.1, abs=0.5)
def test_project_types_auto_insight_concentration_risk():
"""A type with 30%+ of hours and <20% of projects should flag concentration risk."""
# 2 BANNER projects with lots of hours, 8 SMALL projects with little.
logs = []
for i in range(2):
logs.append(_log(f"big-{i}", "BANNER", 100.0, assets=5.0))
for i in range(8):
logs.append(_log(f"small-{i}", "TINY", 5.0, assets=1.0))
out = build_project_types(logs=logs)
banner = next(s for s in out["stats"] if s["projectType"] == "BANNER")
# 200h / 240h ≈ 83% hours, 2/10 = 20% projects (boundary — should still trigger).
# Insight string mentions concentration.
assert banner["concentrationPct"] >= 30
# The 8 TINY projects have 33% concentration of projects but <15% hours.
tiny = next(s for s in out["stats"] if s["projectType"] == "TINY")
assert tiny["projectsPct"] > 30
assert "low effort per brief" in tiny["autoInsight"] or "concentration" in banner["autoInsight"]
def test_project_summary_overrides_log_data():
"""When projectSummary is supplied, asset/type/date data comes from it."""
logs = [_log("P1", "OLD-TYPE", 100.0, assets=10.0)]
ps = [{
"projectNumber": "PRJ-1",
"projectTitle": "P1",
"projectStatus": "COMPLETE",
"projectType": "NEW-TYPE",
"projectStartDate": "2026-04-06",
"projectEndDate": "2026-04-10",
"assetCount": 5.0,
"division": "X",
"brand": "Y",
}]
out = build_project_types(logs=logs, project_summary=ps)
types = {s["projectType"]: s for s in out["stats"]}
assert "NEW-TYPE" in types
assert "OLD-TYPE" not in types
# 100h / 5 assets = 20h/asset (PS asset count wins).
assert types["NEW-TYPE"]["avgHoursPerAsset"] == pytest.approx(20.0)

View file

@ -1,24 +1,45 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import Navbar from './components/Navbar';
import StatsBar from './components/StatsBar';
import ChatToggle from './components/ChatToggle';
import Loading from './components/Loading';
import Login from './pages/Login';
import { DataProvider } from './hooks/useDataContext';
import { canAccess, useAuth } from './hooks/useAuth';
const Department = lazy(() => import('./pages/Department'));
const Resourcing = lazy(() => import('./pages/Resourcing'));
const Bookings = lazy(() => import('./pages/Bookings'));
const Tutorial = lazy(() => import('./pages/Tutorial'));
const Forecast = lazy(() => import('./pages/Forecast'));
const ProjectTypeSummary = lazy(() => import('./pages/ProjectTypeSummary'));
const TimeLogDetail = lazy(() => import('./pages/TimeLogDetail'));
function ProtectedShell({ children }: { children: React.ReactNode }) {
function RoleGate({ slug, children }: { slug: string; children: React.ReactNode }) {
const { user } = useAuth();
if (user && !canAccess(user.role, slug)) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) {
return (
<AuthGate>
<DataProvider>
<RoleGate slug={slug}>
<div className="flex min-h-screen flex-col">
<Navbar />
<StatsBar />
<main className="mx-auto w-full max-w-7xl flex-1 p-4 md:p-6">
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
</main>
<ChatToggle />
</div>
</RoleGate>
</DataProvider>
</AuthGate>
);
}
@ -30,7 +51,7 @@ export default function App() {
<Route
path="/"
element={
<ProtectedShell>
<ProtectedShell slug="department">
<Department />
</ProtectedShell>
}
@ -38,7 +59,7 @@ export default function App() {
<Route
path="/resourcing"
element={
<ProtectedShell>
<ProtectedShell slug="resourcing">
<Resourcing />
</ProtectedShell>
}
@ -46,15 +67,39 @@ export default function App() {
<Route
path="/bookings"
element={
<ProtectedShell>
<ProtectedShell slug="bookings">
<Bookings />
</ProtectedShell>
}
/>
<Route
path="/forecast"
element={
<ProtectedShell slug="forecast">
<Forecast />
</ProtectedShell>
}
/>
<Route
path="/project-types"
element={
<ProtectedShell slug="project-type">
<ProjectTypeSummary />
</ProtectedShell>
}
/>
<Route
path="/time-log"
element={
<ProtectedShell slug="time-log">
<TimeLogDetail />
</ProtectedShell>
}
/>
<Route
path="/tutorial"
element={
<ProtectedShell>
<ProtectedShell slug="tutorial">
<Tutorial />
</ProtectedShell>
}

View file

@ -3,10 +3,16 @@ import type {
AuthMeResponse,
BookingsResponse,
BreakdownResponse,
ChatMessage,
ChatResponse,
DimensionsResponse,
ForecastResponse,
LoginResponse,
MetaResponse,
ParseResponse,
ProjectTypesResponse,
ResourcesResponse,
TimelogRowsResponse,
UtilisationSummaryResponse,
} from './types';
@ -61,6 +67,46 @@ export function parseTimelog(file: File) {
});
}
export function parseDeliverable(file: File) {
const fd = new FormData();
fd.append('file', file);
return apiFetch<ParseResponse>('/deliverable/parse', {
method: 'POST',
body: fd,
skipJsonContentType: true,
});
}
export function parseProjectSummary(file: File) {
const fd = new FormData();
fd.append('file', file);
return apiFetch<ParseResponse>('/projectsummary/parse', {
method: 'POST',
body: fd,
skipJsonContentType: true,
});
}
export interface TimelogRowsParams {
page?: number;
pageSize?: number;
search?: string;
sort?: string;
timelogHash?: string;
}
export function getTimelogRows(params: TimelogRowsParams = {}) {
return apiFetch<TimelogRowsResponse>(
`/timelog/rows${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
);
}
export function getTimelogDimensions(timelogHash?: string) {
return apiFetch<DimensionsResponse>(
`/timelog/dimensions${buildQuery({ timelogHash })}`,
);
}
// ---- Utilisation ----------------------------------------------------------
export interface SummaryParams {
@ -69,6 +115,10 @@ export interface SummaryParams {
department?: string;
name?: string;
billing_type?: string;
brands?: string;
divisions?: string;
hubs?: string;
userRoles?: string;
period?: 'week' | 'month';
timelogHash?: string;
}
@ -94,3 +144,62 @@ export function getUtilisationBreakdown(params: BreakdownParams) {
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
return apiFetch<BreakdownResponse>(`/utilisation/breakdown${buildQuery(query)}`, { headers });
}
// ---- Forecast -------------------------------------------------------------
export interface ForecastParams {
weeks_ahead?: number;
from?: string;
timelogHash?: string;
deliverableHash?: string;
projectSummaryHash?: string;
brands?: string;
divisions?: string;
hubs?: string;
userRoles?: string;
departments?: string;
names?: string;
}
export function getForecast(params: ForecastParams = {}) {
return apiFetch<ForecastResponse>(
`/forecast${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
);
}
// ---- Project Types --------------------------------------------------------
export interface ProjectTypesParams {
timelogHash?: string;
projectSummaryHash?: string;
brands?: string;
divisions?: string;
hubs?: string;
userRoles?: string;
}
export function getProjectTypes(params: ProjectTypesParams = {}) {
return apiFetch<ProjectTypesResponse>(
`/project-types${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
);
}
// ---- Chat -----------------------------------------------------------------
export interface ChatRequest {
messages: ChatMessage[];
context?: {
timelogHash?: string;
projectSummaryHash?: string;
includeBookings?: boolean;
includeResources?: boolean;
};
max_tokens?: number;
}
export function postChat(req: ChatRequest) {
return apiFetch<ChatResponse>('/chat', {
method: 'POST',
body: JSON.stringify(req),
});
}

View file

@ -35,13 +35,36 @@ export interface MetaResponse {
bookingStatuses: string[];
}
/** v2 timelog row — matches the camelCase shape produced by zoho_parse.py. */
export interface TimelogRow {
date: string;
employee: string;
project: string;
task: string;
hours: number;
date: string | null;
timeLogStartDisplay: string | null;
submitter: string | null;
submitterEmail: string | null;
hoursLogged: number;
userRole: string | null;
brand: string | null;
division: string | null;
hub: string | null;
projectTitle: string | null;
projectType: string | null;
projectNumber: string | null;
assetCount: number | null;
userAgency: string | null;
employingCompany: string | null;
sageJobProfile: string | null;
projectBillingType: string | null;
taskDescription: string | null;
projectStatus: string | null;
projectStartDate: string | null;
projectEndDate: string | null;
billable: boolean;
billingType: string | null;
// Back-compat aliases — older code paths read these directly.
employee?: string | null;
project?: string | null;
task?: string | null;
hours?: number;
}
export interface ParseResponse {
@ -50,6 +73,35 @@ export interface ParseResponse {
content_hash: string;
}
export interface DeliverableRow {
projectNumber: string;
projectStatus: string;
deliverableNumber: string;
deliverableStatus: string;
projectType: string;
deliverableStartDate: string;
deliverableEndDate: string;
projectStartDate: string;
projectEndDate: string;
brand: string;
businessDivision: string;
businessArea: string;
market: string;
deliverableTitle: string;
}
export interface ProjectSummaryRow {
projectNumber: string;
projectTitle: string;
projectStatus: string;
projectType: string;
projectStartDate: string | null;
projectEndDate: string | null;
assetCount: number | null;
division: string;
brand: string;
}
export interface UtilisationSummaryRow {
period: string;
employee: string;
@ -123,13 +175,103 @@ export interface BookingsResponse {
cached_at: string;
}
export type UserRole = 'global-lead' | 'dept-lead' | 'forecast';
export interface AuthMeResponse {
username: string;
mode: string;
role: UserRole;
}
export interface LoginResponse {
ok: boolean;
username: string;
mode: string;
role: UserRole;
}
// ---- Forecast ------------------------------------------------------------
export interface ForecastWeek {
weekStart: string;
weekEnd: string;
weekLabel: string;
activeAssets: number;
exitingAssets: number;
exitRatePct: number;
weeklyThroughput: number;
deptCapacityAssetsPerWeek: number;
canTakeOn: number;
}
export interface ForecastResponse {
weeks: ForecastWeek[];
totals: {
weeklyThroughput: number;
deptCapacityAssetsPerWeek: number;
avgHoursPerAsset: number;
baselineHeadcount: number;
weeksAhead: number;
from: string;
};
decision: string;
}
// ---- Project Types -------------------------------------------------------
export interface ProjectTypeStat {
projectType: string;
projectCount: number;
totalHours: number;
totalAssets: number;
avgHoursPerAsset: number;
avgDurationDays: number;
concentrationPct: number;
projectsPct: number;
autoInsight: string;
}
export interface ProjectTypesResponse {
stats: ProjectTypeStat[];
totals: {
totalHours: number;
totalProjects: number;
totalTypes: number;
};
}
// ---- Dimensions ----------------------------------------------------------
export interface DimensionsResponse {
brands: string[];
divisions: string[];
hubs: string[];
userRoles: string[];
}
// ---- Timelog rows --------------------------------------------------------
export interface TimelogRowsResponse {
rows: TimelogRow[];
total: number;
page: number;
pageSize: number;
}
// ---- Chat ---------------------------------------------------------------
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatResponse {
// The Anthropic SDK shape, narrowed to what we use.
content?: Array<{ type: 'text'; text: string }>;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
}

View file

@ -0,0 +1,38 @@
import { lazy, Suspense, useState } from 'react';
import { MessageSquare, X } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
const ChatView = lazy(() => import('./ChatView'));
/**
* Floating bottom-right toggle that mounts the lazy-loaded ChatView panel.
* Hidden for the `forecast` role per the spec.
*/
export default function ChatToggle() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
if (!user || user.role === 'forecast') return null;
return (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-2xl transition-colors ${
open ? 'bg-slate-700 hover:bg-slate-600' : 'bg-blue-600 hover:bg-blue-500'
}`}
aria-label={open ? 'Close AI chat' : 'Open AI chat'}
title={open ? 'Close AI chat' : 'Open AI chat'}
data-tutorial-id="chat-toggle"
>
{open ? <X className="h-5 w-5 text-white" aria-hidden /> : <MessageSquare className="h-5 w-5 text-white" aria-hidden />}
</button>
{open && (
<Suspense fallback={null}>
<ChatView onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}

View file

@ -0,0 +1,181 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { X, Send } from 'lucide-react';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { ChatMessage } from '../api/types';
import { useDataContext } from '../hooks/useDataContext';
interface Props {
onClose: () => void;
}
const SUGGESTIONS: string[] = [
"Who's overloaded this week?",
'Show me upcoming project deadlines',
'Which brands burn the most hours?',
'Estimate hours for a typical Banner Push project',
"Where's the capacity risk?",
'Reconcile email mismatches',
];
export default function ChatView({ onClose }: Props) {
const { timelog, projectSummary } = useDataContext();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const send = useCallback(
async (text?: string) => {
const msg = (text ?? input).trim();
if (!msg || busy) return;
setInput('');
setError(null);
const next: ChatMessage[] = [...messages, { role: 'user', content: msg }];
setMessages(next);
setBusy(true);
try {
const res = await api.postChat({
messages: next,
context: {
timelogHash: timelog.hash ?? undefined,
projectSummaryHash: projectSummary.hash ?? undefined,
},
});
const reply = res.content?.[0]?.text ?? '';
setMessages((m) => [...m, { role: 'assistant', content: reply }]);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 503) {
setError('AI chat is not configured. Ask an admin to set ANTHROPIC_API_KEY in backend .env.');
} else if (err.status === 429) {
setError('Too many requests — please wait a moment and try again.');
} else {
setError(err.detail || `Something went wrong (${err.status}).`);
}
} else {
setError((err as Error).message || 'Something went wrong.');
}
} finally {
setBusy(false);
}
},
[input, messages, busy, timelog.hash, projectSummary.hash],
);
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
};
const hasData = (timelog.rows ?? 0) > 0;
return (
<div className="fixed bottom-20 right-4 z-50 flex h-[70vh] w-[420px] max-w-[92vw] flex-col rounded-xl bg-white shadow-2xl ring-1 ring-slate-300">
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2">
<span className={`h-2 w-2 rounded-full ${hasData ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span className="text-sm font-semibold text-slate-800">AI Assistant</span>
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
className="ml-auto rounded border border-slate-300 px-2 py-0.5 text-[10px] text-slate-500 hover:bg-slate-50"
>
Clear
</button>
)}
<button
type="button"
onClick={onClose}
className="ml-2 rounded p-1 text-slate-500 hover:bg-slate-100"
aria-label="Close AI chat"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && (
<div className="space-y-2">
<p className="text-xs text-slate-500">Try asking:</p>
<div className="flex flex-col gap-1.5">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => void send(s)}
disabled={!hasData || busy}
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 disabled:opacity-40"
>
{s}
</button>
))}
</div>
{!hasData && <p className="text-[10px] text-slate-400">Upload a time log first to give the AI real context.</p>}
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`flex gap-2 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{m.role === 'assistant' && (
<div className="mt-0.5 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">
AI
</div>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-xs leading-relaxed ${
m.role === 'user'
? 'rounded-br-sm bg-blue-600 text-white'
: 'rounded-bl-sm bg-slate-100 text-slate-800'
}`}
>
{m.content || (busy && i === messages.length - 1 ? '…' : '')}
</div>
</div>
))}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">{error}</div>
)}
<div ref={bottomRef} />
</div>
<div className="border-t border-slate-200 px-3 py-2">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKey}
placeholder={hasData ? 'Ask anything… (↵ to send)' : 'Upload data first…'}
disabled={busy}
rows={1}
className="input resize-none text-xs"
/>
<button
type="button"
onClick={() => void send()}
disabled={!input.trim() || busy}
className="btn-primary disabled:opacity-50"
aria-label="Send"
>
<Send className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
</div>
);
}

View file

@ -12,6 +12,10 @@ interface Props {
departments: string[];
names: string[];
billingTypes: string[];
brands?: string[];
divisions?: string[];
hubs?: string[];
userRoles?: string[];
showForecastToggle?: boolean;
}
@ -29,7 +33,7 @@ function MultiSelect({
tutorialId?: string;
}) {
return (
<div className="min-w-[12rem] flex-1">
<div className="min-w-[10rem] flex-1">
<label className="label">{label}</label>
<select
multiple
@ -54,6 +58,10 @@ export default function FilterBar({
departments,
names,
billingTypes,
brands = [],
divisions = [],
hubs = [],
userRoles = [],
showForecastToggle = true,
}: Props) {
const customRange = useMemo(
@ -139,7 +147,7 @@ export default function FilterBar({
onChange={(v) => dispatch({ type: 'set-names', names: v })}
tutorialId="filter-name"
/>
<div className="min-w-[12rem] flex-1">
<div className="min-w-[10rem] flex-1">
<label className="label" htmlFor="billing-type">Billing type</label>
<select
id="billing-type"
@ -158,6 +166,39 @@ export default function FilterBar({
</select>
</div>
</div>
{(brands.length > 0 || divisions.length > 0 || hubs.length > 0 || userRoles.length > 0) && (
<div className="flex flex-wrap gap-3 border-t border-slate-200 pt-3">
<MultiSelect
label="Brand"
options={brands}
selected={state.brands}
onChange={(v) => dispatch({ type: 'set-brands', brands: v })}
tutorialId="filter-brand"
/>
<MultiSelect
label="Division"
options={divisions}
selected={state.divisions}
onChange={(v) => dispatch({ type: 'set-divisions', divisions: v })}
tutorialId="filter-division"
/>
<MultiSelect
label="Hub / Market"
options={hubs}
selected={state.hubs}
onChange={(v) => dispatch({ type: 'set-hubs', hubs: v })}
tutorialId="filter-hub"
/>
<MultiSelect
label="User role"
options={userRoles}
selected={state.userRoles}
onChange={(v) => dispatch({ type: 'set-user-roles', userRoles: v })}
tutorialId="filter-user-role"
/>
</div>
)}
</div>
);
}

View file

@ -1,12 +1,24 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LogOut, BarChart3 } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
import { canAccess, useAuth } from '../hooks/useAuth';
const tabs = [
{ to: '/', label: 'Department', end: true },
{ to: '/resourcing', label: 'Resourcing' },
{ to: '/bookings', label: 'Bookings' },
{ to: '/tutorial', label: 'Tutorial' },
interface Tab {
to: string;
label: string;
slug: string;
end?: boolean;
}
// Order matches the original SPA: Forecast, Project Type Summary, Time Log
// Detail, Department, Resourcing, Bookings, Tutorial.
const TABS: Tab[] = [
{ to: '/forecast', label: 'Forecast', slug: 'forecast' },
{ to: '/project-types', label: 'Project Type Summary', slug: 'project-type' },
{ to: '/time-log', label: 'Time Log Detail', slug: 'time-log' },
{ to: '/', label: 'Department', slug: 'department', end: true },
{ to: '/resourcing', label: 'Resourcing', slug: 'resourcing' },
{ to: '/bookings', label: 'Bookings', slug: 'bookings' },
{ to: '/tutorial', label: 'Tutorial', slug: 'tutorial' },
];
export default function Navbar() {
@ -18,20 +30,22 @@ export default function Navbar() {
navigate('/login', { replace: true });
};
const visible = TABS.filter((t) => canAccess(user?.role, t.slug));
return (
<header className="bg-slate-900 text-slate-100 shadow-md" data-tutorial-id="navbar">
<div className="mx-auto flex w-full max-w-7xl items-center gap-6 px-4 py-3 md:px-6">
<div className="mx-auto flex w-full max-w-7xl items-center gap-4 px-4 py-3 md:px-6">
<div className="flex items-center gap-2 font-semibold tracking-wide">
<BarChart3 className="h-5 w-5 text-blue-400" aria-hidden />
<span>Utilisation</span>
</div>
<nav className="flex items-center gap-1">
{tabs.map((tab) => (
<nav className="flex flex-wrap items-center gap-1">
{visible.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
data-tutorial-id={`tab-${tab.label.toLowerCase()}`}
data-tutorial-id={`tab-${tab.slug}`}
className={({ isActive }) =>
[
'rounded-md px-3 py-1.5 text-sm font-medium transition',
@ -44,7 +58,12 @@ export default function Navbar() {
))}
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
{user && <span className="text-slate-400">Signed in as <span className="text-slate-200">{user.username}</span></span>}
{user && (
<span className="text-slate-400">
Signed in as <span className="text-slate-200">{user.username}</span>
{user.role && <span className="ml-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-slate-300">{user.role}</span>}
</span>
)}
<button
type="button"
onClick={handleLogout}

View file

@ -0,0 +1,39 @@
import { useDataContext } from '../hooks/useDataContext';
/**
* Always-visible stats bar that sits below the Navbar on every data page.
* Reads its numbers from the parsed timelog held in DataContext.
*
* When no time-log has been uploaded yet, the bar still renders (gives the
* tutorial a stable DOM target) but shows zeros and a placeholder
* date-range, so the chart of the day's chosen page can lay out without
* a sudden vertical jump after the upload completes.
*/
export default function StatsBar() {
const { stats } = useDataContext();
const dateRange = stats.dateMin && stats.dateMax
? `${stats.dateMin}${stats.dateMax}`
: '—';
const items: { label: string; value: string }[] = [
{ label: 'Time Entries', value: stats.entries.toLocaleString() },
{ label: 'People', value: stats.people.toLocaleString() },
{ label: 'Projects', value: stats.projects.toLocaleString() },
{ label: 'Total Hours', value: stats.totalHours.toLocaleString() },
{ label: 'Date Range', value: dateRange },
];
return (
<div
className="bg-slate-900/95 text-slate-100 border-b border-slate-800 px-4 md:px-6 py-2 flex flex-wrap gap-6 text-sm"
data-tutorial-id="stats-bar"
>
{items.map((it) => (
<div key={it.label} className="flex flex-col">
<span className="text-[10px] uppercase tracking-wide text-slate-400">{it.label}</span>
<span className="font-semibold tabular-nums">{it.value}</span>
</div>
))}
</div>
);
}

View file

@ -4,19 +4,18 @@ import 'driver.js/dist/driver.css';
import { allSteps, type TutorialSection } from './steps';
interface Props {
section: TutorialSection;
section?: TutorialSection;
onClose?: () => void;
}
/** Runs a driver.js tour for the selected section. Renders nothing — drives the DOM. */
export default function TutorialOverlay({ section, onClose }: Props) {
/** Runs a driver.js tour. Renders nothing — drives the DOM. */
export default function TutorialOverlay({ section = 'global', onClose }: Props) {
useEffect(() => {
const steps = allSteps[section]
.map((s) => ({
element: `[data-tutorial-id="${s.selector}"]`,
popover: { title: s.title, description: s.description },
}))
// Only include steps whose element actually exists on this page.
.filter((s) => document.querySelector(s.element));
if (steps.length === 0) {

View file

@ -1,142 +1,75 @@
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.
* Single global walkthrough 9 chapters that mirror the original SPA's
* TutorialView. Steps whose target isn't on the current page are filtered
* out by TutorialOverlay so the tour gracefully shortens.
*/
export const departmentSteps: TutorialStep[] = [
export const globalSteps: TutorialStep[] = [
{
selector: 'navbar',
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.',
title: 'Tab structure',
description:
'The dashboard has seven tabs: Forecast, Project Type Summary, Time Log Detail, Department, Resourcing, Bookings and Tutorial. Use this nav to jump between them — your role determines which ones are visible.',
},
{
selector: 'stats-bar',
title: 'Quick stats',
description:
'The bar under the header shows time entries, distinct people, projects, total hours and date range. Numbers update live as you filter — useful for sanity-checking what you\'re looking at.',
},
{
selector: 'upload-zone',
title: 'Upload your timelog',
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.',
title: 'Upload data',
description:
'Drop the Zoho Time Summary export here. The Deliverable Summary and Project Summary uploads are optional but improve the forecast and benchmarks substantially.',
},
{
selector: 'filter-bar',
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.',
title: 'Filters & date range',
description:
'Date presets, dept/name multiselects, and the new brand/division/hub/role filters. Filters apply uniformly across every tab.',
},
{
selector: 'forecast-canvas',
title: 'Capacity forecast',
description:
'Active vs exiting assets per week with a capacity baseline overlay. The decision banner tells you at a glance whether you can take on more work.',
},
{
selector: 'project-type-table',
title: 'Project Type Summary',
description:
'Per-type benchmarks: hours/asset, duration, concentration %. Sort columns to spot outliers and identify where your effort really goes.',
},
{
selector: 'time-log-table',
title: 'Time Log Detail',
description:
'Every parsed entry, server-paginated. Search by name, project, brand or hub. Click column headers to sort.',
},
{
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.',
title: 'Headline KPIs',
description:
'On the Department tab the KPI tiles summarise Booked, Logged, Billable, projects and active people for the active filters.',
},
{
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: '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: '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: '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',
title: 'Export to CSV',
description: 'Download the current summary as a CSV — handy for sharing outside the app.',
},
];
export const resourcingSteps: TutorialStep[] = [
{
selector: 'filter-bar',
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: '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: '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: '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',
title: 'Export to CSV',
description: 'Download the resourcing summary for the active filters.',
},
];
export const bookingsSteps: TutorialStep[] = [
{
selector: 'filter-bar',
title: 'Forecast Line & Filters',
description: 'Date range, department and name filters narrow the table below.',
},
{
selector: '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: 'Sync Airtable Bookings',
description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.',
selector: 'chat-toggle',
title: 'AI assistant',
description:
'The floating button bottom-right opens the AI panel. Ask things like "Who\'s overloaded this week?" or "Where\'s the capacity risk?" — the assistant has live context from your uploads.',
},
];
/** Back-compat — the Tutorial page used to expose multiple section tours. */
export const allSteps = {
department: departmentSteps,
resourcing: resourcingSteps,
bookings: bookingsSteps,
global: globalSteps,
} as const;
export type TutorialSection = keyof typeof allSteps;

View file

@ -1,10 +1,12 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { UserRole } from '../api/types';
export interface AuthUser {
username: string;
mode: string;
role: UserRole;
}
interface AuthContextValue {
@ -24,7 +26,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const refresh = useCallback(async () => {
try {
const me = await api.getMe();
setUser({ username: me.username, mode: me.mode });
setUser({ username: me.username, mode: me.mode, role: (me.role ?? 'global-lead') as UserRole });
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setUser(null);
@ -43,7 +45,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback(async (username: string, password: string) => {
const res = await api.login(username, password);
setUser({ username: res.username, mode: res.mode });
setUser({ username: res.username, mode: res.mode, role: (res.role ?? 'global-lead') as UserRole });
}, []);
const logout = useCallback(async () => {
@ -67,3 +69,23 @@ export function useAuth(): AuthContextValue {
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
/** Centralised tab role map mirroring the original SPA's TAB_ACCESS.
* Pages call `canAccess(role, slug)` to gate routes and Navbar to hide tabs.
*/
export const TAB_ACCESS: Record<string, UserRole[]> = {
forecast: ['global-lead', 'forecast'],
'project-type': ['global-lead'],
'time-log': ['global-lead'],
department: ['global-lead', 'dept-lead'],
resourcing: ['global-lead', 'dept-lead'],
bookings: ['global-lead', 'dept-lead'],
tutorial: ['global-lead', 'dept-lead', 'forecast'],
};
export function canAccess(role: UserRole | null | undefined, slug: string): boolean {
if (!role) return false;
const allowed = TAB_ACCESS[slug];
if (!allowed) return true;
return allowed.includes(role);
}

View file

@ -0,0 +1,209 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useState } from 'react';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { DimensionsResponse, ParseResponse } from '../api/types';
import { filterReducer, initialFilterState, type FilterAction, type FilterState } from '../lib/filters';
/**
* Lifts the parsed timelog + the v2 filter state into a top-level context.
*
* Every data page (Department, Resourcing, Bookings, Forecast,
* ProjectTypeSummary, TimeLogDetail) reads the same filter state and the
* same `timelogHash`/`deliverableHash`/`projectSummaryHash`, and the
* always-visible StatsBar shares the dimension counts.
*/
interface UploadState {
hash: string | null;
rows: number;
filename: string | null;
uploading: boolean;
error: string | null;
unrecognised: string[];
}
const emptyUpload: UploadState = {
hash: null,
rows: 0,
filename: null,
uploading: false,
error: null,
unrecognised: [],
};
interface DataContextValue {
// Filter state (single source of truth for all pages).
filters: FilterState;
dispatch: React.Dispatch<FilterAction>;
// Uploaded artefacts.
timelog: UploadState;
deliverable: UploadState;
projectSummary: UploadState;
uploadTimelog: (file: File) => Promise<void>;
uploadDeliverable: (file: File) => Promise<void>;
uploadProjectSummary: (file: File) => Promise<void>;
clearTimelog: () => void;
// FilterBar v2 dimension options (sourced from the parsed timelog).
dimensions: DimensionsResponse;
// Headline stats for the StatsBar — derived from the latest parse.
stats: { entries: number; people: number; projects: number; totalHours: number; dateMin: string; dateMax: string };
lastParse: ParseResponse | null;
}
const DataContext = createContext<DataContextValue | undefined>(undefined);
function deriveStats(rows: ParseResponse['rows']): DataContextValue['stats'] {
if (!rows || rows.length === 0) {
return { entries: 0, people: 0, projects: 0, totalHours: 0, dateMin: '', dateMax: '' };
}
const people = new Set<string>();
const projects = new Set<string>();
let totalHours = 0;
let dateMin = '';
let dateMax = '';
for (const r of rows) {
const ident = r.submitterEmail || r.submitter;
if (ident) people.add(String(ident));
const title = r.projectTitle || r.projectNumber;
if (title) projects.add(String(title));
totalHours += Number(r.hoursLogged) || 0;
const d = r.date ? String(r.date) : '';
if (d) {
if (!dateMin || d < dateMin) dateMin = d;
if (!dateMax || d > dateMax) dateMax = d;
}
}
return {
entries: rows.length,
people: people.size,
projects: projects.size,
totalHours: Math.round(totalHours),
dateMin,
dateMax,
};
}
export function DataProvider({ children }: { children: React.ReactNode }) {
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [timelog, setTimelog] = useState<UploadState>(emptyUpload);
const [deliverable, setDeliverable] = useState<UploadState>(emptyUpload);
const [projectSummary, setProjectSummary] = useState<UploadState>(emptyUpload);
const [lastParse, setLastParse] = useState<ParseResponse | null>(null);
const [dimensions, setDimensions] = useState<DimensionsResponse>({
brands: [],
divisions: [],
hubs: [],
userRoles: [],
});
const stats = useMemo(() => deriveStats(lastParse?.rows ?? []), [lastParse]);
const refreshDimensions = useCallback(async (hash: string | null) => {
if (!hash) {
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
return;
}
try {
const d = await api.getTimelogDimensions(hash);
setDimensions(d);
} catch {
// Non-fatal — multiselect just stays empty.
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
}
}, []);
useEffect(() => {
void refreshDimensions(timelog.hash);
}, [timelog.hash, refreshDimensions]);
const uploadTimelog = useCallback(async (file: File) => {
setTimelog((s) => ({ ...s, uploading: true, error: null }));
try {
const res = await api.parseTimelog(file);
setLastParse(res);
setTimelog({
hash: res.content_hash,
rows: res.rows.length,
filename: file.name,
uploading: false,
error: null,
unrecognised: res.unrecognised_columns,
});
} catch (err) {
const message = err instanceof ApiError ? err.detail : (err as Error).message;
setTimelog((s) => ({ ...s, uploading: false, error: message }));
}
}, []);
const uploadDeliverable = useCallback(async (file: File) => {
setDeliverable((s) => ({ ...s, uploading: true, error: null }));
try {
const res = await api.parseDeliverable(file);
setDeliverable({
hash: res.content_hash,
rows: res.rows.length,
filename: file.name,
uploading: false,
error: null,
unrecognised: res.unrecognised_columns,
});
} catch (err) {
const message = err instanceof ApiError ? err.detail : (err as Error).message;
setDeliverable((s) => ({ ...s, uploading: false, error: message }));
}
}, []);
const uploadProjectSummary = useCallback(async (file: File) => {
setProjectSummary((s) => ({ ...s, uploading: true, error: null }));
try {
const res = await api.parseProjectSummary(file);
setProjectSummary({
hash: res.content_hash,
rows: res.rows.length,
filename: file.name,
uploading: false,
error: null,
unrecognised: res.unrecognised_columns,
});
} catch (err) {
const message = err instanceof ApiError ? err.detail : (err as Error).message;
setProjectSummary((s) => ({ ...s, uploading: false, error: message }));
}
}, []);
const clearTimelog = useCallback(() => {
setTimelog(emptyUpload);
setLastParse(null);
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
}, []);
const value = useMemo<DataContextValue>(
() => ({
filters,
dispatch,
timelog,
deliverable,
projectSummary,
uploadTimelog,
uploadDeliverable,
uploadProjectSummary,
clearTimelog,
dimensions,
stats,
lastParse,
}),
[filters, timelog, deliverable, projectSummary, uploadTimelog, uploadDeliverable, uploadProjectSummary, clearTimelog, dimensions, stats, lastParse],
);
return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
}
export function useDataContext(): DataContextValue {
const ctx = useContext(DataContext);
if (!ctx) throw new Error('useDataContext must be used inside <DataProvider>');
return ctx;
}

View file

@ -8,6 +8,10 @@ export interface FilterState {
range: DateRange | null;
departments: string[];
names: string[];
brands: string[];
divisions: string[];
hubs: string[];
userRoles: string[];
billingType: string | null;
showForecast: boolean;
period: PeriodKind;
@ -18,6 +22,10 @@ export const initialFilterState: FilterState = {
range: dateRangePreset('this-month'),
departments: [],
names: [],
brands: [],
divisions: [],
hubs: [],
userRoles: [],
billingType: null,
showForecast: true,
period: 'week',
@ -28,6 +36,10 @@ export type FilterAction =
| { type: 'set-custom-range'; range: DateRange }
| { type: 'set-departments'; departments: string[] }
| { type: 'set-names'; names: string[] }
| { type: 'set-brands'; brands: string[] }
| { type: 'set-divisions'; divisions: string[] }
| { type: 'set-hubs'; hubs: string[] }
| { type: 'set-user-roles'; userRoles: string[] }
| { type: 'set-billing-type'; billingType: string | null }
| { type: 'set-period'; period: PeriodKind }
| { type: 'toggle-forecast' }
@ -45,6 +57,14 @@ export function filterReducer(state: FilterState, action: FilterAction): FilterS
return { ...state, departments: action.departments };
case 'set-names':
return { ...state, names: action.names };
case 'set-brands':
return { ...state, brands: action.brands };
case 'set-divisions':
return { ...state, divisions: action.divisions };
case 'set-hubs':
return { ...state, hubs: action.hubs };
case 'set-user-roles':
return { ...state, userRoles: action.userRoles };
case 'set-billing-type':
return { ...state, billingType: action.billingType };
case 'set-period':
@ -67,5 +87,9 @@ export function filtersToQuery(state: FilterState): Record<string, string | unde
name: state.names.length ? state.names.join(',') : undefined,
billing_type: state.billingType ?? undefined,
period: state.period,
brands: state.brands.length ? state.brands.join(',') : undefined,
divisions: state.divisions.length ? state.divisions.join(',') : undefined,
hubs: state.hubs.length ? state.hubs.join(',') : undefined,
userRoles: state.userRoles.length ? state.userRoles.join(',') : undefined,
};
}

View file

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import { useAirtableData } from '../hooks/useAirtableData';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
import { useDataContext } from '../hooks/useDataContext';
import { filtersToQuery } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
@ -15,7 +16,7 @@ const OVERSCAN = 8;
export default function Bookings() {
const airtable = useAirtableData(false);
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const { filters, dispatch, dimensions } = useDataContext();
const [bookings, setBookings] = useState<Booking[]>([]);
const [cachedAt, setCachedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -90,6 +91,10 @@ export default function Bookings() {
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import UploadButton from '../components/UploadButton';
@ -12,8 +12,8 @@ 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 { useDataContext } from '../hooks/useDataContext';
import { filtersToQuery } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
@ -21,8 +21,7 @@ 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 { filters, dispatch, timelog, dimensions, uploadTimelog, clearTimelog } = useDataContext();
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
@ -46,8 +45,12 @@ export default function Department() {
department: q.department,
name: q.name,
billing_type: q.billing_type,
brands: q.brands,
divisions: q.divisions,
hubs: q.hubs,
userRoles: q.userRoles,
period: filters.period,
timelogHash: tl.hash ?? undefined,
timelogHash: timelog.hash ?? undefined,
});
setSummary(res.rows);
setTotals(res.totals ?? null);
@ -56,7 +59,7 @@ export default function Department() {
} finally {
setSummaryLoading(false);
}
}, [filters, tl.hash]);
}, [filters, timelog.hash]);
useEffect(() => {
void loadSummary();
@ -65,7 +68,6 @@ export default function Department() {
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();
@ -97,13 +99,13 @@ export default function Department() {
<div className="grid gap-4 md:grid-cols-2">
<UploadButton
uploading={tl.uploading}
error={tl.error}
unrecognised={tl.unrecognised}
filename={tl.filename}
rowCount={tl.rows.length}
onFile={(f) => void tl.upload(f)}
onClear={tl.clear}
uploading={timelog.uploading}
error={timelog.error}
unrecognised={timelog.unrecognised}
filename={timelog.filename}
rowCount={timelog.rows}
onFile={(f) => void uploadTimelog(f)}
onClear={clearTimelog}
/>
<FilterBar
@ -112,6 +114,10 @@ export default function Department() {
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
/>
</div>
@ -120,7 +126,6 @@ export default function Department() {
value={filters.period}
onChange={(p) => {
dispatch({ type: 'set-period', period: p });
// Clear drill-down: period labels won't match across week/month.
setSelectedPeriod(null);
}}
/>
@ -171,7 +176,7 @@ export default function Department() {
period={selectedPeriod}
from={filters.range?.from}
to={filters.range?.to}
timelogHash={tl.hash}
timelogHash={timelog.hash}
onClose={() => setSelectedPeriod(null)}
/>
</ErrorBoundary>

View file

@ -0,0 +1,208 @@
import { useCallback, useEffect, useState } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import { useDataContext } from '../hooks/useDataContext';
import type { ForecastResponse } from '../api/types';
import { filtersToQuery } from '../lib/filters';
function decisionIcon(decision: string) {
const d = decision.toLowerCase();
if (d.includes('overload')) return <AlertTriangle className="h-5 w-5" aria-hidden />;
if (d.includes('capacity') && !d.includes('no capacity')) return <Info className="h-5 w-5" aria-hidden />;
if (d.includes('ok')) return <CheckCircle2 className="h-5 w-5" aria-hidden />;
return <Info className="h-5 w-5" aria-hidden />;
}
function decisionStyle(decision: string): string {
const d = decision.toLowerCase();
if (d.includes('overload')) return 'bg-red-50 text-red-800 ring-red-200';
if (d.includes('at capacity')) return 'bg-amber-50 text-amber-800 ring-amber-200';
if (d.includes('ok')) return 'bg-emerald-50 text-emerald-800 ring-emerald-200';
return 'bg-slate-50 text-slate-700 ring-slate-200';
}
export default function ForecastPage() {
const { filters, timelog, deliverable, projectSummary } = useDataContext();
const [data, setData] = useState<ForecastResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getForecast({
weeks_ahead: 4,
from: filters.range?.from,
timelogHash: timelog.hash ?? undefined,
deliverableHash: deliverable.hash ?? undefined,
projectSummaryHash: projectSummary.hash ?? undefined,
brands: q.brands,
divisions: q.divisions,
hubs: q.hubs,
userRoles: q.userRoles,
departments: q.department,
names: q.name,
});
setData(res);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
}, [filters, timelog.hash, deliverable.hash, projectSummary.hash]);
useEffect(() => {
void load();
}, [load]);
const chartData = (data?.weeks ?? []).map((w) => ({
label: w.weekLabel,
active: w.activeAssets,
exiting: w.exitingAssets,
exitRate: w.exitRatePct,
capacity: w.deptCapacityAssetsPerWeek,
}));
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Forecast Next 4 weeks</h1>
<p className="mt-1 text-xs text-slate-500">
Active asset count and exit rate per week, plus the department capacity baseline derived
from your historical hours-per-asset and headcount. Upload a Project Summary file to
improve accuracy.
</p>
</section>
{!timelog.hash && (
<div className="card text-sm text-slate-600">
Upload a time log on the Department tab to populate the forecast.
</div>
)}
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Computing forecast…" />}
{!loading && !error && data && (
<>
<div
className={`card flex items-center gap-3 ring-1 ${decisionStyle(data.decision)}`}
data-tutorial-id="capacity-decision"
>
{decisionIcon(data.decision)}
<div>
<div className="text-xs uppercase tracking-wide opacity-75">Capacity decision</div>
<div className="text-sm font-semibold">{data.decision}</div>
</div>
<div className="ml-auto grid grid-cols-3 gap-4 text-xs">
<div>
<div className="opacity-75">Weekly throughput</div>
<div className="font-semibold">{data.totals.weeklyThroughput}</div>
</div>
<div>
<div className="opacity-75">Capacity / week</div>
<div className="font-semibold">{data.totals.deptCapacityAssetsPerWeek}</div>
</div>
<div>
<div className="opacity-75">Avg hrs / asset</div>
<div className="font-semibold">{data.totals.avgHoursPerAsset}</div>
</div>
</div>
</div>
<div className="card" data-tutorial-id="forecast-canvas">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Active + Exiting Assets</h3>
<span className="text-xs text-slate-500">
Headcount baseline: {data.totals.baselineHeadcount}
</span>
</div>
<div className="h-80 w-full">
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} unit="%" />
<Tooltip />
<Legend />
<Bar yAxisId="left" dataKey="active" stackId="a" name="Active assets" fill="#2563eb" />
<Bar yAxisId="left" dataKey="exiting" stackId="a" name="Exiting assets" fill="#f59e0b" />
<Line
yAxisId="right"
type="monotone"
dataKey="exitRate"
name="Exit rate %"
stroke="#dc2626"
strokeWidth={2}
dot
/>
<Line
yAxisId="left"
type="monotone"
dataKey="capacity"
name="Capacity baseline"
stroke="#10b981"
strokeWidth={2}
strokeDasharray="6 3"
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
<div className="card">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Week-by-week breakdown</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="py-2 pr-2">Week</th>
<th className="py-2 pr-2">Starts</th>
<th className="py-2 pr-2 text-right">Active</th>
<th className="py-2 pr-2 text-right">Exiting</th>
<th className="py-2 pr-2 text-right">Exit %</th>
<th className="py-2 pr-2 text-right">Throughput</th>
<th className="py-2 pr-2 text-right">Capacity</th>
<th className="py-2 pr-2 text-right">Can take on</th>
</tr>
</thead>
<tbody>
{data.weeks.map((w) => (
<tr key={w.weekStart} className="border-b border-slate-100">
<td className="py-1.5 pr-2 font-medium text-slate-800">{w.weekLabel}</td>
<td className="py-1.5 pr-2 text-slate-500">{w.weekStart}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.activeAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitingAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitRatePct}%</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.weeklyThroughput}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.deptCapacityAssetsPerWeek}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.canTakeOn}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import { useDataContext } from '../hooks/useDataContext';
import type { ProjectTypeStat, ProjectTypesResponse } from '../api/types';
import { filtersToQuery } from '../lib/filters';
type SortKey = keyof ProjectTypeStat;
export default function ProjectTypeSummaryPage() {
const { filters, timelog, projectSummary } = useDataContext();
const [data, setData] = useState<ProjectTypesResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<SortKey>('totalHours');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getProjectTypes({
timelogHash: timelog.hash ?? undefined,
projectSummaryHash: projectSummary.hash ?? undefined,
brands: q.brands,
divisions: q.divisions,
hubs: q.hubs,
userRoles: q.userRoles,
});
setData(res);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
}, [filters, timelog.hash, projectSummary.hash]);
useEffect(() => {
void load();
}, [load]);
const sortedStats = useMemo(() => {
const stats = [...(data?.stats ?? [])];
stats.sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
return stats;
}, [data, sortKey, sortDir]);
const handleSort = (k: SortKey) => {
if (sortKey === k) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else {
setSortKey(k);
setSortDir('desc');
}
};
const chartData = sortedStats.slice(0, 10).map((s) => ({
label: s.projectType,
hoursPerAsset: s.avgHoursPerAsset,
projectCount: s.projectCount,
}));
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Project Type Summary</h1>
<p className="mt-1 text-xs text-slate-500">
Effort, duration and hour distribution per project type. Sort columns to find concentration
risks and outliers.
</p>
</section>
{!timelog.hash && (
<div className="card text-sm text-slate-600">
Upload a time log to compute project-type benchmarks.
</div>
)}
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Computing project-type stats…" />}
{!loading && !error && data && data.stats.length > 0 && (
<>
<div className="card">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Hours / asset by type</h3>
<div className="h-64 w-full">
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} angle={-15} textAnchor="end" height={60} />
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Bar yAxisId="left" dataKey="hoursPerAsset" name="h/asset" fill="#6366f1" />
<Line
yAxisId="right"
type="monotone"
dataKey="projectCount"
name="# projects"
stroke="#0ea5e9"
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
<div className="card" data-tutorial-id="project-type-table">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
{data.totals.totalTypes} types · {data.totals.totalProjects} projects ·{' '}
{data.totals.totalHours}h
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<Th k="projectType" l="Project type" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<Th k="projectCount" l="# projects" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="totalHours" l="Total hours" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="totalAssets" l="Total assets" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="avgHoursPerAsset" l="h/asset" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="avgDurationDays" l="Avg dur. (wd)" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="concentrationPct" l="Concent. %" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<th className="py-2">Insight</th>
</tr>
</thead>
<tbody>
{sortedStats.map((s) => (
<tr key={s.projectType} className="border-b border-slate-100">
<td className="py-1.5 pr-2 font-medium text-slate-800">{s.projectType}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.projectCount}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalHours}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgHoursPerAsset}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgDurationDays}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.concentrationPct}%</td>
<td className="py-1.5 pr-2 text-xs text-slate-500">{s.autoInsight}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
}
function Th({
k,
l,
sortKey,
sortDir,
onSort,
right,
}: {
k: SortKey;
l: string;
sortKey: SortKey;
sortDir: 'asc' | 'desc';
onSort: (k: SortKey) => void;
right?: boolean;
}) {
const arrow = sortKey === k ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '';
return (
<th
onClick={() => onSort(k)}
className={`cursor-pointer py-2 pr-2 hover:text-slate-700 select-none ${right ? 'text-right' : ''}`}
>
{l}
{arrow}
</th>
);
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import KpiTiles from '../components/KpiTiles';
@ -11,8 +11,8 @@ 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 { useDataContext } from '../hooks/useDataContext';
import { filtersToQuery } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
@ -20,8 +20,7 @@ import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/t
export default function Resourcing() {
const airtable = useAirtableData(false);
const tl = useTimelog();
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const { filters, dispatch, timelog, dimensions } = useDataContext();
const [bookings, setBookings] = useState<Booking[]>([]);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
@ -35,10 +34,6 @@ export default function Resourcing() {
[airtable.resources],
);
// Belt-and-braces: even though the backend now narrows bookings by
// department/name server-side, Airtable's filterByFormula against linked
// lookup fields can be flaky — so we keep the client-side filter to make
// sure the chart stays consistent with the FilterBar.
const visibleBookings = useMemo(() => {
const deptSet = new Set(filters.departments);
const nameSet = new Set(filters.names);
@ -69,8 +64,12 @@ export default function Resourcing() {
department: q.department,
name: q.name,
billing_type: q.billing_type,
brands: q.brands,
divisions: q.divisions,
hubs: q.hubs,
userRoles: q.userRoles,
period: filters.period,
timelogHash: tl.hash ?? undefined,
timelogHash: timelog.hash ?? undefined,
}),
]);
setBookings(bk.bookings);
@ -82,7 +81,7 @@ export default function Resourcing() {
setLoading(false);
}
},
[filters, tl.hash],
[filters, timelog.hash],
);
useEffect(() => {
@ -112,6 +111,10 @@ export default function Resourcing() {
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>
@ -174,7 +177,7 @@ export default function Resourcing() {
period={selectedPeriod}
from={filters.range?.from}
to={filters.range?.to}
timelogHash={tl.hash}
timelogHash={timelog.hash}
onClose={() => setSelectedPeriod(null)}
/>
</ErrorBoundary>

View file

@ -0,0 +1,202 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import { useDataContext } from '../hooks/useDataContext';
import type { TimelogRow } from '../api/types';
const COLS: { key: keyof TimelogRow; label: string; cls?: string; numeric?: boolean }[] = [
{ key: 'date', label: 'Date' },
{ key: 'submitter', label: 'Submitter' },
{ key: 'userRole', label: 'Role' },
{ key: 'brand', label: 'Brand' },
{ key: 'division', label: 'Division' },
{ key: 'hub', label: 'Hub' },
{ key: 'hoursLogged', label: 'Hours', numeric: true },
{ key: 'projectNumber', label: 'Project #' },
{ key: 'projectTitle', label: 'Title' },
{ key: 'projectType', label: 'Type' },
{ key: 'projectBillingType', label: 'Billing' },
{ key: 'taskDescription', label: 'Task' },
];
const PAGE_SIZE = 100;
const ROW_HEIGHT = 32;
const OVERSCAN = 8;
export default function TimeLogDetailPage() {
const { timelog } = useDataContext();
const [rows, setRows] = useState<TimelogRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<keyof TimelogRow>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Virtualisation refs.
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportH, setViewportH] = useState(480);
const load = useCallback(async () => {
if (!timelog.hash) {
setRows([]);
setTotal(0);
return;
}
setLoading(true);
setError(null);
try {
const res = await api.getTimelogRows({
page,
pageSize: PAGE_SIZE,
search,
sort: `${String(sortKey)}:${sortDir}`,
timelogHash: timelog.hash,
});
setRows(res.rows);
setTotal(res.total);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
}, [timelog.hash, page, search, sortKey, sortDir]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setViewportH(el.clientHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const handleSort = (k: keyof TimelogRow) => {
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else {
setSortKey(k);
setSortDir('desc');
}
setPage(1);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const endIndex = Math.min(rows.length, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN);
const visible = rows.slice(startIndex, endIndex);
const totalHeight = rows.length * ROW_HEIGHT;
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Time Log Detail</h1>
<p className="mt-1 text-xs text-slate-500">
Every parsed time entry. Server-side search and sort across the upload the table below
is virtualised so it stays smooth at 100k+ rows.
</p>
</section>
<div className="card space-y-2" data-tutorial-id="time-log-table">
<div className="flex flex-wrap items-center gap-2">
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Search name, role, project, brand…"
className="input max-w-xs"
/>
<span className="ml-auto text-xs text-slate-500">
{total.toLocaleString()} entries · page {page} / {totalPages}
</span>
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="btn-secondary"
>
Prev
</button>
<button
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className="btn-secondary"
>
Next
</button>
</div>
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Loading rows…" />}
{!loading && !error && (
<div className="overflow-x-auto">
<div
className="grid border-b border-slate-200 bg-slate-50 px-2 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
style={{ gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
>
{COLS.map((c) => (
<div
key={String(c.key)}
className={`cursor-pointer select-none ${c.numeric ? 'text-right' : ''}`}
onClick={() => handleSort(c.key)}
>
{c.label}
{sortKey === c.key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</div>
))}
</div>
<div
ref={containerRef}
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
style={{ height: 540 }}
className="relative overflow-auto"
>
<div style={{ height: totalHeight }}>
<div style={{ transform: `translateY(${startIndex * ROW_HEIGHT}px)` }}>
{visible.map((row, i) => (
<div
key={`${row.date ?? ''}-${row.submitter ?? ''}-${row.projectNumber ?? ''}-${startIndex + i}`}
style={{ height: ROW_HEIGHT, gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
className="grid items-center border-b border-slate-100 px-2 text-xs text-slate-700 hover:bg-slate-50"
>
{COLS.map((c) => {
const v = row[c.key];
return (
<div
key={String(c.key)}
className={`truncate ${c.numeric ? 'text-right tabular-nums' : ''}`}
title={v == null ? '' : String(v)}
>
{v == null ? '' : c.key === 'hoursLogged' ? (v as number).toFixed(1) : String(v)}
</div>
);
})}
</div>
))}
{rows.length === 0 && (
<div className="p-6 text-center text-sm text-slate-500">
{timelog.hash ? 'No rows match the current search.' : 'Upload a time log to see rows.'}
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,71 +1,45 @@
import { lazy, Suspense, useState } from 'react';
import { Play } from 'lucide-react';
import { allSteps, type TutorialSection } from '../components/tutorial/steps';
import { globalSteps } from '../components/tutorial/steps';
const TutorialOverlay = lazy(() => import('../components/tutorial/TutorialOverlay'));
const SECTIONS: { key: TutorialSection; label: string; blurb: string }[] = [
{
key: 'department',
label: 'Department',
blurb: 'Upload your timelog, filter by department and see monthly utilisation, booking-vs-actual and billability.',
},
{
key: 'resourcing',
label: 'Resourcing',
blurb: 'Weekly utilisation, per-person project load, and FTE-vs-Freelancer side-by-side.',
},
{
key: 'bookings',
label: 'Bookings',
blurb: 'Virtualised booking table with cache info and an Airtable sync button.',
},
];
export default function Tutorial() {
const [activeSection, setActiveSection] = useState<TutorialSection | null>(null);
const [running, setRunning] = useState(false);
return (
<div className="space-y-4">
<section className="card">
<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. 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>.
A 9-step tour of the entire dashboard. The overlay only highlights elements that exist on
the current page start the tour, then navigate to other tabs to continue. The original
video walkthrough has been retired.
</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>
<div className="grid gap-4 md:grid-cols-3">
{SECTIONS.map((s) => (
<div key={s.key} className="card flex flex-col">
<h2 className="text-base font-semibold text-slate-800">{s.label}</h2>
<p className="mt-1 flex-1 text-sm text-slate-600">{s.blurb}</p>
<button
type="button"
onClick={() => setActiveSection(s.key)}
className="btn-primary mt-3 self-start"
data-tutorial-id={`replay-${s.key}`}
onClick={() => setRunning(true)}
className="btn-primary mt-3"
data-tutorial-id="start-walkthrough"
>
<Play className="h-4 w-4" aria-hidden /> Replay
<Play className="h-4 w-4" aria-hidden /> Start walkthrough
</button>
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-slate-500">
{allSteps[s.key].map((step) => (
<li key={`${s.key}-${step.selector}-${step.title}`}>
<strong className="text-slate-700">{step.title}:</strong> {step.description}
</section>
<div className="card">
<h2 className="text-sm font-semibold text-slate-800">Chapter list</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-600">
{globalSteps.map((step) => (
<li key={step.selector}>
<strong className="text-slate-800">{step.title}:</strong> {step.description}
</li>
))}
</ul>
</div>
))}
</ol>
</div>
{activeSection && (
{running && (
<Suspense fallback={null}>
<TutorialOverlay section={activeSection} onClose={() => setActiveSection(null)} />
<TutorialOverlay section="global" onClose={() => setRunning(false)} />
</Suspense>
)}
</div>