diff --git a/.env.example b/.env.example index 35c3cd8..246ca6c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/app/auth/local.py b/backend/app/auth/local.py index 834a42d..5fd5f2b 100644 --- a/backend/app/auth/local.py +++ b/backend/app/auth/local.py @@ -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} diff --git a/backend/app/config.py b/backend/app/config.py index f30daf5..c5b212a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/deps/auth.py b/backend/app/deps/auth.py index e97c2bd..8812d25 100644 --- a/backend/app/deps/auth.py +++ b/backend/app/deps/auth.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 2d2e24c..c652632 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index cfb1993..34b78dc 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -23,6 +23,7 @@ class LoginRequest(BaseModel): class MeResponse(BaseModel): username: str mode: str # "local" | "azure" | "bypass" + role: str = "global-lead" # ---------- Airtable ---------- diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 7e87ee1..7c628a8 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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), + ) diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000..befc717 --- /dev/null +++ b/backend/app/routers/chat.py @@ -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 diff --git a/backend/app/routers/deliverable.py b/backend/app/routers/deliverable.py new file mode 100644 index 0000000..48dcea5 --- /dev/null +++ b/backend/app/routers/deliverable.py @@ -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 diff --git a/backend/app/routers/forecast.py b/backend/app/routers/forecast.py new file mode 100644 index 0000000..69e95ad --- /dev/null +++ b/backend/app/routers/forecast.py @@ -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 diff --git a/backend/app/routers/project_summary.py b/backend/app/routers/project_summary.py new file mode 100644 index 0000000..30f4cc6 --- /dev/null +++ b/backend/app/routers/project_summary.py @@ -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 diff --git a/backend/app/routers/project_types.py b/backend/app/routers/project_types.py new file mode 100644 index 0000000..e11ca0e --- /dev/null +++ b/backend/app/routers/project_types.py @@ -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) diff --git a/backend/app/routers/timelog.py b/backend/app/routers/timelog.py index 56e7865..1c2ef0f 100644 --- a/backend/app/routers/timelog.py +++ b/backend/app/routers/timelog.py @@ -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, + } diff --git a/backend/app/routers/utilisation.py b/backend/app/routers/utilisation.py index 1529185..669164d 100644 --- a/backend/app/routers/utilisation.py +++ b/backend/app/routers/utilisation.py @@ -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", []) diff --git a/backend/app/services/ai_context.py b/backend/app/services/ai_context.py new file mode 100644 index 0000000..ad0f4dc --- /dev/null +++ b/backend/app/services/ai_context.py @@ -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) diff --git a/backend/app/services/deliverable_parse.py b/backend/app/services/deliverable_parse.py new file mode 100644 index 0000000..1b60b14 --- /dev/null +++ b/backend/app/services/deliverable_parse.py @@ -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) diff --git a/backend/app/services/forecast.py b/backend/app/services/forecast.py new file mode 100644 index 0000000..3b75226 --- /dev/null +++ b/backend/app/services/forecast.py @@ -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" diff --git a/backend/app/services/parse_store.py b/backend/app/services/parse_store.py new file mode 100644 index 0000000..14bf849 --- /dev/null +++ b/backend/app/services/parse_store.py @@ -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() diff --git a/backend/app/services/project_summary_parse.py b/backend/app/services/project_summary_parse.py new file mode 100644 index 0000000..bce6280 --- /dev/null +++ b/backend/app/services/project_summary_parse.py @@ -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) diff --git a/backend/app/services/project_types.py b/backend/app/services/project_types.py new file mode 100644 index 0000000..9ad1298 --- /dev/null +++ b/backend/app/services/project_types.py @@ -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 ''}" diff --git a/backend/app/services/timelog_filters.py b/backend/app/services/timelog_filters.py new file mode 100644 index 0000000..94315a4 --- /dev/null +++ b/backend/app/services/timelog_filters.py @@ -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), + } diff --git a/backend/app/services/zoho_parse.py b/backend/app/services/zoho_parse.py index b27fbef..f4a05da 100644 --- a/backend/app/services/zoho_parse.py +++ b/backend/app/services/zoho_parse.py @@ -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 diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 0000000..5fbe399 --- /dev/null +++ b/backend/tests/test_chat.py @@ -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) diff --git a/backend/tests/test_deliverable_parse.py b/backend/tests/test_deliverable_parse.py new file mode 100644 index 0000000..7a44473 --- /dev/null +++ b/backend/tests/test_deliverable_parse.py @@ -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"] diff --git a/backend/tests/test_forecast.py b/backend/tests/test_forecast.py new file mode 100644 index 0000000..9e13a5e --- /dev/null +++ b/backend/tests/test_forecast.py @@ -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 diff --git a/backend/tests/test_project_types.py b/backend/tests/test_project_types.py new file mode 100644 index 0000000..95fc1c8 --- /dev/null +++ b/backend/tests/test_project_types.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed13c3c..f576176 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; + } + return <>{children}; +} + +function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) { return ( -
- -
- }>{children} -
-
+ + +
+ + +
+ }>{children} +
+ +
+
+
); } @@ -30,7 +51,7 @@ export default function App() { + } @@ -38,7 +59,7 @@ export default function App() { + } @@ -46,15 +67,39 @@ export default function App() { + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 6283036..0fd0c34 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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('/deliverable/parse', { + method: 'POST', + body: fd, + skipJsonContentType: true, + }); +} + +export function parseProjectSummary(file: File) { + const fd = new FormData(); + fd.append('file', file); + return apiFetch('/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( + `/timelog/rows${buildQuery(params as Record)}`, + ); +} + +export function getTimelogDimensions(timelogHash?: string) { + return apiFetch( + `/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(`/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( + `/forecast${buildQuery(params as Record)}`, + ); +} + +// ---- Project Types -------------------------------------------------------- + +export interface ProjectTypesParams { + timelogHash?: string; + projectSummaryHash?: string; + brands?: string; + divisions?: string; + hubs?: string; + userRoles?: string; +} + +export function getProjectTypes(params: ProjectTypesParams = {}) { + return apiFetch( + `/project-types${buildQuery(params as Record)}`, + ); +} + +// ---- 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('/chat', { + method: 'POST', + body: JSON.stringify(req), + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a492740..42b513c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; + }; } diff --git a/frontend/src/components/ChatToggle.tsx b/frontend/src/components/ChatToggle.tsx new file mode 100644 index 0000000..2f7b241 --- /dev/null +++ b/frontend/src/components/ChatToggle.tsx @@ -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 ( + <> + + {open && ( + + setOpen(false)} /> + + )} + + ); +} diff --git a/frontend/src/components/ChatView.tsx b/frontend/src/components/ChatView.tsx new file mode 100644 index 0000000..418267b --- /dev/null +++ b/frontend/src/components/ChatView.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const bottomRef = useRef(null); + const inputRef = useRef(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 ( +
+
+ + AI Assistant + {messages.length > 0 && ( + + )} + +
+ +
+ {messages.length === 0 && ( +
+

Try asking:

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+ {!hasData &&

Upload a time log first to give the AI real context.

} +
+ )} + + {messages.map((m, i) => ( +
+ {m.role === 'assistant' && ( +
+ AI +
+ )} +
+ {m.content || (busy && i === messages.length - 1 ? '…' : '')} +
+
+ ))} + + {error && ( +
{error}
+ )} +
+
+ +
+
+