feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC
Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).
Backend — new endpoints + services:
- POST /api/deliverable/parse — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse — parse Project Summary CSV/XLSX
- GET /api/timelog/rows — paginated, searchable, sortable view
over the parsed Zoho upload
- GET /api/forecast — 4-week pipeline + capacity decision
- GET /api/project-types — hours/asset, duration, concentration
per project type + auto-insights
- POST /api/chat — Claude API proxy. 503s gracefully
when ANTHROPIC_API_KEY is unset.
Prompt-cached system prompt;
rate-limited 20/min/IP.
- GET /api/auth/me now returns role.
Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
projectType, assetCount, projectStatus, project start/end dates,
userAgency, employingCompany, sageJobProfile, …) with back-compat
aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.
Frontend — new pages + components:
- pages/Forecast.tsx — ComposedChart (stacked bars + line)
+ capacity-decision banner + table
- pages/ProjectTypeSummary.tsx — sortable table + small trend chart
- pages/TimeLogDetail.tsx — virtualised, searchable, sortable
view over all parsed timelog rows
- components/ChatView.tsx — floating side panel with Claude.
6 preset prompts mirroring the
original. Visible only for roles
with chat access.
- components/ChatToggle.tsx — bottom-right FAB.
- components/StatsBar.tsx — always-visible: Time Entries /
People / Projects / Total Hours /
Date Range.
- hooks/useDataContext.tsx — single source of truth for filter
state + parsed upload + filter
dimensions (brands/divs/hubs/
roles derived from uploads).
Frontend — modified:
- App.tsx, Navbar.tsx — 7 tabs + role gating per the
original TAB_ACCESS matrix.
- hooks/useAuth.tsx — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx — Brand / Division / Hub / Role
multiselects added (additive — keep
Department / Name / Billing).
- pages/Department, Resourcing,
Bookings, Tutorial.tsx — wired into DataContext; tutorial
is now a single 9-step global tour
mirroring the original's narrative.
Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd1c99d5e0
commit
993e370cea
46 changed files with 3880 additions and 295 deletions
13
.env.example
13
.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class LoginRequest(BaseModel):
|
|||
class MeResponse(BaseModel):
|
||||
username: str
|
||||
mode: str # "local" | "azure" | "bypass"
|
||||
role: str = "global-lead"
|
||||
|
||||
|
||||
# ---------- Airtable ----------
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def _log_attempt(request: Request, username: str, outcome: str) -> None:
|
|||
async def login(request: Request, response: Response, body: LoginRequest = Body(...)):
|
||||
if settings.DEV_AUTH_BYPASS:
|
||||
_log_attempt(request, body.username, "bypass")
|
||||
return {"ok": True, "username": "dev", "mode": "bypass"}
|
||||
return {"ok": True, "username": "dev", "mode": "bypass", "role": settings.ADMIN_ROLE}
|
||||
|
||||
if not verify_password(body.username, body.password):
|
||||
_log_attempt(request, body.username, "fail")
|
||||
|
|
@ -72,7 +72,7 @@ async def login(request: Request, response: Response, body: LoginRequest = Body(
|
|||
|
||||
set_session_cookie(response, body.username)
|
||||
_log_attempt(request, body.username, "success")
|
||||
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE}
|
||||
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE, "role": settings.ADMIN_ROLE}
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -85,4 +85,8 @@ async def logout(response: Response) -> Response:
|
|||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(user: dict = Depends(auth_required)) -> MeResponse:
|
||||
return MeResponse(username=user["username"], mode=user["mode"])
|
||||
return MeResponse(
|
||||
username=user["username"],
|
||||
mode=user["mode"],
|
||||
role=user.get("role", settings.ADMIN_ROLE),
|
||||
)
|
||||
|
|
|
|||
189
backend/app/routers/chat.py
Normal file
189
backend/app/routers/chat.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Claude API proxy with prompt caching.
|
||||
|
||||
Decisions:
|
||||
- Single non-streaming endpoint (POST /api/chat). Streaming via SSE adds
|
||||
complexity that wasn't worth shipping in v1 — the model + caching are
|
||||
what matter for response time on long context blocks.
|
||||
- Prompt caching: the system prompt + the dashboard-context block are
|
||||
marked with cache_control breakpoints. Together they're typically
|
||||
~5-50k tokens depending on dataset size; caching them halves the
|
||||
cost of repeat questions and dramatically reduces TTFT.
|
||||
- 503 when ANTHROPIC_API_KEY is empty so the UI can show a helpful
|
||||
message instead of a generic upstream error.
|
||||
- Rate-limit at 20/min/IP via slowapi (separate from the auth router's
|
||||
limiter; both share the same Limiter instance).
|
||||
- Forecast role has no chat access — gated on the frontend (`showChat`
|
||||
in App.tsx). Backend allows any authenticated user; restricting it
|
||||
here would mean adding role checks to every router, which we'll do
|
||||
centrally if/when Azure AD lands.
|
||||
- Request logging: writes timestamp + user + token count to a
|
||||
RotatingFileHandler at /app/logs/chat.log. Message bodies are NOT
|
||||
logged — they may contain employee names / project codes.
|
||||
- We intentionally DO NOT use `from __future__ import annotations` in
|
||||
this module: slowapi's decorator breaks FastAPI forward-ref resolution
|
||||
for the request body model under Python 3.14 / pydantic 2.x. The
|
||||
auth router has the same caveat.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.deps.auth import auth_required
|
||||
from app.routers.auth import limiter # share the same slowapi limiter
|
||||
from app.services.ai_context import build_dashboard_context
|
||||
from app.services.parse_store import get_project_summary, get_timelog
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- chat.log file handler ----
|
||||
chat_logger = logging.getLogger("ud.chat")
|
||||
if not chat_logger.handlers:
|
||||
log_dir = Path("/app/logs")
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
log_dir / "chat.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)sZ %(message)s"))
|
||||
chat_logger.addHandler(handler)
|
||||
chat_logger.setLevel(logging.INFO)
|
||||
chat_logger.propagate = False
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chat"], dependencies=[Depends(auth_required)])
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are an AI assistant embedded in the L'Oréal Utilisation Dashboard "
|
||||
"— an internal capacity planning tool for an Oliver agency team.\n\n"
|
||||
"You have access to live dashboard data provided at the end of this prompt. "
|
||||
"Use it to answer questions about:\n"
|
||||
"- Team capacity and utilisation (weekly hours, headcount, % utilisation)\n"
|
||||
"- Active project pipeline: what's in flight, upcoming, or exiting this week\n"
|
||||
"- Benchmark hours per asset by project type (from historical completed projects)\n"
|
||||
"- Weekly throughput forecasts and asset counts\n"
|
||||
"- Specific project status, dates, or asset numbers\n\n"
|
||||
"Guidelines:\n"
|
||||
"- Be concise and reference specific numbers from the data\n"
|
||||
"- If a question is about something not in the data, say so clearly\n"
|
||||
"- When comparing types, call out the biggest differences\n"
|
||||
"- Dates are in YYYY-MM-DD unless stated otherwise"
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str # "user" | "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class ChatContext(BaseModel):
|
||||
timelogHash: str | None = None
|
||||
projectSummaryHash: str | None = None
|
||||
includeBookings: bool = False
|
||||
includeResources: bool = False
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
messages: list[ChatMessage]
|
||||
context: ChatContext | None = None
|
||||
max_tokens: int = 1024
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
@limiter.limit("20/minute")
|
||||
async def chat(request: Request, body: ChatRequest, user: dict = Depends(auth_required)) -> Any:
|
||||
settings = get_settings()
|
||||
if not body.messages:
|
||||
raise HTTPException(status_code=400, detail="messages must be non-empty")
|
||||
if not settings.ANTHROPIC_API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI chat is not configured. Add ANTHROPIC_API_KEY to backend .env.",
|
||||
)
|
||||
|
||||
# Build the cached context block from whatever uploads the user has.
|
||||
logs: list[dict[str, Any]] = []
|
||||
project_summary: list[dict[str, Any]] = []
|
||||
ctx = body.context or ChatContext()
|
||||
if ctx.timelogHash:
|
||||
cached = get_timelog(ctx.timelogHash)
|
||||
if cached:
|
||||
logs = cached.get("rows", []) or []
|
||||
if ctx.projectSummaryHash:
|
||||
cached = get_project_summary(ctx.projectSummaryHash)
|
||||
if cached:
|
||||
project_summary = cached.get("rows", []) or []
|
||||
|
||||
dashboard_block = build_dashboard_context(logs=logs, project_summary=project_summary or None)
|
||||
|
||||
# System prompt as a list with two cache_control breakpoints — Anthropic
|
||||
# caches everything up to and including each breakpoint. Two stable
|
||||
# blocks (the static guide + the dashboard data) keep cache hits high
|
||||
# across follow-ups.
|
||||
system = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": SYSTEM_PROMPT,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"---\n\n## LIVE DASHBOARD DATA\n\n{dashboard_block}",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
|
||||
payload = {
|
||||
"model": settings.ANTHROPIC_MODEL, # type: ignore[union-attr]
|
||||
"max_tokens": min(max(body.max_tokens, 256), 4096),
|
||||
"system": system,
|
||||
"messages": [{"role": m.role, "content": m.content} for m in body.messages],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
||||
try:
|
||||
res = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": settings.ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception("anthropic upstream error")
|
||||
raise HTTPException(status_code=502, detail=f"Upstream error: {e}") from e
|
||||
|
||||
if res.status_code >= 400:
|
||||
# Surface upstream status verbatim so the UI can show a useful message.
|
||||
try:
|
||||
body_json = res.json()
|
||||
except ValueError:
|
||||
body_json = {"error": res.text}
|
||||
raise HTTPException(status_code=res.status_code, detail=body_json)
|
||||
|
||||
data = res.json()
|
||||
usage = data.get("usage", {}) or {}
|
||||
in_tok = usage.get("input_tokens", 0)
|
||||
out_tok = usage.get("output_tokens", 0)
|
||||
cache_create = usage.get("cache_creation_input_tokens", 0)
|
||||
cache_read = usage.get("cache_read_input_tokens", 0)
|
||||
chat_logger.info(
|
||||
"user=%s in=%s out=%s cache_create=%s cache_read=%s",
|
||||
user.get("username", "?"), in_tok, out_tok, cache_create, cache_read,
|
||||
)
|
||||
return data
|
||||
34
backend/app/routers/deliverable.py
Normal file
34
backend/app/routers/deliverable.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Deliverable Summary upload + parse."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from app.config import settings
|
||||
from app.deps.auth import auth_required
|
||||
from app.services import deliverable_parse
|
||||
from app.services.parse_store import remember_deliverable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/deliverable", tags=["deliverable"], dependencies=[Depends(auth_required)])
|
||||
|
||||
|
||||
@router.post("/parse")
|
||||
async def parse_deliverable(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
content = await file.read()
|
||||
if len(content) > settings.MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Empty file")
|
||||
try:
|
||||
parsed = deliverable_parse.parse(file.filename or "", content)
|
||||
except Exception as e:
|
||||
logger.exception("Deliverable parse failed")
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
|
||||
remember_deliverable(parsed)
|
||||
return parsed
|
||||
77
backend/app/routers/forecast.py
Normal file
77
backend/app/routers/forecast.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Forecast view — 4-week capacity outlook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.deps.auth import auth_required
|
||||
from app.services.forecast import build_forecast
|
||||
from app.services.parse_store import get_deliverable, get_project_summary, get_timelog
|
||||
from app.services.timelog_filters import apply_filters
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/forecast", tags=["forecast"], dependencies=[Depends(auth_required)])
|
||||
|
||||
|
||||
def _parse_date(s: str | None) -> date | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(s)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def forecast(
|
||||
weeks_ahead: int = Query(4, ge=1, le=12, alias="weeks_ahead"),
|
||||
from_: str | None = Query(None, alias="from"),
|
||||
timelog_hash: str | None = Query(None, alias="timelogHash"),
|
||||
deliverable_hash: str | None = Query(None, alias="deliverableHash"),
|
||||
project_summary_hash: str | None = Query(None, alias="projectSummaryHash"),
|
||||
brands: str | None = Query(None),
|
||||
divisions: str | None = Query(None),
|
||||
hubs: str | None = Query(None),
|
||||
user_roles: str | None = Query(None, alias="userRoles"),
|
||||
departments: str | None = Query(None),
|
||||
names: str | None = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
logs: list[dict[str, Any]] = []
|
||||
if timelog_hash:
|
||||
cached = get_timelog(timelog_hash)
|
||||
if cached:
|
||||
logs = cached.get("rows", []) or []
|
||||
|
||||
deliverables: list[dict[str, Any]] = []
|
||||
if deliverable_hash:
|
||||
cached = get_deliverable(deliverable_hash)
|
||||
if cached:
|
||||
deliverables = cached.get("rows", []) or []
|
||||
|
||||
project_summary: list[dict[str, Any]] = []
|
||||
if project_summary_hash:
|
||||
cached = get_project_summary(project_summary_hash)
|
||||
if cached:
|
||||
project_summary = cached.get("rows", []) or []
|
||||
|
||||
logs = apply_filters(
|
||||
logs,
|
||||
brands=brands,
|
||||
divisions=divisions,
|
||||
hubs=hubs,
|
||||
user_roles=user_roles,
|
||||
departments=departments,
|
||||
names=names,
|
||||
)
|
||||
|
||||
out = build_forecast(
|
||||
logs=logs,
|
||||
deliverables=deliverables or None,
|
||||
project_summary=project_summary or None,
|
||||
weeks_ahead=weeks_ahead,
|
||||
from_date=_parse_date(from_),
|
||||
)
|
||||
return out
|
||||
34
backend/app/routers/project_summary.py
Normal file
34
backend/app/routers/project_summary.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Project Summary upload + parse."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from app.config import settings
|
||||
from app.deps.auth import auth_required
|
||||
from app.services import project_summary_parse
|
||||
from app.services.parse_store import remember_project_summary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projectsummary", tags=["projectsummary"], dependencies=[Depends(auth_required)])
|
||||
|
||||
|
||||
@router.post("/parse")
|
||||
async def parse_project_summary(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
content = await file.read()
|
||||
if len(content) > settings.MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Empty file")
|
||||
try:
|
||||
parsed = project_summary_parse.parse(file.filename or "", content)
|
||||
except Exception as e:
|
||||
logger.exception("Project Summary parse failed")
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
|
||||
remember_project_summary(parsed)
|
||||
return parsed
|
||||
59
backend/app/routers/project_types.py
Normal file
59
backend/app/routers/project_types.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Per-project-type stats endpoint + filter-dimensions for the FilterBar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.deps.auth import auth_required
|
||||
from app.services.parse_store import get_project_summary, get_timelog
|
||||
from app.services.project_types import build_project_types
|
||||
from app.services.timelog_filters import apply_filters, distinct_dimensions
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["project-types"], dependencies=[Depends(auth_required)])
|
||||
|
||||
|
||||
@router.get("/project-types")
|
||||
async def project_types(
|
||||
timelog_hash: str | None = Query(None, alias="timelogHash"),
|
||||
project_summary_hash: str | None = Query(None, alias="projectSummaryHash"),
|
||||
brands: str | None = Query(None),
|
||||
divisions: str | None = Query(None),
|
||||
hubs: str | None = Query(None),
|
||||
user_roles: str | None = Query(None, alias="userRoles"),
|
||||
) -> dict[str, Any]:
|
||||
logs: list[dict[str, Any]] = []
|
||||
if timelog_hash:
|
||||
cached = get_timelog(timelog_hash)
|
||||
if cached:
|
||||
logs = cached.get("rows", []) or []
|
||||
|
||||
project_summary: list[dict[str, Any]] = []
|
||||
if project_summary_hash:
|
||||
cached = get_project_summary(project_summary_hash)
|
||||
if cached:
|
||||
project_summary = cached.get("rows", []) or []
|
||||
|
||||
logs = apply_filters(
|
||||
logs,
|
||||
brands=brands, divisions=divisions, hubs=hubs, user_roles=user_roles,
|
||||
)
|
||||
return build_project_types(logs=logs, project_summary=project_summary or None)
|
||||
|
||||
|
||||
@router.get("/timelog/dimensions")
|
||||
async def timelog_dimensions(
|
||||
timelog_hash: str | None = Query(None, alias="timelogHash"),
|
||||
) -> dict[str, Any]:
|
||||
"""Distinct brands / divisions / hubs / userRoles from the parsed
|
||||
timelog — sourced from the upload, not Airtable. Powers the new
|
||||
FilterBar v2 multiselects on every data page.
|
||||
"""
|
||||
logs: list[dict[str, Any]] = []
|
||||
if timelog_hash:
|
||||
cached = get_timelog(timelog_hash)
|
||||
if cached:
|
||||
logs = cached.get("rows", []) or []
|
||||
return distinct_dimensions(logs)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
|
||||
|
|
|
|||
102
backend/app/services/ai_context.py
Normal file
102
backend/app/services/ai_context.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Builds the dashboard-context system prompt the AI chat reads from.
|
||||
|
||||
Mirrors `src/lib/aiContextBuilder.ts` in the original SPA — kept small
|
||||
and human-readable so the LLM can cite specific numbers back to the
|
||||
user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Iterable
|
||||
|
||||
from app.services.project_types import build_project_types
|
||||
|
||||
|
||||
def _is_complete(s: str | None) -> bool:
|
||||
if not s:
|
||||
return False
|
||||
n = s.upper()
|
||||
return n in {"APPROVED", "COMPLETE", "CANCELLED", "REJECTED", "DECLINED"}
|
||||
|
||||
|
||||
def build_dashboard_context(
|
||||
*,
|
||||
logs: list[dict[str, Any]],
|
||||
project_summary: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
today = date.today().isoformat()
|
||||
lines: list[str] = [f"Today: {today}", ""]
|
||||
|
||||
# Time-log summary.
|
||||
people: set[str] = set()
|
||||
projects: set[str] = set()
|
||||
total_hours = 0.0
|
||||
dates: list[str] = []
|
||||
for r in logs:
|
||||
ident = r.get("submitterEmail") or r.get("submitter") or ""
|
||||
if ident:
|
||||
people.add(str(ident))
|
||||
title = r.get("projectTitle") or r.get("projectNumber") or ""
|
||||
if title:
|
||||
projects.add(str(title))
|
||||
total_hours += float(r.get("hoursLogged") or r.get("hours") or 0)
|
||||
d = r.get("date")
|
||||
if d:
|
||||
dates.append(str(d))
|
||||
dates.sort()
|
||||
lines.append("## TIME LOG")
|
||||
lines.append(f"{len(logs):,} entries | {len(people)} people | {len(projects)} projects | {total_hours:.0f}h total")
|
||||
if dates:
|
||||
lines.append(f"Date range: {dates[0]} to {dates[-1]}")
|
||||
lines.append("")
|
||||
|
||||
# Benchmark by project type (top types).
|
||||
types = build_project_types(logs=logs, project_summary=project_summary or None)
|
||||
stats = types.get("stats", []) or []
|
||||
if stats:
|
||||
lines.append(f"## BENCHMARK ({sum(s['projectCount'] for s in stats)} projects across {len(stats)} types)")
|
||||
for s in stats[:12]:
|
||||
lines.append(
|
||||
f" {s['projectType']}: "
|
||||
f"{s['avgHoursPerAsset']:.1f}h/asset | "
|
||||
f"{s['projectCount']} projects | "
|
||||
f"{s['totalAssets']:.0f} assets | "
|
||||
f"avg {s['avgDurationDays']:.0f}d"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Active pipeline from Project Summary.
|
||||
if project_summary:
|
||||
active = [ps for ps in project_summary
|
||||
if not _is_complete(ps.get("projectStatus"))
|
||||
and ps.get("projectStartDate")
|
||||
and ps["projectStartDate"] <= today
|
||||
and (not ps.get("projectEndDate") or ps["projectEndDate"] >= today)]
|
||||
upcoming = [ps for ps in project_summary
|
||||
if not _is_complete(ps.get("projectStatus"))
|
||||
and (ps.get("projectStartDate") or "") > today]
|
||||
# Exiting this week (Mon-Fri).
|
||||
d = date.today()
|
||||
mon = d - timedelta(days=d.weekday())
|
||||
fri = mon + timedelta(days=4)
|
||||
exiting = [ps for ps in project_summary
|
||||
if not _is_complete(ps.get("projectStatus"))
|
||||
and ps.get("projectEndDate")
|
||||
and mon.isoformat() <= ps["projectEndDate"] <= fri.isoformat()]
|
||||
total_active_assets = sum(float(ps.get("assetCount") or 0) for ps in active)
|
||||
lines.append("## ACTIVE PIPELINE")
|
||||
lines.append(
|
||||
f"Active: {len(active)} projects ({total_active_assets:,.0f} assets) | "
|
||||
f"Upcoming: {len(upcoming)} | Exiting this week: {len(exiting)}"
|
||||
)
|
||||
if exiting:
|
||||
lines.append("Exiting this week:")
|
||||
for ps in exiting[:20]:
|
||||
lines.append(
|
||||
f" {ps.get('projectNumber','')} | {ps.get('projectTitle','')} | "
|
||||
f"{(ps.get('assetCount') or 0):.0f} assets | ends {ps.get('projectEndDate')}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
167
backend/app/services/deliverable_parse.py
Normal file
167
backend/app/services/deliverable_parse.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Deliverable Summary parser.
|
||||
|
||||
Decisions:
|
||||
- Mirrors `parseDeliverableBuffer` from the original SPA. The CSV contains
|
||||
both "D" (Deliverable) and "B" (Brief) rows; we only emit D rows because
|
||||
those are the asset-level records the dashboard reasons about.
|
||||
- Date fields are emitted as ISO strings (YYYY-MM-DD) so JSON serialisation
|
||||
is straightforward downstream — matches the original.
|
||||
- Header lookup is case-insensitive and trim-stripped, first occurrence
|
||||
wins (the source CSV doesn't repeat headers but we keep the rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
from typing import Any, Iterable
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.services.zoho_parse import _parse_date
|
||||
|
||||
|
||||
def _norm(s: Any) -> str:
|
||||
return str(s or "").strip().lower()
|
||||
|
||||
|
||||
def _str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def _build_header_index(headers: Iterable[Any]) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for i, h in enumerate(headers):
|
||||
k = _norm(h)
|
||||
if k and k not in out:
|
||||
out[k] = i
|
||||
return out
|
||||
|
||||
|
||||
def _col(row: list[Any], idx: dict[str, int], *names: str) -> str:
|
||||
for n in names:
|
||||
i = idx.get(n.lower())
|
||||
if i is not None and i < len(row):
|
||||
v = row[i]
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _col_raw(row: list[Any], idx: dict[str, int], *names: str) -> Any:
|
||||
for n in names:
|
||||
i = idx.get(n.lower())
|
||||
if i is not None and i < len(row):
|
||||
v = row[i]
|
||||
if v is None or (isinstance(v, str) and v.strip() == ""):
|
||||
continue
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _date_iso(v: Any) -> str:
|
||||
d = _parse_date(v)
|
||||
return d.isoformat() if d else ""
|
||||
|
||||
|
||||
# Expected canonical headers — anything outside this set goes into
|
||||
# unrecognised_columns.
|
||||
_KNOWN_HEADERS = {
|
||||
"component", "project number", "project status", "deliverable number",
|
||||
"deliverable status", "project type (from omg)", "project type",
|
||||
"deliverable start date", "deliverable end date",
|
||||
"project start date", "project end date",
|
||||
"brand", "business division", "business area - lv 2", "business area",
|
||||
"market", "deliverable title",
|
||||
}
|
||||
|
||||
|
||||
def _build_rows(headers: list[Any], data_rows: Iterable[list[Any]]) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
idx = _build_header_index(headers)
|
||||
|
||||
unrecognised: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for h in headers:
|
||||
s = _str(h)
|
||||
if not s:
|
||||
continue
|
||||
if s.lower() in _KNOWN_HEADERS:
|
||||
continue
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
unrecognised.append(s)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in data_rows:
|
||||
if not row or all(c in (None, "") for c in row):
|
||||
continue
|
||||
# D-row filter (per original).
|
||||
component = _col(list(row), idx, "component")
|
||||
if component and component.upper() != "D":
|
||||
continue
|
||||
project_number = _col(list(row), idx, "project number")
|
||||
if not project_number:
|
||||
continue
|
||||
|
||||
d_start = _date_iso(_col_raw(list(row), idx, "deliverable start date"))
|
||||
d_end = _date_iso(_col_raw(list(row), idx, "deliverable end date"))
|
||||
if not d_start or not d_end:
|
||||
continue
|
||||
|
||||
out.append({
|
||||
"projectNumber": project_number,
|
||||
"projectStatus": _col(list(row), idx, "project status").upper(),
|
||||
"deliverableNumber": _col(list(row), idx, "deliverable number"),
|
||||
"deliverableStatus": _col(list(row), idx, "deliverable status").upper(),
|
||||
"projectType": _col(list(row), idx, "project type (from omg)", "project type"),
|
||||
"deliverableStartDate": d_start,
|
||||
"deliverableEndDate": d_end,
|
||||
"projectStartDate": _date_iso(_col_raw(list(row), idx, "project start date")),
|
||||
"projectEndDate": _date_iso(_col_raw(list(row), idx, "project end date")),
|
||||
"brand": _col(list(row), idx, "brand") or "Unknown",
|
||||
"businessDivision": _col(list(row), idx, "business division") or "Unknown",
|
||||
"businessArea": _col(list(row), idx, "business area - lv 2", "business area") or "Unknown",
|
||||
"market": _col(list(row), idx, "market") or "Unknown",
|
||||
"deliverableTitle": _col(list(row), idx, "deliverable title"),
|
||||
})
|
||||
return out, unrecognised
|
||||
|
||||
|
||||
def parse(filename: str, content: bytes) -> dict[str, Any]:
|
||||
fn = (filename or "").lower()
|
||||
if fn.endswith(".xlsx") or fn.endswith(".xlsm"):
|
||||
rows, unknown = _parse_xlsx(content)
|
||||
else:
|
||||
rows, unknown = _parse_csv(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
return {"rows": rows, "unrecognised_columns": unknown, "content_hash": f"sha256:{digest}"}
|
||||
|
||||
|
||||
def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
text = content.decode("utf-8-sig", errors="replace")
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return [], []
|
||||
return _build_rows(rows[0], rows[1:])
|
||||
|
||||
|
||||
def _parse_xlsx(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
return [], []
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
try:
|
||||
headers = list(next(rows_iter))
|
||||
except StopIteration:
|
||||
return [], []
|
||||
data = (list(r) for r in rows_iter)
|
||||
return _build_rows(headers, data)
|
||||
319
backend/app/services/forecast.py
Normal file
319
backend/app/services/forecast.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""Forecast pipeline — 4 weeks ahead capacity planning.
|
||||
|
||||
Decisions:
|
||||
- Mirrors the original SPA's `buildForecast` math but trims the output
|
||||
surface to the brief's contract: weekly rows with
|
||||
activeAssets / exitingAssets / exitRate / weeklyThroughput /
|
||||
deptCapacityAssetsPerWeek / canTakeOn, plus a single capacity decision
|
||||
string. The TS port had a full multi-type breakdown — out of scope.
|
||||
- We avoid pulling the original's 8-week look-back / weighted-mix logic
|
||||
in full; the brief just needs the weekly assets/throughput pair.
|
||||
- Pure function (no FastAPI imports here) so it can be unit-tested.
|
||||
- Inputs are the parsed-timelog rows (camelCase from zoho_parse) plus
|
||||
optional projectSummary rows and deliverables. When PS is supplied we
|
||||
use it as the authoritative active-projects list; otherwise we infer
|
||||
from the time log (last-seen projectStartDate / projectEndDate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _iso(d: date) -> str:
|
||||
return d.isoformat()
|
||||
|
||||
|
||||
def _iso_week_label(d: date) -> str:
|
||||
iso = d.isocalendar()
|
||||
return f"W{iso.week:02d}"
|
||||
|
||||
|
||||
def _monday_of(d: date) -> date:
|
||||
return d - timedelta(days=d.weekday())
|
||||
|
||||
|
||||
def _coerce_date(v: Any) -> date | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date) and not isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
try:
|
||||
return date.fromisoformat(str(v)[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _weekday_overlap(a_start: date, a_end: date, b_start: date, b_end: date) -> int:
|
||||
"""Mon-Fri overlap count between [a] and [b] (inclusive)."""
|
||||
if not a_start or not a_end or not b_start or not b_end:
|
||||
return 0
|
||||
start = max(a_start, b_start)
|
||||
end = min(a_end, b_end)
|
||||
if end < start:
|
||||
return 0
|
||||
days = 0
|
||||
d = start
|
||||
while d <= end:
|
||||
if d.weekday() < 5:
|
||||
days += 1
|
||||
d += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def _weekdays_in(start: date, end: date) -> int:
|
||||
return _weekday_overlap(start, end, start, end)
|
||||
|
||||
|
||||
def _historic_weekly_throughput(rows: Iterable[dict[str, Any]]) -> float:
|
||||
"""Average ISO-week asset throughput from completed projects in the
|
||||
time log. We approximate "throughput" as the average distinct
|
||||
project count per ISO week over the last 4 full weeks of data.
|
||||
"""
|
||||
by_week: dict[str, set[str]] = {}
|
||||
for r in rows:
|
||||
d = _coerce_date(r.get("date"))
|
||||
if not d:
|
||||
continue
|
||||
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
iso = d.isocalendar()
|
||||
key = f"{iso.year:04d}-W{iso.week:02d}"
|
||||
by_week.setdefault(key, set()).add(title)
|
||||
if not by_week:
|
||||
return 0.0
|
||||
keys = sorted(by_week.keys())
|
||||
# Drop the most recent ("partial") week and average over the next 4.
|
||||
full = keys[:-1] if len(keys) > 1 else keys
|
||||
baseline = full[-4:]
|
||||
if not baseline:
|
||||
return 0.0
|
||||
return sum(len(by_week[k]) for k in baseline) / len(baseline)
|
||||
|
||||
|
||||
def _avg_hours_per_asset(rows: Iterable[dict[str, Any]]) -> float:
|
||||
"""Weighted avg hours-per-asset across the time log."""
|
||||
project_hours: dict[str, float] = {}
|
||||
project_assets: dict[str, float] = {}
|
||||
for r in rows:
|
||||
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
project_hours[title] = project_hours.get(title, 0.0) + float(r.get("hoursLogged") or r.get("hours") or 0)
|
||||
ac = r.get("assetCount")
|
||||
if ac and ac > 0:
|
||||
project_assets[title] = float(ac)
|
||||
total_h = 0.0
|
||||
total_a = 0.0
|
||||
for p, a in project_assets.items():
|
||||
total_h += project_hours.get(p, 0.0)
|
||||
total_a += a
|
||||
return (total_h / total_a) if total_a > 0 else 0.0
|
||||
|
||||
|
||||
def _is_active_status(s: str | None) -> bool:
|
||||
if not s:
|
||||
return True
|
||||
u = s.upper()
|
||||
return u not in {"COMPLETE", "CANCELLED", "REJECTED", "DECLINED", "APPROVED"}
|
||||
|
||||
|
||||
def _project_universe(
|
||||
logs: Iterable[dict[str, Any]],
|
||||
project_summary: Iterable[dict[str, Any]] | None,
|
||||
deliverables: Iterable[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Project records with {projectNumber, projectTitle, projectType,
|
||||
projectStartDate, projectEndDate, assetCount, status}. PS wins; else
|
||||
fall back to time-log-derived rows enriched with deliverable dates.
|
||||
"""
|
||||
if project_summary:
|
||||
ps = [dict(p) for p in project_summary]
|
||||
return ps
|
||||
|
||||
del_dates: dict[str, tuple[str | None, str | None, str | None]] = {}
|
||||
del_assets: dict[str, float] = {}
|
||||
for d in (deliverables or []):
|
||||
pn = d.get("projectNumber")
|
||||
if not pn:
|
||||
continue
|
||||
if pn not in del_dates:
|
||||
del_dates[pn] = (d.get("projectStartDate"), d.get("projectEndDate"), d.get("projectStatus"))
|
||||
del_assets[pn] = del_assets.get(pn, 0.0) + 1.0
|
||||
|
||||
by_proj: dict[str, dict[str, Any]] = {}
|
||||
for r in logs:
|
||||
pn = r.get("projectNumber") or r.get("projectTitle")
|
||||
if not pn:
|
||||
continue
|
||||
p = by_proj.setdefault(pn, {
|
||||
"projectNumber": r.get("projectNumber"),
|
||||
"projectTitle": r.get("projectTitle") or pn,
|
||||
"projectType": r.get("projectType"),
|
||||
"projectStartDate": r.get("projectStartDate"),
|
||||
"projectEndDate": r.get("projectEndDate"),
|
||||
"projectStatus": r.get("projectStatus"),
|
||||
"assetCount": r.get("assetCount"),
|
||||
})
|
||||
if not p.get("projectStartDate") and r.get("projectStartDate"):
|
||||
p["projectStartDate"] = r["projectStartDate"]
|
||||
if not p.get("projectEndDate") and r.get("projectEndDate"):
|
||||
p["projectEndDate"] = r["projectEndDate"]
|
||||
if p.get("assetCount") in (None, 0) and r.get("assetCount"):
|
||||
p["assetCount"] = r["assetCount"]
|
||||
|
||||
# Enrich from deliverables when fields are missing.
|
||||
for pn, p in by_proj.items():
|
||||
if pn in del_dates:
|
||||
ds, de, st = del_dates[pn]
|
||||
if not p.get("projectStartDate"):
|
||||
p["projectStartDate"] = ds
|
||||
if not p.get("projectEndDate"):
|
||||
p["projectEndDate"] = de
|
||||
if not p.get("projectStatus") and st:
|
||||
p["projectStatus"] = st
|
||||
if not p.get("assetCount") and pn in del_assets:
|
||||
p["assetCount"] = del_assets[pn]
|
||||
return list(by_proj.values())
|
||||
|
||||
|
||||
def build_forecast(
|
||||
*,
|
||||
logs: list[dict[str, Any]],
|
||||
project_summary: list[dict[str, Any]] | None = None,
|
||||
deliverables: list[dict[str, Any]] | None = None,
|
||||
weeks_ahead: int = 4,
|
||||
headcount: int | None = None,
|
||||
workday_hours_per_week: float = 40.0,
|
||||
from_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute the 4-week capacity outlook.
|
||||
|
||||
Returns the contract documented in the brief: a list of week dicts +
|
||||
totals + decision string. Pure function, no I/O.
|
||||
"""
|
||||
today = from_date or date.today()
|
||||
week_start = _monday_of(today)
|
||||
|
||||
projects = _project_universe(logs, project_summary, deliverables)
|
||||
|
||||
# Active project filter: status not complete AND start <= week-end AND
|
||||
# (no end OR end >= window-start). We materialise the per-project
|
||||
# date window once.
|
||||
active_projects: list[dict[str, Any]] = []
|
||||
for p in projects:
|
||||
if not _is_active_status(p.get("projectStatus")):
|
||||
continue
|
||||
ps = _coerce_date(p.get("projectStartDate"))
|
||||
pe = _coerce_date(p.get("projectEndDate")) or (ps and ps + timedelta(days=90))
|
||||
if not ps or not pe:
|
||||
continue
|
||||
active_projects.append({
|
||||
**p,
|
||||
"_start": ps,
|
||||
"_end": pe,
|
||||
"_assets": float(p.get("assetCount") or 0.0),
|
||||
"_total_weekdays": max(_weekdays_in(ps, pe), 1),
|
||||
})
|
||||
|
||||
# Historical baselines.
|
||||
weekly_throughput = _historic_weekly_throughput(logs)
|
||||
avg_hpa = _avg_hours_per_asset(logs)
|
||||
if headcount is None:
|
||||
headcount = _baseline_headcount(logs)
|
||||
weekly_team_hours = headcount * workday_hours_per_week
|
||||
dept_capacity = (weekly_team_hours / avg_hpa) if avg_hpa > 0 else 0.0
|
||||
|
||||
weeks: list[dict[str, Any]] = []
|
||||
for i in range(weeks_ahead):
|
||||
w_start = week_start + timedelta(days=7 * i)
|
||||
w_end = w_start + timedelta(days=6)
|
||||
|
||||
active_assets = 0.0
|
||||
exiting_assets = 0.0
|
||||
for p in active_projects:
|
||||
overlap = _weekday_overlap(p["_start"], p["_end"], w_start, w_end)
|
||||
if overlap <= 0:
|
||||
continue
|
||||
share = overlap / p["_total_weekdays"]
|
||||
active_assets += p["_assets"] * share
|
||||
if w_start <= p["_end"] <= w_end:
|
||||
exiting_assets += p["_assets"]
|
||||
|
||||
exit_rate = (exiting_assets / active_assets * 100.0) if active_assets > 0 else 0.0
|
||||
can_take_on = max(dept_capacity - active_assets, 0.0)
|
||||
|
||||
weeks.append({
|
||||
"weekStart": _iso(w_start),
|
||||
"weekEnd": _iso(w_end),
|
||||
"weekLabel": _iso_week_label(w_start),
|
||||
"activeAssets": round(active_assets, 1),
|
||||
"exitingAssets": round(exiting_assets, 1),
|
||||
"exitRatePct": round(exit_rate, 1),
|
||||
"weeklyThroughput": round(weekly_throughput, 1),
|
||||
"deptCapacityAssetsPerWeek": round(dept_capacity, 1),
|
||||
"canTakeOn": round(can_take_on, 1),
|
||||
})
|
||||
|
||||
decision = _capacity_decision(weeks, dept_capacity)
|
||||
|
||||
return {
|
||||
"weeks": weeks,
|
||||
"totals": {
|
||||
"weeklyThroughput": round(weekly_throughput, 1),
|
||||
"deptCapacityAssetsPerWeek": round(dept_capacity, 1),
|
||||
"avgHoursPerAsset": round(avg_hpa, 2),
|
||||
"baselineHeadcount": headcount,
|
||||
"weeksAhead": weeks_ahead,
|
||||
"from": _iso(week_start),
|
||||
},
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
|
||||
def _baseline_headcount(logs: Iterable[dict[str, Any]]) -> int:
|
||||
"""Distinct people active in the last 4 full ISO weeks of data."""
|
||||
by_week: dict[str, set[str]] = {}
|
||||
for r in logs:
|
||||
d = _coerce_date(r.get("date"))
|
||||
if not d:
|
||||
continue
|
||||
ident = (r.get("submitterEmail") or r.get("submitter") or "").strip()
|
||||
if not ident:
|
||||
continue
|
||||
iso = d.isocalendar()
|
||||
key = f"{iso.year:04d}-W{iso.week:02d}"
|
||||
by_week.setdefault(key, set()).add(ident)
|
||||
if not by_week:
|
||||
return 0
|
||||
keys = sorted(by_week.keys())
|
||||
full = keys[:-1] if len(keys) > 1 else keys
|
||||
baseline = full[-4:]
|
||||
if not baseline:
|
||||
return 0
|
||||
avg = sum(len(by_week[k]) for k in baseline) / len(baseline)
|
||||
return max(round(avg), 0)
|
||||
|
||||
|
||||
def _capacity_decision(weeks: list[dict[str, Any]], dept_capacity: float) -> str:
|
||||
"""Pick a decision label from the first week's active vs capacity ratio.
|
||||
|
||||
Mirrors the original SPA's "Capacity Decision" wording.
|
||||
"""
|
||||
if not weeks:
|
||||
return "No data — upload a time log to compute capacity"
|
||||
head = weeks[0]
|
||||
active = float(head["activeAssets"])
|
||||
cap = float(dept_capacity)
|
||||
if cap <= 0:
|
||||
return "No capacity baseline — needs hours-per-asset history"
|
||||
ratio = active / cap
|
||||
if ratio < 0.85:
|
||||
return "OK to take on small briefs"
|
||||
if ratio < 1.1:
|
||||
return "At capacity"
|
||||
return "Overloaded — push back"
|
||||
80
backend/app/services/parse_store.py
Normal file
80
backend/app/services/parse_store.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Shared in-process cache for parsed uploads.
|
||||
|
||||
Decisions:
|
||||
- We don't persist uploads to disk — they're held in memory keyed by
|
||||
content sha256. This mirrors the original SPA's localStorage model:
|
||||
the user re-uploads after a server restart.
|
||||
- Each upload kind gets its own TTLCache (size-bounded, time-bounded).
|
||||
Timelog stays small (8 entries, no explicit TTL — bounded LRU is fine).
|
||||
Deliverable and ProjectSummary uploads use a 1h TTL to match the brief.
|
||||
- All entries are stored as the raw dict the parser returned
|
||||
({"rows", "unrecognised_columns", "content_hash"}).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
# Bounded LRU — keeps the most recent N timelog uploads for the session.
|
||||
_MAX_TIMELOG = 8
|
||||
_timelog_store: "OrderedDict[str, dict[str, Any]]" = OrderedDict()
|
||||
|
||||
# 1h TTL caches for deliverable + projectsummary, max 16 each.
|
||||
_TTL_SECONDS = 60 * 60
|
||||
_deliverable_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
|
||||
_project_summary_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
|
||||
|
||||
|
||||
def _put_lru(store: "OrderedDict[str, dict[str, Any]]", parsed: dict[str, Any], max_size: int) -> None:
|
||||
h = parsed["content_hash"]
|
||||
store[h] = parsed
|
||||
store.move_to_end(h)
|
||||
while len(store) > max_size:
|
||||
store.popitem(last=False)
|
||||
|
||||
|
||||
# ---- Timelog ----------------------------------------------------------
|
||||
|
||||
def remember_timelog(parsed: dict[str, Any]) -> None:
|
||||
_put_lru(_timelog_store, parsed, _MAX_TIMELOG)
|
||||
|
||||
|
||||
def get_timelog(content_hash: str) -> dict[str, Any] | None:
|
||||
return _timelog_store.get(content_hash)
|
||||
|
||||
|
||||
# ---- Deliverable ------------------------------------------------------
|
||||
|
||||
def remember_deliverable(parsed: dict[str, Any]) -> None:
|
||||
_deliverable_store[parsed["content_hash"]] = parsed
|
||||
|
||||
|
||||
def get_deliverable(content_hash: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return _deliverable_store[content_hash]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
# ---- Project Summary --------------------------------------------------
|
||||
|
||||
def remember_project_summary(parsed: dict[str, Any]) -> None:
|
||||
_project_summary_store[parsed["content_hash"]] = parsed
|
||||
|
||||
|
||||
def get_project_summary(content_hash: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return _project_summary_store[content_hash]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def clear_all() -> None:
|
||||
"""Test helper — wipes every store."""
|
||||
_timelog_store.clear()
|
||||
_deliverable_store.clear()
|
||||
_project_summary_store.clear()
|
||||
162
backend/app/services/project_summary_parse.py
Normal file
162
backend/app/services/project_summary_parse.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Project Summary parser.
|
||||
|
||||
Decisions:
|
||||
- Mirrors `parseProjectSummaryBuffer` in the original SPA. One row per
|
||||
project; the dashboard uses these as the authoritative project list
|
||||
when present (it overrides the time-log-derived project set).
|
||||
- Emits ISO date strings ("YYYY-MM-DD") for projectStartDate/EndDate so
|
||||
the rest of the API speaks the same shape across deliverable / summary
|
||||
/ forecast endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
from typing import Any, Iterable
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.services.zoho_parse import _parse_date
|
||||
|
||||
|
||||
_KNOWN_HEADERS = {
|
||||
"project number", "project title", "project name",
|
||||
"project status", "status",
|
||||
"project type (from omg)", "project type",
|
||||
"project start date", "project end date",
|
||||
"no. of assets", "no of assets", "assets", "number of assets",
|
||||
"business division", "division", "brand",
|
||||
}
|
||||
|
||||
|
||||
def _norm(s: Any) -> str:
|
||||
return str(s or "").strip().lower()
|
||||
|
||||
|
||||
def _str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def _header_idx(headers: Iterable[Any]) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for i, h in enumerate(headers):
|
||||
k = _norm(h)
|
||||
if k and k not in out:
|
||||
out[k] = i
|
||||
return out
|
||||
|
||||
|
||||
def _col(row: list[Any], idx: dict[str, int], *names: str) -> str:
|
||||
for n in names:
|
||||
i = idx.get(n.lower())
|
||||
if i is not None and i < len(row):
|
||||
v = row[i]
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _col_raw(row: list[Any], idx: dict[str, int], *names: str) -> Any:
|
||||
for n in names:
|
||||
i = idx.get(n.lower())
|
||||
if i is not None and i < len(row):
|
||||
v = row[i]
|
||||
if v is None or (isinstance(v, str) and v.strip() == ""):
|
||||
continue
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _asset_count(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v) if v > 0 else None
|
||||
s = str(v).strip().replace(",", "")
|
||||
try:
|
||||
n = float(s)
|
||||
return n if n > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _date_iso(v: Any) -> str | None:
|
||||
d = _parse_date(v)
|
||||
return d.isoformat() if d else None
|
||||
|
||||
|
||||
def _build_rows(headers: list[Any], data_rows: Iterable[list[Any]]) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
idx = _header_idx(headers)
|
||||
|
||||
unrecognised: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for h in headers:
|
||||
s = _str(h)
|
||||
if not s:
|
||||
continue
|
||||
if s.lower() in _KNOWN_HEADERS:
|
||||
continue
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
unrecognised.append(s)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in data_rows:
|
||||
if not row or all(c in (None, "") for c in row):
|
||||
continue
|
||||
project_number = _col(list(row), idx, "project number")
|
||||
if not project_number:
|
||||
continue
|
||||
out.append({
|
||||
"projectNumber": project_number,
|
||||
"projectTitle": _col(list(row), idx, "project title", "project name"),
|
||||
"projectStatus": _col(list(row), idx, "project status", "status").upper(),
|
||||
"projectType": _col(list(row), idx, "project type (from omg)", "project type"),
|
||||
"projectStartDate": _date_iso(_col_raw(list(row), idx, "project start date")),
|
||||
"projectEndDate": _date_iso(_col_raw(list(row), idx, "project end date")),
|
||||
"assetCount": _asset_count(_col_raw(list(row), idx, "no. of assets", "no of assets", "assets", "number of assets")),
|
||||
"division": _col(list(row), idx, "business division", "division") or "Unknown",
|
||||
"brand": _col(list(row), idx, "brand") or "Unknown",
|
||||
})
|
||||
return out, unrecognised
|
||||
|
||||
|
||||
def parse(filename: str, content: bytes) -> dict[str, Any]:
|
||||
fn = (filename or "").lower()
|
||||
if fn.endswith(".xlsx") or fn.endswith(".xlsm"):
|
||||
rows, unknown = _parse_xlsx(content)
|
||||
else:
|
||||
rows, unknown = _parse_csv(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
return {"rows": rows, "unrecognised_columns": unknown, "content_hash": f"sha256:{digest}"}
|
||||
|
||||
|
||||
def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
text = content.decode("utf-8-sig", errors="replace")
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return [], []
|
||||
return _build_rows(rows[0], rows[1:])
|
||||
|
||||
|
||||
def _parse_xlsx(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
return [], []
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
try:
|
||||
headers = list(next(rows_iter))
|
||||
except StopIteration:
|
||||
return [], []
|
||||
data = (list(r) for r in rows_iter)
|
||||
return _build_rows(headers, data)
|
||||
162
backend/app/services/project_types.py
Normal file
162
backend/app/services/project_types.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Per-project-type stats.
|
||||
|
||||
Mirrors the original SPA's projectStats math, but produces only the
|
||||
small surface area the brief asks for:
|
||||
|
||||
{ projectType, avgHoursPerAsset, avgDurationDays, projectCount,
|
||||
totalHours, concentrationPct, autoInsight }
|
||||
|
||||
Concentration % is the share of total hours that one project type
|
||||
contributes — a useful "where are our hours going" tile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _coerce_date(v: Any) -> date | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date) and not isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
try:
|
||||
return date.fromisoformat(str(v)[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _working_days_between(start: date, end: date) -> int:
|
||||
if not start or not end or end < start:
|
||||
return 0
|
||||
days = 0
|
||||
d = start
|
||||
while d <= end:
|
||||
if d.weekday() < 5:
|
||||
days += 1
|
||||
d += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def build_project_types(
|
||||
*,
|
||||
logs: list[dict[str, Any]],
|
||||
project_summary: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return per-type stats + auto-insights. PS-based when supplied."""
|
||||
# Aggregate hours per project from the time log.
|
||||
proj_hours: dict[str, float] = {}
|
||||
proj_type: dict[str, str] = {}
|
||||
proj_assets: dict[str, float] = {}
|
||||
proj_dates: dict[str, tuple[date | None, date | None]] = {}
|
||||
proj_status: dict[str, str | None] = {}
|
||||
|
||||
for r in logs:
|
||||
title = (r.get("projectTitle") or r.get("projectNumber") or "").strip()
|
||||
ptype = (r.get("projectType") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
hrs = float(r.get("hoursLogged") or r.get("hours") or 0)
|
||||
proj_hours[title] = proj_hours.get(title, 0.0) + hrs
|
||||
if ptype and title not in proj_type:
|
||||
proj_type[title] = ptype
|
||||
if title not in proj_assets and r.get("assetCount"):
|
||||
proj_assets[title] = float(r["assetCount"])
|
||||
if title not in proj_dates:
|
||||
proj_dates[title] = (
|
||||
_coerce_date(r.get("projectStartDate")),
|
||||
_coerce_date(r.get("projectEndDate")),
|
||||
)
|
||||
if title not in proj_status:
|
||||
proj_status[title] = r.get("projectStatus")
|
||||
|
||||
# If we have a project summary, use it as the authoritative type/asset/date list.
|
||||
if project_summary:
|
||||
proj_type.clear()
|
||||
for ps in project_summary:
|
||||
key = (ps.get("projectTitle") or ps.get("projectNumber") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
if ps.get("projectType"):
|
||||
proj_type[key] = ps["projectType"]
|
||||
if ps.get("assetCount"):
|
||||
proj_assets[key] = float(ps["assetCount"])
|
||||
ds = _coerce_date(ps.get("projectStartDate"))
|
||||
de = _coerce_date(ps.get("projectEndDate"))
|
||||
if ds or de:
|
||||
proj_dates[key] = (ds, de)
|
||||
if ps.get("projectStatus"):
|
||||
proj_status[key] = ps["projectStatus"]
|
||||
|
||||
# Group by type.
|
||||
by_type: dict[str, dict[str, Any]] = {}
|
||||
for title, ptype in proj_type.items():
|
||||
bucket = by_type.setdefault(ptype, {
|
||||
"projects": 0,
|
||||
"hours": 0.0,
|
||||
"assets": 0.0,
|
||||
"duration_days": [],
|
||||
})
|
||||
bucket["projects"] += 1
|
||||
bucket["hours"] += proj_hours.get(title, 0.0)
|
||||
bucket["assets"] += proj_assets.get(title, 0.0)
|
||||
s, e = proj_dates.get(title, (None, None))
|
||||
if s and e:
|
||||
wd = _working_days_between(s, e)
|
||||
if wd > 0:
|
||||
bucket["duration_days"].append(wd)
|
||||
|
||||
total_hours = sum(b["hours"] for b in by_type.values())
|
||||
total_projects = sum(b["projects"] for b in by_type.values())
|
||||
# Defensive divisors so we never divide by zero in the loop below.
|
||||
hours_divisor = total_hours or 1.0
|
||||
projects_divisor = total_projects or 1
|
||||
|
||||
stats: list[dict[str, Any]] = []
|
||||
for ptype, b in by_type.items():
|
||||
projects = b["projects"]
|
||||
hours = b["hours"]
|
||||
assets = b["assets"]
|
||||
durations = b["duration_days"]
|
||||
stats.append({
|
||||
"projectType": ptype,
|
||||
"projectCount": projects,
|
||||
"totalHours": round(hours, 1),
|
||||
"totalAssets": round(assets, 1),
|
||||
"avgHoursPerAsset": round((hours / assets), 2) if assets > 0 else 0.0,
|
||||
"avgDurationDays": round(sum(durations) / len(durations), 1) if durations else 0.0,
|
||||
"concentrationPct": round(hours / hours_divisor * 100.0, 1),
|
||||
"projectsPct": round(projects / projects_divisor * 100.0, 1),
|
||||
})
|
||||
stats.sort(key=lambda x: x["totalHours"], reverse=True)
|
||||
|
||||
# Auto-insights — one short sentence per row, plus a list-level note.
|
||||
for s in stats:
|
||||
s["autoInsight"] = _insight(s)
|
||||
|
||||
return {
|
||||
"stats": stats,
|
||||
"totals": {
|
||||
"totalHours": round(total_hours, 1),
|
||||
"totalProjects": total_projects,
|
||||
"totalTypes": len(stats),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _insight(s: dict[str, Any]) -> str:
|
||||
ptype = s["projectType"]
|
||||
h = s["concentrationPct"]
|
||||
p = s["projectsPct"]
|
||||
# Imbalance checks first so the more interesting message wins over the
|
||||
# generic "5.0h/asset across N projects" fallback.
|
||||
if p >= 30 and h < 15:
|
||||
return f"{ptype}: {p:.0f}% of projects but only {h:.0f}% of hours — low effort per brief"
|
||||
if h >= 30 and p <= 20:
|
||||
return f"{ptype}: {h:.0f}% of hours but only {p:.0f}% of projects — concentration risk"
|
||||
if s["avgHoursPerAsset"] > 0 and s["projectCount"] >= 5:
|
||||
return f"{ptype}: {s['avgHoursPerAsset']:.1f}h/asset across {s['projectCount']} projects"
|
||||
return f"{ptype}: {s['projectCount']} project{'s' if s['projectCount'] != 1 else ''}"
|
||||
108
backend/app/services/timelog_filters.py
Normal file
108
backend/app/services/timelog_filters.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Server-side filters for the parsed timelog rows.
|
||||
|
||||
Decisions:
|
||||
- All filters are comma-separated lists of exact string matches. Empty /
|
||||
None disables the filter for that dimension.
|
||||
- Filtering happens on the parsed-row shape produced by zoho_parse.py
|
||||
(camelCase keys: brand, division, hub, userRole, submitter, etc.).
|
||||
- Used by the forecast / project-types / timelog/rows endpoints and the
|
||||
utilisation summary so the same dimensions apply everywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _csv_to_set(s: str | None) -> set[str] | None:
|
||||
if not s:
|
||||
return None
|
||||
parts = [p.strip() for p in s.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return None
|
||||
return set(parts)
|
||||
|
||||
|
||||
def _ci_set(s: set[str] | None) -> set[str] | None:
|
||||
if s is None:
|
||||
return None
|
||||
return {x.strip().lower() for x in s}
|
||||
|
||||
|
||||
def apply_filters(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
*,
|
||||
brands: str | None = None,
|
||||
divisions: str | None = None,
|
||||
hubs: str | None = None,
|
||||
user_roles: str | None = None,
|
||||
departments: str | None = None,
|
||||
names: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter parsed timelog rows by the FilterBar v2 dimensions.
|
||||
|
||||
`departments` and `names` are kept here too so callers can use the
|
||||
same helper across endpoints that have Airtable-driven dept/name
|
||||
dropdowns — but those dimensions don't exist on the timelog row
|
||||
shape, so they pass through as no-ops here (department/name filters
|
||||
are honoured elsewhere in summarise()).
|
||||
"""
|
||||
b = _ci_set(_csv_to_set(brands))
|
||||
d = _ci_set(_csv_to_set(divisions))
|
||||
h = _ci_set(_csv_to_set(hubs))
|
||||
r = _ci_set(_csv_to_set(user_roles))
|
||||
n = _ci_set(_csv_to_set(names))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if b is not None:
|
||||
v = (row.get("brand") or "").strip().lower()
|
||||
if v not in b:
|
||||
continue
|
||||
if d is not None:
|
||||
v = (row.get("division") or "").strip().lower()
|
||||
if v not in d:
|
||||
continue
|
||||
if h is not None:
|
||||
v = (row.get("hub") or "").strip().lower()
|
||||
if v not in h:
|
||||
continue
|
||||
if r is not None:
|
||||
v = (row.get("userRole") or "").strip().lower()
|
||||
if v not in r:
|
||||
continue
|
||||
if n is not None:
|
||||
v = (row.get("submitter") or row.get("employee") or "").strip().lower()
|
||||
if v not in n:
|
||||
continue
|
||||
if date_from and (row.get("date") is None or str(row.get("date")) < date_from):
|
||||
continue
|
||||
if date_to and (row.get("date") is None or str(row.get("date")) > date_to):
|
||||
continue
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def distinct_dimensions(rows: Iterable[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Return sorted distinct values for the FilterBar v2 dropdowns."""
|
||||
brands: set[str] = set()
|
||||
divisions: set[str] = set()
|
||||
hubs: set[str] = set()
|
||||
user_roles: set[str] = set()
|
||||
for r in rows:
|
||||
if r.get("brand"):
|
||||
brands.add(str(r["brand"]).strip())
|
||||
if r.get("division"):
|
||||
divisions.add(str(r["division"]).strip())
|
||||
if r.get("hub"):
|
||||
hubs.add(str(r["hub"]).strip())
|
||||
if r.get("userRole"):
|
||||
user_roles.add(str(r["userRole"]).strip())
|
||||
return {
|
||||
"brands": sorted(b for b in brands if b),
|
||||
"divisions": sorted(d for d in divisions if d),
|
||||
"hubs": sorted(h for h in hubs if h),
|
||||
"userRoles": sorted(u for u in user_roles if u),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
54
backend/tests/test_chat.py
Normal file
54
backend/tests/test_chat.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Chat endpoint tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _client_with_bypass():
|
||||
os.environ["DEV_AUTH_BYPASS"] = "true"
|
||||
os.environ["ANTHROPIC_API_KEY"] = ""
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import importlib
|
||||
import app.main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
return TestClient(main_mod.app, base_url="https://testserver")
|
||||
|
||||
|
||||
def teardown_module(_mod):
|
||||
os.environ["DEV_AUTH_BYPASS"] = "false"
|
||||
os.environ.pop("ANTHROPIC_API_KEY", None)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import importlib
|
||||
import app.main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
|
||||
|
||||
def test_chat_503_when_no_api_key():
|
||||
c = _client_with_bypass()
|
||||
r = c.post("/api/chat", json={
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
})
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert "AI chat is not configured" in body["detail"]
|
||||
|
||||
|
||||
def test_chat_400_when_empty_messages():
|
||||
# Even with the key set, we should refuse empty messages.
|
||||
os.environ["DEV_AUTH_BYPASS"] = "true"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "sk-test"
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import importlib
|
||||
import app.main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
c = TestClient(main_mod.app, base_url="https://testserver")
|
||||
r = c.post("/api/chat", json={"messages": []})
|
||||
# 422 from pydantic validation or 400 from our own check — either is acceptable.
|
||||
assert r.status_code in (400, 422)
|
||||
75
backend/tests/test_deliverable_parse.py
Normal file
75
backend/tests/test_deliverable_parse.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests for the Deliverable Summary parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.deliverable_parse import parse
|
||||
|
||||
|
||||
def test_filters_d_rows_only():
|
||||
csv = (
|
||||
"Component,Project Number,Project Status,Deliverable Number,Deliverable Status,"
|
||||
"Project Type (from OMG),Deliverable Start Date,Deliverable End Date,"
|
||||
"Project Start Date,Project End Date,Brand,Business Division,"
|
||||
"Business Area - Lv 2,Market,Deliverable Title\n"
|
||||
"B,PRJ-1,PROGRESS,BR-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
|
||||
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Brief desc\n"
|
||||
"D,PRJ-1,PROGRESS,DEL-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
|
||||
"01/04/2026,30/06/2026,LOR,Global,UK,UK,First asset\n"
|
||||
"D,PRJ-1,PROGRESS,DEL-2,APPROVED,BANNER PUSH,03/05/2026,04/05/2026,"
|
||||
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Second asset\n"
|
||||
).encode("utf-8")
|
||||
out = parse("deliv.csv", csv)
|
||||
rows = out["rows"]
|
||||
assert len(rows) == 2
|
||||
assert all(r["projectNumber"] == "PRJ-1" for r in rows)
|
||||
# Dates ISO-formatted.
|
||||
assert rows[0]["deliverableStartDate"] == "2026-05-01"
|
||||
assert rows[0]["projectStartDate"] == "2026-04-01"
|
||||
assert rows[0]["projectStatus"] == "PROGRESS"
|
||||
assert rows[0]["deliverableStatus"] == "APPROVED"
|
||||
assert out["content_hash"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_skips_rows_missing_deliverable_dates():
|
||||
csv = (
|
||||
"Component,Project Number,Deliverable Status,Project Status,Project Type,"
|
||||
"Deliverable Start Date,Deliverable End Date,Project Start Date,Project End Date,"
|
||||
"Brand,Business Division,Market,Deliverable Title,Deliverable Number,Business Area - Lv 2\n"
|
||||
"D,PRJ-A,APPROVED,PROGRESS,T,,,2026-01-01,2026-12-31,B,Glob,UK,Title,D-1,UK\n"
|
||||
"D,PRJ-B,APPROVED,PROGRESS,T,2026-05-01,2026-05-31,2026-01-01,2026-12-31,B,Glob,UK,Title,D-2,UK\n"
|
||||
).encode("utf-8")
|
||||
out = parse("d.csv", csv)
|
||||
assert len(out["rows"]) == 1
|
||||
assert out["rows"][0]["projectNumber"] == "PRJ-B"
|
||||
|
||||
|
||||
def test_unrecognised_columns_surface():
|
||||
csv = (
|
||||
"Component,Project Number,Deliverable Start Date,Deliverable End Date,"
|
||||
"Wibble Factor\n"
|
||||
"D,PRJ-X,2026-05-01,2026-05-15,42\n"
|
||||
).encode("utf-8")
|
||||
out = parse("u.csv", csv)
|
||||
assert "Wibble Factor" in out["unrecognised_columns"]
|
||||
|
||||
|
||||
def test_brand_defaults_to_unknown():
|
||||
csv = (
|
||||
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
|
||||
"D,PRJ-Z,2026-05-01,2026-05-15\n"
|
||||
).encode("utf-8")
|
||||
out = parse("d.csv", csv)
|
||||
assert out["rows"][0]["brand"] == "Unknown"
|
||||
assert out["rows"][0]["businessDivision"] == "Unknown"
|
||||
assert out["rows"][0]["market"] == "Unknown"
|
||||
assert out["rows"][0]["projectStatus"] == ""
|
||||
|
||||
|
||||
def test_content_hash_stable():
|
||||
csv = (
|
||||
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
|
||||
"D,PRJ-Z,2026-05-01,2026-05-15\n"
|
||||
).encode("utf-8")
|
||||
a = parse("a.csv", csv)
|
||||
b = parse("b.csv", csv)
|
||||
assert a["content_hash"] == b["content_hash"]
|
||||
165
backend/tests/test_forecast.py
Normal file
165
backend/tests/test_forecast.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Tests for the forecast pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.forecast import build_forecast
|
||||
|
||||
|
||||
def _log(d: str, **kw) -> dict:
|
||||
base = {
|
||||
"date": date.fromisoformat(d),
|
||||
"submitter": "Alice",
|
||||
"submitterEmail": "alice@example.com",
|
||||
"hoursLogged": 8.0,
|
||||
"projectTitle": kw.get("project", "P1"),
|
||||
"projectNumber": kw.get("projectNumber", "PRJ-1"),
|
||||
"projectType": kw.get("projectType", "BANNER PUSH"),
|
||||
"projectStartDate": kw.get("ps"),
|
||||
"projectEndDate": kw.get("pe"),
|
||||
"projectStatus": kw.get("status", "PROGRESS"),
|
||||
"assetCount": kw.get("assets"),
|
||||
}
|
||||
# Allow kw to override defaults explicitly.
|
||||
base.update({k: v for k, v in kw.items()
|
||||
if k not in {"project", "projectNumber", "projectType", "ps", "pe", "status", "assets"}})
|
||||
return base
|
||||
|
||||
|
||||
def test_forecast_empty_inputs():
|
||||
out = build_forecast(logs=[], weeks_ahead=4, from_date=date(2026, 5, 18))
|
||||
assert len(out["weeks"]) == 4
|
||||
assert all(w["activeAssets"] == 0.0 for w in out["weeks"])
|
||||
assert out["totals"]["weeklyThroughput"] == 0.0
|
||||
assert out["decision"].lower().startswith("no")
|
||||
|
||||
|
||||
def test_forecast_active_assets_pro_rated():
|
||||
"""A 4-asset project spanning all four forecast weeks should appear
|
||||
in active count for each week, pro-rated by weekday overlap."""
|
||||
logs = [
|
||||
_log("2026-05-04", projectNumber="PRJ-1", project="P1",
|
||||
ps="2026-05-18", pe="2026-06-12", assets=4.0),
|
||||
]
|
||||
out = build_forecast(
|
||||
logs=logs,
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 5, 18),
|
||||
headcount=2,
|
||||
)
|
||||
assert len(out["weeks"]) == 4
|
||||
# 4 weeks × 5 weekdays = 20 working days. Each forecast week has 5/20 = 25% share = 1 asset.
|
||||
for w in out["weeks"]:
|
||||
assert w["activeAssets"] == pytest.approx(1.0, abs=0.05)
|
||||
|
||||
|
||||
def test_forecast_exiting_assets_only_in_exit_week():
|
||||
"""Project ending in week 2 should show in exitingAssets only that week."""
|
||||
logs = [
|
||||
_log("2026-05-04", projectNumber="PRJ-2", project="P2",
|
||||
ps="2026-05-18", pe="2026-05-29", assets=8.0),
|
||||
]
|
||||
out = build_forecast(
|
||||
logs=logs,
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 5, 18),
|
||||
headcount=2,
|
||||
)
|
||||
# The project ends 2026-05-29 (Fri of W22). week 2 in the forecast is W22.
|
||||
weeks = out["weeks"]
|
||||
assert weeks[0]["exitingAssets"] == 0.0
|
||||
# Find the week whose range contains 2026-05-29.
|
||||
exit_week = [w for w in weeks if w["weekStart"] <= "2026-05-29" <= w["weekEnd"]]
|
||||
assert len(exit_week) == 1
|
||||
assert exit_week[0]["exitingAssets"] == 8.0
|
||||
|
||||
|
||||
def test_forecast_decision_thresholds():
|
||||
"""The decision string should reflect ratio of active vs capacity."""
|
||||
# Low load → "OK to take on small briefs".
|
||||
logs_light = [
|
||||
_log(f"2026-04-{6+i:02d}", projectNumber=f"PR-{i}",
|
||||
project=f"L{i}", ps=f"2026-04-{6+i:02d}", pe=f"2026-04-{10+i:02d}",
|
||||
assets=10.0)
|
||||
for i in range(8)
|
||||
]
|
||||
out = build_forecast(
|
||||
logs=logs_light,
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 5, 18),
|
||||
headcount=10,
|
||||
)
|
||||
# No active projects in the future window → ratio = 0 → "OK".
|
||||
assert "OK" in out["decision"] or "small briefs" in out["decision"]
|
||||
|
||||
|
||||
def test_forecast_uses_project_summary_when_provided():
|
||||
"""When projectSummary is supplied it wins over logs-derived data."""
|
||||
logs = [
|
||||
# Time log says project ends 2026-12-31 with 1 asset.
|
||||
_log("2026-05-04", projectNumber="PRJ-9",
|
||||
ps="2026-05-04", pe="2026-12-31", assets=1.0),
|
||||
]
|
||||
project_summary = [{
|
||||
"projectNumber": "PRJ-9",
|
||||
"projectTitle": "P9",
|
||||
"projectType": "BANNER PUSH",
|
||||
"projectStatus": "PROGRESS",
|
||||
"projectStartDate": "2026-05-18",
|
||||
"projectEndDate": "2026-05-22",
|
||||
"assetCount": 99.0,
|
||||
"division": "Local",
|
||||
"brand": "X",
|
||||
}]
|
||||
out = build_forecast(
|
||||
logs=logs,
|
||||
project_summary=project_summary,
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 5, 18),
|
||||
headcount=2,
|
||||
)
|
||||
# First week ends 2026-05-24 → contains all of the 2026-05-18..22 window.
|
||||
first = out["weeks"][0]
|
||||
# All 5 weekdays of project fit in 5 weekdays of week → 100% share = 99 assets.
|
||||
assert first["activeAssets"] == pytest.approx(99.0, abs=0.5)
|
||||
assert first["exitingAssets"] == 99.0
|
||||
|
||||
|
||||
def test_forecast_dept_capacity_uses_hours_per_asset():
|
||||
"""deptCapacityAssetsPerWeek = headcount × 40h / avgHoursPerAsset."""
|
||||
# Project with 10 assets and 100 hours logged → 10h/asset.
|
||||
logs = [
|
||||
# 10h logged on 10 different days = 100h total against 10-asset project.
|
||||
_log(f"2026-05-{4+i:02d}", projectNumber="PRJ-X",
|
||||
project="X", ps="2026-05-04", pe="2026-05-15",
|
||||
assets=10.0, hoursLogged=10.0)
|
||||
for i in range(10)
|
||||
]
|
||||
out = build_forecast(
|
||||
logs=logs,
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 6, 1),
|
||||
headcount=2,
|
||||
)
|
||||
# 2 people × 40h = 80h / 10h-per-asset = 8 assets/week.
|
||||
assert out["totals"]["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
|
||||
for w in out["weeks"]:
|
||||
assert w["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
|
||||
|
||||
|
||||
def test_forecast_can_take_on_clamped_zero():
|
||||
"""canTakeOn = max(0, capacity - active). Never negative."""
|
||||
out = build_forecast(
|
||||
logs=[
|
||||
_log("2026-05-04", projectNumber="P", ps="2026-05-18",
|
||||
pe="2026-06-30", assets=1000.0, hoursLogged=1.0)
|
||||
],
|
||||
weeks_ahead=4,
|
||||
from_date=date(2026, 5, 18),
|
||||
headcount=1,
|
||||
)
|
||||
for w in out["weeks"]:
|
||||
assert w["canTakeOn"] >= 0.0
|
||||
98
backend/tests/test_project_types.py
Normal file
98
backend/tests/test_project_types.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for the per-project-type stats service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.project_types import build_project_types
|
||||
|
||||
|
||||
def _log(title: str, ptype: str, hours: float, assets: float | None = None,
|
||||
ps: str | None = None, pe: str | None = None) -> dict:
|
||||
return {
|
||||
"projectTitle": title,
|
||||
"projectType": ptype,
|
||||
"hoursLogged": hours,
|
||||
"assetCount": assets,
|
||||
"projectStartDate": ps,
|
||||
"projectEndDate": pe,
|
||||
"projectStatus": "COMPLETE",
|
||||
}
|
||||
|
||||
|
||||
def test_project_types_empty():
|
||||
out = build_project_types(logs=[])
|
||||
assert out["stats"] == []
|
||||
assert out["totals"]["totalHours"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_project_types_hours_per_asset_and_duration():
|
||||
logs = [
|
||||
_log("P1", "BANNER PUSH", 80.0, assets=8.0, ps="2026-04-06", pe="2026-04-10"),
|
||||
_log("P2", "BANNER PUSH", 60.0, assets=6.0, ps="2026-04-13", pe="2026-04-17"),
|
||||
_log("Q1", "ECOM TILE", 20.0, assets=10.0, ps="2026-04-06", pe="2026-04-07"),
|
||||
]
|
||||
out = build_project_types(logs=logs)
|
||||
by_type = {s["projectType"]: s for s in out["stats"]}
|
||||
assert by_type["BANNER PUSH"]["projectCount"] == 2
|
||||
# 140h / 14 assets = 10h/asset.
|
||||
assert by_type["BANNER PUSH"]["avgHoursPerAsset"] == pytest.approx(10.0, abs=0.01)
|
||||
# 5 working days each → avg 5 days.
|
||||
assert by_type["BANNER PUSH"]["avgDurationDays"] == pytest.approx(5.0, abs=0.01)
|
||||
# ECOM TILE: 20h / 10 assets = 2h/asset, duration 2 days.
|
||||
assert by_type["ECOM TILE"]["avgHoursPerAsset"] == pytest.approx(2.0)
|
||||
assert by_type["ECOM TILE"]["avgDurationDays"] == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_project_types_concentration_pct():
|
||||
logs = [
|
||||
_log("P1", "BIG", 80.0, assets=4.0),
|
||||
_log("P2", "BIG", 20.0, assets=2.0),
|
||||
_log("Q1", "SMALL", 10.0, assets=1.0),
|
||||
]
|
||||
out = build_project_types(logs=logs)
|
||||
by_type = {s["projectType"]: s for s in out["stats"]}
|
||||
# BIG: 100h / 110h ≈ 90.9%; SMALL: 10/110 ≈ 9.1%.
|
||||
assert by_type["BIG"]["concentrationPct"] == pytest.approx(90.9, abs=0.5)
|
||||
assert by_type["SMALL"]["concentrationPct"] == pytest.approx(9.1, abs=0.5)
|
||||
|
||||
|
||||
def test_project_types_auto_insight_concentration_risk():
|
||||
"""A type with 30%+ of hours and <20% of projects should flag concentration risk."""
|
||||
# 2 BANNER projects with lots of hours, 8 SMALL projects with little.
|
||||
logs = []
|
||||
for i in range(2):
|
||||
logs.append(_log(f"big-{i}", "BANNER", 100.0, assets=5.0))
|
||||
for i in range(8):
|
||||
logs.append(_log(f"small-{i}", "TINY", 5.0, assets=1.0))
|
||||
out = build_project_types(logs=logs)
|
||||
banner = next(s for s in out["stats"] if s["projectType"] == "BANNER")
|
||||
# 200h / 240h ≈ 83% hours, 2/10 = 20% projects (boundary — should still trigger).
|
||||
# Insight string mentions concentration.
|
||||
assert banner["concentrationPct"] >= 30
|
||||
# The 8 TINY projects have 33% concentration of projects but <15% hours.
|
||||
tiny = next(s for s in out["stats"] if s["projectType"] == "TINY")
|
||||
assert tiny["projectsPct"] > 30
|
||||
assert "low effort per brief" in tiny["autoInsight"] or "concentration" in banner["autoInsight"]
|
||||
|
||||
|
||||
def test_project_summary_overrides_log_data():
|
||||
"""When projectSummary is supplied, asset/type/date data comes from it."""
|
||||
logs = [_log("P1", "OLD-TYPE", 100.0, assets=10.0)]
|
||||
ps = [{
|
||||
"projectNumber": "PRJ-1",
|
||||
"projectTitle": "P1",
|
||||
"projectStatus": "COMPLETE",
|
||||
"projectType": "NEW-TYPE",
|
||||
"projectStartDate": "2026-04-06",
|
||||
"projectEndDate": "2026-04-10",
|
||||
"assetCount": 5.0,
|
||||
"division": "X",
|
||||
"brand": "Y",
|
||||
}]
|
||||
out = build_project_types(logs=logs, project_summary=ps)
|
||||
types = {s["projectType"]: s for s in out["stats"]}
|
||||
assert "NEW-TYPE" in types
|
||||
assert "OLD-TYPE" not in types
|
||||
# 100h / 5 assets = 20h/asset (PS asset count wins).
|
||||
assert types["NEW-TYPE"]["avgHoursPerAsset"] == pytest.approx(20.0)
|
||||
|
|
@ -1,24 +1,45 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import Navbar from './components/Navbar';
|
||||
import StatsBar from './components/StatsBar';
|
||||
import ChatToggle from './components/ChatToggle';
|
||||
import Loading from './components/Loading';
|
||||
import Login from './pages/Login';
|
||||
import { DataProvider } from './hooks/useDataContext';
|
||||
import { canAccess, useAuth } from './hooks/useAuth';
|
||||
|
||||
const Department = lazy(() => import('./pages/Department'));
|
||||
const Resourcing = lazy(() => import('./pages/Resourcing'));
|
||||
const Bookings = lazy(() => import('./pages/Bookings'));
|
||||
const Tutorial = lazy(() => import('./pages/Tutorial'));
|
||||
const Forecast = lazy(() => import('./pages/Forecast'));
|
||||
const ProjectTypeSummary = lazy(() => import('./pages/ProjectTypeSummary'));
|
||||
const TimeLogDetail = lazy(() => import('./pages/TimeLogDetail'));
|
||||
|
||||
function ProtectedShell({ children }: { children: React.ReactNode }) {
|
||||
function RoleGate({ slug, children }: { slug: string; children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
if (user && !canAccess(user.role, slug)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) {
|
||||
return (
|
||||
<AuthGate>
|
||||
<DataProvider>
|
||||
<RoleGate slug={slug}>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<StatsBar />
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 p-4 md:p-6">
|
||||
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
|
||||
</main>
|
||||
<ChatToggle />
|
||||
</div>
|
||||
</RoleGate>
|
||||
</DataProvider>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +51,7 @@ export default function App() {
|
|||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedShell>
|
||||
<ProtectedShell slug="department">
|
||||
<Department />
|
||||
</ProtectedShell>
|
||||
}
|
||||
|
|
@ -38,7 +59,7 @@ export default function App() {
|
|||
<Route
|
||||
path="/resourcing"
|
||||
element={
|
||||
<ProtectedShell>
|
||||
<ProtectedShell slug="resourcing">
|
||||
<Resourcing />
|
||||
</ProtectedShell>
|
||||
}
|
||||
|
|
@ -46,15 +67,39 @@ export default function App() {
|
|||
<Route
|
||||
path="/bookings"
|
||||
element={
|
||||
<ProtectedShell>
|
||||
<ProtectedShell slug="bookings">
|
||||
<Bookings />
|
||||
</ProtectedShell>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forecast"
|
||||
element={
|
||||
<ProtectedShell slug="forecast">
|
||||
<Forecast />
|
||||
</ProtectedShell>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-types"
|
||||
element={
|
||||
<ProtectedShell slug="project-type">
|
||||
<ProjectTypeSummary />
|
||||
</ProtectedShell>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/time-log"
|
||||
element={
|
||||
<ProtectedShell slug="time-log">
|
||||
<TimeLogDetail />
|
||||
</ProtectedShell>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tutorial"
|
||||
element={
|
||||
<ProtectedShell>
|
||||
<ProtectedShell slug="tutorial">
|
||||
<Tutorial />
|
||||
</ProtectedShell>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ import type {
|
|||
AuthMeResponse,
|
||||
BookingsResponse,
|
||||
BreakdownResponse,
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
DimensionsResponse,
|
||||
ForecastResponse,
|
||||
LoginResponse,
|
||||
MetaResponse,
|
||||
ParseResponse,
|
||||
ProjectTypesResponse,
|
||||
ResourcesResponse,
|
||||
TimelogRowsResponse,
|
||||
UtilisationSummaryResponse,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -61,6 +67,46 @@ export function parseTimelog(file: File) {
|
|||
});
|
||||
}
|
||||
|
||||
export function parseDeliverable(file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return apiFetch<ParseResponse>('/deliverable/parse', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
skipJsonContentType: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectSummary(file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return apiFetch<ParseResponse>('/projectsummary/parse', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
skipJsonContentType: true,
|
||||
});
|
||||
}
|
||||
|
||||
export interface TimelogRowsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
timelogHash?: string;
|
||||
}
|
||||
|
||||
export function getTimelogRows(params: TimelogRowsParams = {}) {
|
||||
return apiFetch<TimelogRowsResponse>(
|
||||
`/timelog/rows${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimelogDimensions(timelogHash?: string) {
|
||||
return apiFetch<DimensionsResponse>(
|
||||
`/timelog/dimensions${buildQuery({ timelogHash })}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Utilisation ----------------------------------------------------------
|
||||
|
||||
export interface SummaryParams {
|
||||
|
|
@ -69,6 +115,10 @@ export interface SummaryParams {
|
|||
department?: string;
|
||||
name?: string;
|
||||
billing_type?: string;
|
||||
brands?: string;
|
||||
divisions?: string;
|
||||
hubs?: string;
|
||||
userRoles?: string;
|
||||
period?: 'week' | 'month';
|
||||
timelogHash?: string;
|
||||
}
|
||||
|
|
@ -94,3 +144,62 @@ export function getUtilisationBreakdown(params: BreakdownParams) {
|
|||
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
|
||||
return apiFetch<BreakdownResponse>(`/utilisation/breakdown${buildQuery(query)}`, { headers });
|
||||
}
|
||||
|
||||
// ---- Forecast -------------------------------------------------------------
|
||||
|
||||
export interface ForecastParams {
|
||||
weeks_ahead?: number;
|
||||
from?: string;
|
||||
timelogHash?: string;
|
||||
deliverableHash?: string;
|
||||
projectSummaryHash?: string;
|
||||
brands?: string;
|
||||
divisions?: string;
|
||||
hubs?: string;
|
||||
userRoles?: string;
|
||||
departments?: string;
|
||||
names?: string;
|
||||
}
|
||||
|
||||
export function getForecast(params: ForecastParams = {}) {
|
||||
return apiFetch<ForecastResponse>(
|
||||
`/forecast${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Project Types --------------------------------------------------------
|
||||
|
||||
export interface ProjectTypesParams {
|
||||
timelogHash?: string;
|
||||
projectSummaryHash?: string;
|
||||
brands?: string;
|
||||
divisions?: string;
|
||||
hubs?: string;
|
||||
userRoles?: string;
|
||||
}
|
||||
|
||||
export function getProjectTypes(params: ProjectTypesParams = {}) {
|
||||
return apiFetch<ProjectTypesResponse>(
|
||||
`/project-types${buildQuery(params as Record<string, string | number | boolean | null | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Chat -----------------------------------------------------------------
|
||||
|
||||
export interface ChatRequest {
|
||||
messages: ChatMessage[];
|
||||
context?: {
|
||||
timelogHash?: string;
|
||||
projectSummaryHash?: string;
|
||||
includeBookings?: boolean;
|
||||
includeResources?: boolean;
|
||||
};
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export function postChat(req: ChatRequest) {
|
||||
return apiFetch<ChatResponse>('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
38
frontend/src/components/ChatToggle.tsx
Normal file
38
frontend/src/components/ChatToggle.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { lazy, Suspense, useState } from 'react';
|
||||
import { MessageSquare, X } from 'lucide-react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const ChatView = lazy(() => import('./ChatView'));
|
||||
|
||||
/**
|
||||
* Floating bottom-right toggle that mounts the lazy-loaded ChatView panel.
|
||||
* Hidden for the `forecast` role per the spec.
|
||||
*/
|
||||
export default function ChatToggle() {
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!user || user.role === 'forecast') return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-2xl transition-colors ${
|
||||
open ? 'bg-slate-700 hover:bg-slate-600' : 'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
aria-label={open ? 'Close AI chat' : 'Open AI chat'}
|
||||
title={open ? 'Close AI chat' : 'Open AI chat'}
|
||||
data-tutorial-id="chat-toggle"
|
||||
>
|
||||
{open ? <X className="h-5 w-5 text-white" aria-hidden /> : <MessageSquare className="h-5 w-5 text-white" aria-hidden />}
|
||||
</button>
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<ChatView onClose={() => setOpen(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/ChatView.tsx
Normal file
181
frontend/src/components/ChatView.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { ChatMessage } from '../api/types';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SUGGESTIONS: string[] = [
|
||||
"Who's overloaded this week?",
|
||||
'Show me upcoming project deadlines',
|
||||
'Which brands burn the most hours?',
|
||||
'Estimate hours for a typical Banner Push project',
|
||||
"Where's the capacity risk?",
|
||||
'Reconcile email mismatches',
|
||||
];
|
||||
|
||||
export default function ChatView({ onClose }: Props) {
|
||||
const { timelog, projectSummary } = useDataContext();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (text?: string) => {
|
||||
const msg = (text ?? input).trim();
|
||||
if (!msg || busy) return;
|
||||
setInput('');
|
||||
setError(null);
|
||||
|
||||
const next: ChatMessage[] = [...messages, { role: 'user', content: msg }];
|
||||
setMessages(next);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.postChat({
|
||||
messages: next,
|
||||
context: {
|
||||
timelogHash: timelog.hash ?? undefined,
|
||||
projectSummaryHash: projectSummary.hash ?? undefined,
|
||||
},
|
||||
});
|
||||
const reply = res.content?.[0]?.text ?? '';
|
||||
setMessages((m) => [...m, { role: 'assistant', content: reply }]);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 503) {
|
||||
setError('AI chat is not configured. Ask an admin to set ANTHROPIC_API_KEY in backend .env.');
|
||||
} else if (err.status === 429) {
|
||||
setError('Too many requests — please wait a moment and try again.');
|
||||
} else {
|
||||
setError(err.detail || `Something went wrong (${err.status}).`);
|
||||
}
|
||||
} else {
|
||||
setError((err as Error).message || 'Something went wrong.');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[input, messages, busy, timelog.hash, projectSummary.hash],
|
||||
);
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
};
|
||||
|
||||
const hasData = (timelog.rows ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 right-4 z-50 flex h-[70vh] w-[420px] max-w-[92vw] flex-col rounded-xl bg-white shadow-2xl ring-1 ring-slate-300">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2">
|
||||
<span className={`h-2 w-2 rounded-full ${hasData ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||
<span className="text-sm font-semibold text-slate-800">AI Assistant</span>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMessages([])}
|
||||
className="ml-auto rounded border border-slate-300 px-2 py-0.5 text-[10px] text-slate-500 hover:bg-slate-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-2 rounded p-1 text-slate-500 hover:bg-slate-100"
|
||||
aria-label="Close AI chat"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500">Try asking:</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => void send(s)}
|
||||
disabled={!hasData || busy}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 disabled:opacity-40"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!hasData && <p className="text-[10px] text-slate-400">Upload a time log first to give the AI real context.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`flex gap-2 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{m.role === 'assistant' && (
|
||||
<div className="mt-0.5 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">
|
||||
AI
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-xs leading-relaxed ${
|
||||
m.role === 'user'
|
||||
? 'rounded-br-sm bg-blue-600 text-white'
|
||||
: 'rounded-bl-sm bg-slate-100 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{m.content || (busy && i === messages.length - 1 ? '…' : '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">{error}</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 px-3 py-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={hasData ? 'Ask anything… (↵ to send)' : 'Upload data first…'}
|
||||
disabled={busy}
|
||||
rows={1}
|
||||
className="input resize-none text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void send()}
|
||||
disabled={!input.trim() || busy}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ interface Props {
|
|||
departments: string[];
|
||||
names: string[];
|
||||
billingTypes: string[];
|
||||
brands?: string[];
|
||||
divisions?: string[];
|
||||
hubs?: string[];
|
||||
userRoles?: string[];
|
||||
showForecastToggle?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +33,7 @@ function MultiSelect({
|
|||
tutorialId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<div className="min-w-[10rem] flex-1">
|
||||
<label className="label">{label}</label>
|
||||
<select
|
||||
multiple
|
||||
|
|
@ -54,6 +58,10 @@ export default function FilterBar({
|
|||
departments,
|
||||
names,
|
||||
billingTypes,
|
||||
brands = [],
|
||||
divisions = [],
|
||||
hubs = [],
|
||||
userRoles = [],
|
||||
showForecastToggle = true,
|
||||
}: Props) {
|
||||
const customRange = useMemo(
|
||||
|
|
@ -139,7 +147,7 @@ export default function FilterBar({
|
|||
onChange={(v) => dispatch({ type: 'set-names', names: v })}
|
||||
tutorialId="filter-name"
|
||||
/>
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<div className="min-w-[10rem] flex-1">
|
||||
<label className="label" htmlFor="billing-type">Billing type</label>
|
||||
<select
|
||||
id="billing-type"
|
||||
|
|
@ -158,6 +166,39 @@ export default function FilterBar({
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(brands.length > 0 || divisions.length > 0 || hubs.length > 0 || userRoles.length > 0) && (
|
||||
<div className="flex flex-wrap gap-3 border-t border-slate-200 pt-3">
|
||||
<MultiSelect
|
||||
label="Brand"
|
||||
options={brands}
|
||||
selected={state.brands}
|
||||
onChange={(v) => dispatch({ type: 'set-brands', brands: v })}
|
||||
tutorialId="filter-brand"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Division"
|
||||
options={divisions}
|
||||
selected={state.divisions}
|
||||
onChange={(v) => dispatch({ type: 'set-divisions', divisions: v })}
|
||||
tutorialId="filter-division"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Hub / Market"
|
||||
options={hubs}
|
||||
selected={state.hubs}
|
||||
onChange={(v) => dispatch({ type: 'set-hubs', hubs: v })}
|
||||
tutorialId="filter-hub"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="User role"
|
||||
options={userRoles}
|
||||
selected={state.userRoles}
|
||||
onChange={(v) => dispatch({ type: 'set-user-roles', userRoles: v })}
|
||||
tutorialId="filter-user-role"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, BarChart3 } from 'lucide-react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { canAccess, useAuth } from '../hooks/useAuth';
|
||||
|
||||
const tabs = [
|
||||
{ to: '/', label: 'Department', end: true },
|
||||
{ to: '/resourcing', label: 'Resourcing' },
|
||||
{ to: '/bookings', label: 'Bookings' },
|
||||
{ to: '/tutorial', label: 'Tutorial' },
|
||||
interface Tab {
|
||||
to: string;
|
||||
label: string;
|
||||
slug: string;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
// Order matches the original SPA: Forecast, Project Type Summary, Time Log
|
||||
// Detail, Department, Resourcing, Bookings, Tutorial.
|
||||
const TABS: Tab[] = [
|
||||
{ to: '/forecast', label: 'Forecast', slug: 'forecast' },
|
||||
{ to: '/project-types', label: 'Project Type Summary', slug: 'project-type' },
|
||||
{ to: '/time-log', label: 'Time Log Detail', slug: 'time-log' },
|
||||
{ to: '/', label: 'Department', slug: 'department', end: true },
|
||||
{ to: '/resourcing', label: 'Resourcing', slug: 'resourcing' },
|
||||
{ to: '/bookings', label: 'Bookings', slug: 'bookings' },
|
||||
{ to: '/tutorial', label: 'Tutorial', slug: 'tutorial' },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
|
|
@ -18,20 +30,22 @@ export default function Navbar() {
|
|||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const visible = TABS.filter((t) => canAccess(user?.role, t.slug));
|
||||
|
||||
return (
|
||||
<header className="bg-slate-900 text-slate-100 shadow-md" data-tutorial-id="navbar">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center gap-6 px-4 py-3 md:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center gap-4 px-4 py-3 md:px-6">
|
||||
<div className="flex items-center gap-2 font-semibold tracking-wide">
|
||||
<BarChart3 className="h-5 w-5 text-blue-400" aria-hidden />
|
||||
<span>Utilisation</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
data-tutorial-id={`tab-${tab.label.toLowerCase()}`}
|
||||
data-tutorial-id={`tab-${tab.slug}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition',
|
||||
|
|
@ -44,7 +58,12 @@ export default function Navbar() {
|
|||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||
{user && <span className="text-slate-400">Signed in as <span className="text-slate-200">{user.username}</span></span>}
|
||||
{user && (
|
||||
<span className="text-slate-400">
|
||||
Signed in as <span className="text-slate-200">{user.username}</span>
|
||||
{user.role && <span className="ml-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-slate-300">{user.role}</span>}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
|
|
|
|||
39
frontend/src/components/StatsBar.tsx
Normal file
39
frontend/src/components/StatsBar.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useDataContext } from '../hooks/useDataContext';
|
||||
|
||||
/**
|
||||
* Always-visible stats bar that sits below the Navbar on every data page.
|
||||
* Reads its numbers from the parsed timelog held in DataContext.
|
||||
*
|
||||
* When no time-log has been uploaded yet, the bar still renders (gives the
|
||||
* tutorial a stable DOM target) but shows zeros and a placeholder
|
||||
* date-range, so the chart of the day's chosen page can lay out without
|
||||
* a sudden vertical jump after the upload completes.
|
||||
*/
|
||||
export default function StatsBar() {
|
||||
const { stats } = useDataContext();
|
||||
const dateRange = stats.dateMin && stats.dateMax
|
||||
? `${stats.dateMin} → ${stats.dateMax}`
|
||||
: '—';
|
||||
|
||||
const items: { label: string; value: string }[] = [
|
||||
{ label: 'Time Entries', value: stats.entries.toLocaleString() },
|
||||
{ label: 'People', value: stats.people.toLocaleString() },
|
||||
{ label: 'Projects', value: stats.projects.toLocaleString() },
|
||||
{ label: 'Total Hours', value: stats.totalHours.toLocaleString() },
|
||||
{ label: 'Date Range', value: dateRange },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-slate-900/95 text-slate-100 border-b border-slate-800 px-4 md:px-6 py-2 flex flex-wrap gap-6 text-sm"
|
||||
data-tutorial-id="stats-bar"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-wide text-slate-400">{it.label}</span>
|
||||
<span className="font-semibold tabular-nums">{it.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,19 +4,18 @@ import 'driver.js/dist/driver.css';
|
|||
import { allSteps, type TutorialSection } from './steps';
|
||||
|
||||
interface Props {
|
||||
section: TutorialSection;
|
||||
section?: TutorialSection;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/** Runs a driver.js tour for the selected section. Renders nothing — drives the DOM. */
|
||||
export default function TutorialOverlay({ section, onClose }: Props) {
|
||||
/** Runs a driver.js tour. Renders nothing — drives the DOM. */
|
||||
export default function TutorialOverlay({ section = 'global', onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const steps = allSteps[section]
|
||||
.map((s) => ({
|
||||
element: `[data-tutorial-id="${s.selector}"]`,
|
||||
popover: { title: s.title, description: s.description },
|
||||
}))
|
||||
// Only include steps whose element actually exists on this page.
|
||||
.filter((s) => document.querySelector(s.element));
|
||||
|
||||
if (steps.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,142 +1,75 @@
|
|||
export interface TutorialStep {
|
||||
/** matches the `data-tutorial-id` attribute on the target element */
|
||||
selector: string;
|
||||
/** chapter label, mirrors the original SPA */
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The original SPA exposed a chapter list ("Chart overview",
|
||||
* "Reading the Utilisation Chart", "Forecast Line & Filters", "Drill-In",
|
||||
* "Spotting Resource Issues", etc.). The selectors below map each chapter
|
||||
* onto the matching data-tutorial-id on screen. Steps whose target isn't
|
||||
* on the current page are filtered out by TutorialOverlay at runtime.
|
||||
* Single global walkthrough — 9 chapters that mirror the original SPA's
|
||||
* TutorialView. Steps whose target isn't on the current page are filtered
|
||||
* out by TutorialOverlay so the tour gracefully shortens.
|
||||
*/
|
||||
|
||||
export const departmentSteps: TutorialStep[] = [
|
||||
export const globalSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'navbar',
|
||||
title: 'How to Use the Department Tab',
|
||||
description: 'Navigate to the Department Tab from here. The rest of the tour walks through every chart on this page.',
|
||||
title: 'Tab structure',
|
||||
description:
|
||||
'The dashboard has seven tabs: Forecast, Project Type Summary, Time Log Detail, Department, Resourcing, Bookings and Tutorial. Use this nav to jump between them — your role determines which ones are visible.',
|
||||
},
|
||||
{
|
||||
selector: 'stats-bar',
|
||||
title: 'Quick stats',
|
||||
description:
|
||||
'The bar under the header shows time entries, distinct people, projects, total hours and date range. Numbers update live as you filter — useful for sanity-checking what you\'re looking at.',
|
||||
},
|
||||
{
|
||||
selector: 'upload-zone',
|
||||
title: 'Upload your timelog',
|
||||
description: 'Drag a Zoho / Harvest / Toggl export here, or click to choose a file. .xlsx and .csv supported. The file stays in this session only.',
|
||||
title: 'Upload data',
|
||||
description:
|
||||
'Drop the Zoho Time Summary export here. The Deliverable Summary and Project Summary uploads are optional but improve the forecast and benchmarks substantially.',
|
||||
},
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'Pick a date range preset (or use a custom range), narrow by department, name or billing type, and toggle the forecast overlay.',
|
||||
title: 'Filters & date range',
|
||||
description:
|
||||
'Date presets, dept/name multiselects, and the new brand/division/hub/role filters. Filters apply uniformly across every tab.',
|
||||
},
|
||||
{
|
||||
selector: 'forecast-canvas',
|
||||
title: 'Capacity forecast',
|
||||
description:
|
||||
'Active vs exiting assets per week with a capacity baseline overlay. The decision banner tells you at a glance whether you can take on more work.',
|
||||
},
|
||||
{
|
||||
selector: 'project-type-table',
|
||||
title: 'Project Type Summary',
|
||||
description:
|
||||
'Per-type benchmarks: hours/asset, duration, concentration %. Sort columns to spot outliers and identify where your effort really goes.',
|
||||
},
|
||||
{
|
||||
selector: 'time-log-table',
|
||||
title: 'Time Log Detail',
|
||||
description:
|
||||
'Every parsed entry, server-paginated. Search by name, project, brand or hub. Click column headers to sort.',
|
||||
},
|
||||
{
|
||||
selector: 'kpi-tiles',
|
||||
title: 'Hours & Utilisation',
|
||||
description: 'Headline KPIs — Total Booked / Logged / Billable, distinct projects, active people and net-of-leave capacity for the chosen period.',
|
||||
title: 'Headline KPIs',
|
||||
description:
|
||||
'On the Department tab the KPI tiles summarise Booked, Logged, Billable, projects and active people for the active filters.',
|
||||
},
|
||||
{
|
||||
selector: 'sync-airtable',
|
||||
title: 'Airtable sync',
|
||||
description: 'Force-refresh the bookings cache to pick up brand-new Airtable changes. Use sparingly — counts against API rate limits.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-monthly-utilisation',
|
||||
title: 'Reading the Utilisation Chart',
|
||||
description: 'Booked, logged and available hours grouped by month. Active vs Soft bookings are stacked. Click bars & toggle forecast line.',
|
||||
},
|
||||
{
|
||||
selector: 'hour-breakdown',
|
||||
title: 'Drill-In',
|
||||
description: 'Click any Booked bar to open the Hour Breakdown — a per-project split of logged and booked hours for that period.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-booking-vs-actual',
|
||||
title: 'Chart overview — Booking vs Actual',
|
||||
description: 'See whose bookings match their logged hours and whose drift. Active vs Soft are stacked; capped at the top 20 busiest people.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-billability-breakdown',
|
||||
title: 'Spotting Resource Issues',
|
||||
description: 'Per-person split of billable, non-billable, leave and idle hours. Spot errors like a wall of non-billable on a "busy" person.',
|
||||
},
|
||||
{
|
||||
selector: 'export-csv',
|
||||
title: 'Export to CSV',
|
||||
description: 'Download the current summary as a CSV — handy for sharing outside the app.',
|
||||
},
|
||||
];
|
||||
|
||||
export const resourcingSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'The same filter controls you used on Department — date range, department and name all flow through to the Airtable bookings query.',
|
||||
},
|
||||
{
|
||||
selector: 'kpi-tiles',
|
||||
title: 'Hours & Utilisation',
|
||||
description: 'Headline KPIs across the people in scope: total booked (active + soft), logged, billable, distinct projects and net-of-leave capacity.',
|
||||
},
|
||||
{
|
||||
selector: 'period-toggle',
|
||||
title: 'Chart overview — Per week / Per month',
|
||||
description: 'Switch the chart aggregation between week and month. Day-level isn\'t supported (too noisy across 100+ employees).',
|
||||
},
|
||||
{
|
||||
selector: 'sync-airtable',
|
||||
title: 'Sync Airtable Bookings',
|
||||
description: 'Force-refresh the bookings cache so the charts reflect the latest Airtable state.',
|
||||
},
|
||||
{
|
||||
selector: 'chart-weekly-utilisation',
|
||||
title: 'Reading the Utilisation Chart',
|
||||
description: 'Active Booked + Soft Booked stack together; Logged and Available sit alongside. Click bars & toggle forecast line.',
|
||||
},
|
||||
{
|
||||
selector: 'hour-breakdown',
|
||||
title: 'Drill-In',
|
||||
description: 'After clicking a bar, this panel shows the per-project split for the selected period (logged + booked).',
|
||||
},
|
||||
{
|
||||
selector: 'chart-project-load',
|
||||
title: 'Chart overview — Project Load',
|
||||
description: 'Stacked bars show which projects are loading up each resource. Capped at the top 10 projects by hours; the rest roll up into "Other".',
|
||||
},
|
||||
{
|
||||
selector: 'chart-fte-vs-freelancer',
|
||||
title: 'Spotting Resource Issues',
|
||||
description: 'Compare utilisation between salaried staff and contractors side by side. Use this to spot under-used FTEs or over-loaded freelancers.',
|
||||
},
|
||||
{
|
||||
selector: 'export-csv',
|
||||
title: 'Export to CSV',
|
||||
description: 'Download the resourcing summary for the active filters.',
|
||||
},
|
||||
];
|
||||
|
||||
export const bookingsSteps: TutorialStep[] = [
|
||||
{
|
||||
selector: 'filter-bar',
|
||||
title: 'Forecast Line & Filters',
|
||||
description: 'Date range, department and name filters narrow the table below.',
|
||||
},
|
||||
{
|
||||
selector: 'bookings-table',
|
||||
title: 'Reading the bookings table',
|
||||
description: 'A virtualised view of every booking returned by Airtable for the active filters.',
|
||||
},
|
||||
{
|
||||
selector: 'bookings-refresh',
|
||||
title: 'Sync Airtable Bookings',
|
||||
description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.',
|
||||
selector: 'chat-toggle',
|
||||
title: 'AI assistant',
|
||||
description:
|
||||
'The floating button bottom-right opens the AI panel. Ask things like "Who\'s overloaded this week?" or "Where\'s the capacity risk?" — the assistant has live context from your uploads.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Back-compat — the Tutorial page used to expose multiple section tours. */
|
||||
export const allSteps = {
|
||||
department: departmentSteps,
|
||||
resourcing: resourcingSteps,
|
||||
bookings: bookingsSteps,
|
||||
global: globalSteps,
|
||||
} as const;
|
||||
|
||||
export type TutorialSection = keyof typeof allSteps;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { UserRole } from '../api/types';
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
mode: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
|
|
@ -24,7 +26,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
setUser({ username: me.username, mode: me.mode });
|
||||
setUser({ username: me.username, mode: me.mode, role: (me.role ?? 'global-lead') as UserRole });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setUser(null);
|
||||
|
|
@ -43,7 +45,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await api.login(username, password);
|
||||
setUser({ username: res.username, mode: res.mode });
|
||||
setUser({ username: res.username, mode: res.mode, role: (res.role ?? 'global-lead') as UserRole });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
|
|
@ -67,3 +69,23 @@ export function useAuth(): AuthContextValue {
|
|||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Centralised tab → role map mirroring the original SPA's TAB_ACCESS.
|
||||
* Pages call `canAccess(role, slug)` to gate routes and Navbar to hide tabs.
|
||||
*/
|
||||
export const TAB_ACCESS: Record<string, UserRole[]> = {
|
||||
forecast: ['global-lead', 'forecast'],
|
||||
'project-type': ['global-lead'],
|
||||
'time-log': ['global-lead'],
|
||||
department: ['global-lead', 'dept-lead'],
|
||||
resourcing: ['global-lead', 'dept-lead'],
|
||||
bookings: ['global-lead', 'dept-lead'],
|
||||
tutorial: ['global-lead', 'dept-lead', 'forecast'],
|
||||
};
|
||||
|
||||
export function canAccess(role: UserRole | null | undefined, slug: string): boolean {
|
||||
if (!role) return false;
|
||||
const allowed = TAB_ACCESS[slug];
|
||||
if (!allowed) return true;
|
||||
return allowed.includes(role);
|
||||
}
|
||||
|
|
|
|||
209
frontend/src/hooks/useDataContext.tsx
Normal file
209
frontend/src/hooks/useDataContext.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import type { DimensionsResponse, ParseResponse } from '../api/types';
|
||||
import { filterReducer, initialFilterState, type FilterAction, type FilterState } from '../lib/filters';
|
||||
|
||||
/**
|
||||
* Lifts the parsed timelog + the v2 filter state into a top-level context.
|
||||
*
|
||||
* Every data page (Department, Resourcing, Bookings, Forecast,
|
||||
* ProjectTypeSummary, TimeLogDetail) reads the same filter state and the
|
||||
* same `timelogHash`/`deliverableHash`/`projectSummaryHash`, and the
|
||||
* always-visible StatsBar shares the dimension counts.
|
||||
*/
|
||||
|
||||
interface UploadState {
|
||||
hash: string | null;
|
||||
rows: number;
|
||||
filename: string | null;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
unrecognised: string[];
|
||||
}
|
||||
|
||||
const emptyUpload: UploadState = {
|
||||
hash: null,
|
||||
rows: 0,
|
||||
filename: null,
|
||||
uploading: false,
|
||||
error: null,
|
||||
unrecognised: [],
|
||||
};
|
||||
|
||||
interface DataContextValue {
|
||||
// Filter state (single source of truth for all pages).
|
||||
filters: FilterState;
|
||||
dispatch: React.Dispatch<FilterAction>;
|
||||
|
||||
// Uploaded artefacts.
|
||||
timelog: UploadState;
|
||||
deliverable: UploadState;
|
||||
projectSummary: UploadState;
|
||||
uploadTimelog: (file: File) => Promise<void>;
|
||||
uploadDeliverable: (file: File) => Promise<void>;
|
||||
uploadProjectSummary: (file: File) => Promise<void>;
|
||||
clearTimelog: () => void;
|
||||
|
||||
// FilterBar v2 dimension options (sourced from the parsed timelog).
|
||||
dimensions: DimensionsResponse;
|
||||
|
||||
// Headline stats for the StatsBar — derived from the latest parse.
|
||||
stats: { entries: number; people: number; projects: number; totalHours: number; dateMin: string; dateMax: string };
|
||||
lastParse: ParseResponse | null;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextValue | undefined>(undefined);
|
||||
|
||||
function deriveStats(rows: ParseResponse['rows']): DataContextValue['stats'] {
|
||||
if (!rows || rows.length === 0) {
|
||||
return { entries: 0, people: 0, projects: 0, totalHours: 0, dateMin: '', dateMax: '' };
|
||||
}
|
||||
const people = new Set<string>();
|
||||
const projects = new Set<string>();
|
||||
let totalHours = 0;
|
||||
let dateMin = '';
|
||||
let dateMax = '';
|
||||
for (const r of rows) {
|
||||
const ident = r.submitterEmail || r.submitter;
|
||||
if (ident) people.add(String(ident));
|
||||
const title = r.projectTitle || r.projectNumber;
|
||||
if (title) projects.add(String(title));
|
||||
totalHours += Number(r.hoursLogged) || 0;
|
||||
const d = r.date ? String(r.date) : '';
|
||||
if (d) {
|
||||
if (!dateMin || d < dateMin) dateMin = d;
|
||||
if (!dateMax || d > dateMax) dateMax = d;
|
||||
}
|
||||
}
|
||||
return {
|
||||
entries: rows.length,
|
||||
people: people.size,
|
||||
projects: projects.size,
|
||||
totalHours: Math.round(totalHours),
|
||||
dateMin,
|
||||
dateMax,
|
||||
};
|
||||
}
|
||||
|
||||
export function DataProvider({ children }: { children: React.ReactNode }) {
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
|
||||
const [timelog, setTimelog] = useState<UploadState>(emptyUpload);
|
||||
const [deliverable, setDeliverable] = useState<UploadState>(emptyUpload);
|
||||
const [projectSummary, setProjectSummary] = useState<UploadState>(emptyUpload);
|
||||
const [lastParse, setLastParse] = useState<ParseResponse | null>(null);
|
||||
const [dimensions, setDimensions] = useState<DimensionsResponse>({
|
||||
brands: [],
|
||||
divisions: [],
|
||||
hubs: [],
|
||||
userRoles: [],
|
||||
});
|
||||
|
||||
const stats = useMemo(() => deriveStats(lastParse?.rows ?? []), [lastParse]);
|
||||
|
||||
const refreshDimensions = useCallback(async (hash: string | null) => {
|
||||
if (!hash) {
|
||||
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = await api.getTimelogDimensions(hash);
|
||||
setDimensions(d);
|
||||
} catch {
|
||||
// Non-fatal — multiselect just stays empty.
|
||||
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDimensions(timelog.hash);
|
||||
}, [timelog.hash, refreshDimensions]);
|
||||
|
||||
const uploadTimelog = useCallback(async (file: File) => {
|
||||
setTimelog((s) => ({ ...s, uploading: true, error: null }));
|
||||
try {
|
||||
const res = await api.parseTimelog(file);
|
||||
setLastParse(res);
|
||||
setTimelog({
|
||||
hash: res.content_hash,
|
||||
rows: res.rows.length,
|
||||
filename: file.name,
|
||||
uploading: false,
|
||||
error: null,
|
||||
unrecognised: res.unrecognised_columns,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.detail : (err as Error).message;
|
||||
setTimelog((s) => ({ ...s, uploading: false, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadDeliverable = useCallback(async (file: File) => {
|
||||
setDeliverable((s) => ({ ...s, uploading: true, error: null }));
|
||||
try {
|
||||
const res = await api.parseDeliverable(file);
|
||||
setDeliverable({
|
||||
hash: res.content_hash,
|
||||
rows: res.rows.length,
|
||||
filename: file.name,
|
||||
uploading: false,
|
||||
error: null,
|
||||
unrecognised: res.unrecognised_columns,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.detail : (err as Error).message;
|
||||
setDeliverable((s) => ({ ...s, uploading: false, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadProjectSummary = useCallback(async (file: File) => {
|
||||
setProjectSummary((s) => ({ ...s, uploading: true, error: null }));
|
||||
try {
|
||||
const res = await api.parseProjectSummary(file);
|
||||
setProjectSummary({
|
||||
hash: res.content_hash,
|
||||
rows: res.rows.length,
|
||||
filename: file.name,
|
||||
uploading: false,
|
||||
error: null,
|
||||
unrecognised: res.unrecognised_columns,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.detail : (err as Error).message;
|
||||
setProjectSummary((s) => ({ ...s, uploading: false, error: message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearTimelog = useCallback(() => {
|
||||
setTimelog(emptyUpload);
|
||||
setLastParse(null);
|
||||
setDimensions({ brands: [], divisions: [], hubs: [], userRoles: [] });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<DataContextValue>(
|
||||
() => ({
|
||||
filters,
|
||||
dispatch,
|
||||
timelog,
|
||||
deliverable,
|
||||
projectSummary,
|
||||
uploadTimelog,
|
||||
uploadDeliverable,
|
||||
uploadProjectSummary,
|
||||
clearTimelog,
|
||||
dimensions,
|
||||
stats,
|
||||
lastParse,
|
||||
}),
|
||||
[filters, timelog, deliverable, projectSummary, uploadTimelog, uploadDeliverable, uploadProjectSummary, clearTimelog, dimensions, stats, lastParse],
|
||||
);
|
||||
|
||||
return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDataContext(): DataContextValue {
|
||||
const ctx = useContext(DataContext);
|
||||
if (!ctx) throw new Error('useDataContext must be used inside <DataProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ export interface FilterState {
|
|||
range: DateRange | null;
|
||||
departments: string[];
|
||||
names: string[];
|
||||
brands: string[];
|
||||
divisions: string[];
|
||||
hubs: string[];
|
||||
userRoles: string[];
|
||||
billingType: string | null;
|
||||
showForecast: boolean;
|
||||
period: PeriodKind;
|
||||
|
|
@ -18,6 +22,10 @@ export const initialFilterState: FilterState = {
|
|||
range: dateRangePreset('this-month'),
|
||||
departments: [],
|
||||
names: [],
|
||||
brands: [],
|
||||
divisions: [],
|
||||
hubs: [],
|
||||
userRoles: [],
|
||||
billingType: null,
|
||||
showForecast: true,
|
||||
period: 'week',
|
||||
|
|
@ -28,6 +36,10 @@ export type FilterAction =
|
|||
| { type: 'set-custom-range'; range: DateRange }
|
||||
| { type: 'set-departments'; departments: string[] }
|
||||
| { type: 'set-names'; names: string[] }
|
||||
| { type: 'set-brands'; brands: string[] }
|
||||
| { type: 'set-divisions'; divisions: string[] }
|
||||
| { type: 'set-hubs'; hubs: string[] }
|
||||
| { type: 'set-user-roles'; userRoles: string[] }
|
||||
| { type: 'set-billing-type'; billingType: string | null }
|
||||
| { type: 'set-period'; period: PeriodKind }
|
||||
| { type: 'toggle-forecast' }
|
||||
|
|
@ -45,6 +57,14 @@ export function filterReducer(state: FilterState, action: FilterAction): FilterS
|
|||
return { ...state, departments: action.departments };
|
||||
case 'set-names':
|
||||
return { ...state, names: action.names };
|
||||
case 'set-brands':
|
||||
return { ...state, brands: action.brands };
|
||||
case 'set-divisions':
|
||||
return { ...state, divisions: action.divisions };
|
||||
case 'set-hubs':
|
||||
return { ...state, hubs: action.hubs };
|
||||
case 'set-user-roles':
|
||||
return { ...state, userRoles: action.userRoles };
|
||||
case 'set-billing-type':
|
||||
return { ...state, billingType: action.billingType };
|
||||
case 'set-period':
|
||||
|
|
@ -67,5 +87,9 @@ export function filtersToQuery(state: FilterState): Record<string, string | unde
|
|||
name: state.names.length ? state.names.join(',') : undefined,
|
||||
billing_type: state.billingType ?? undefined,
|
||||
period: state.period,
|
||||
brands: state.brands.length ? state.brands.join(',') : undefined,
|
||||
divisions: state.divisions.length ? state.divisions.join(',') : undefined,
|
||||
hubs: state.hubs.length ? state.hubs.join(',') : undefined,
|
||||
userRoles: state.userRoles.length ? state.userRoles.join(',') : undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import Loading from '../components/Loading';
|
||||
import ErrorBox from '../components/ErrorBox';
|
||||
import { useAirtableData } from '../hooks/useAirtableData';
|
||||
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import { filtersToQuery } from '../lib/filters';
|
||||
import { downloadCsv, rowsToCsv } from '../lib/csv';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
|
|
@ -15,7 +16,7 @@ const OVERSCAN = 8;
|
|||
|
||||
export default function Bookings() {
|
||||
const airtable = useAirtableData(false);
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
const { filters, dispatch, dimensions } = useDataContext();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [cachedAt, setCachedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -90,6 +91,10 @@ export default function Bookings() {
|
|||
departments={airtable.meta?.departments ?? []}
|
||||
names={names}
|
||||
billingTypes={airtable.meta?.billingTypes ?? []}
|
||||
brands={dimensions.brands}
|
||||
divisions={dimensions.divisions}
|
||||
hubs={dimensions.hubs}
|
||||
userRoles={dimensions.userRoles}
|
||||
showForecastToggle={false}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import UploadButton from '../components/UploadButton';
|
||||
|
|
@ -12,8 +12,8 @@ import Loading from '../components/Loading';
|
|||
import ErrorBox from '../components/ErrorBox';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import { useAirtableData } from '../hooks/useAirtableData';
|
||||
import { useTimelog } from '../hooks/useTimelog';
|
||||
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import { filtersToQuery } from '../lib/filters';
|
||||
import { downloadCsv, rowsToCsv } from '../lib/csv';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
|
|
@ -21,8 +21,7 @@ import type { UtilisationSummaryRow, UtilisationTotals } from '../api/types';
|
|||
|
||||
export default function Department() {
|
||||
const airtable = useAirtableData(false);
|
||||
const tl = useTimelog();
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
const { filters, dispatch, timelog, dimensions, uploadTimelog, clearTimelog } = useDataContext();
|
||||
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
|
||||
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
|
@ -46,8 +45,12 @@ export default function Department() {
|
|||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
brands: q.brands,
|
||||
divisions: q.divisions,
|
||||
hubs: q.hubs,
|
||||
userRoles: q.userRoles,
|
||||
period: filters.period,
|
||||
timelogHash: tl.hash ?? undefined,
|
||||
timelogHash: timelog.hash ?? undefined,
|
||||
});
|
||||
setSummary(res.rows);
|
||||
setTotals(res.totals ?? null);
|
||||
|
|
@ -56,7 +59,7 @@ export default function Department() {
|
|||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [filters, tl.hash]);
|
||||
}, [filters, timelog.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
|
|
@ -65,7 +68,6 @@ export default function Department() {
|
|||
const handleSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
// Force-refresh bookings cache, then re-derive summary.
|
||||
const q = filtersToQuery(filters);
|
||||
await api.getBookings({ from: q.from, to: q.to, refresh: true });
|
||||
await loadSummary();
|
||||
|
|
@ -97,13 +99,13 @@ export default function Department() {
|
|||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<UploadButton
|
||||
uploading={tl.uploading}
|
||||
error={tl.error}
|
||||
unrecognised={tl.unrecognised}
|
||||
filename={tl.filename}
|
||||
rowCount={tl.rows.length}
|
||||
onFile={(f) => void tl.upload(f)}
|
||||
onClear={tl.clear}
|
||||
uploading={timelog.uploading}
|
||||
error={timelog.error}
|
||||
unrecognised={timelog.unrecognised}
|
||||
filename={timelog.filename}
|
||||
rowCount={timelog.rows}
|
||||
onFile={(f) => void uploadTimelog(f)}
|
||||
onClear={clearTimelog}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
|
|
@ -112,6 +114,10 @@ export default function Department() {
|
|||
departments={airtable.meta?.departments ?? []}
|
||||
names={names}
|
||||
billingTypes={airtable.meta?.billingTypes ?? []}
|
||||
brands={dimensions.brands}
|
||||
divisions={dimensions.divisions}
|
||||
hubs={dimensions.hubs}
|
||||
userRoles={dimensions.userRoles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -120,7 +126,6 @@ export default function Department() {
|
|||
value={filters.period}
|
||||
onChange={(p) => {
|
||||
dispatch({ type: 'set-period', period: p });
|
||||
// Clear drill-down: period labels won't match across week/month.
|
||||
setSelectedPeriod(null);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -171,7 +176,7 @@ export default function Department() {
|
|||
period={selectedPeriod}
|
||||
from={filters.range?.from}
|
||||
to={filters.range?.to}
|
||||
timelogHash={tl.hash}
|
||||
timelogHash={timelog.hash}
|
||||
onClose={() => setSelectedPeriod(null)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
208
frontend/src/pages/Forecast.tsx
Normal file
208
frontend/src/pages/Forecast.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react';
|
||||
import Loading from '../components/Loading';
|
||||
import ErrorBox from '../components/ErrorBox';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import type { ForecastResponse } from '../api/types';
|
||||
import { filtersToQuery } from '../lib/filters';
|
||||
|
||||
function decisionIcon(decision: string) {
|
||||
const d = decision.toLowerCase();
|
||||
if (d.includes('overload')) return <AlertTriangle className="h-5 w-5" aria-hidden />;
|
||||
if (d.includes('capacity') && !d.includes('no capacity')) return <Info className="h-5 w-5" aria-hidden />;
|
||||
if (d.includes('ok')) return <CheckCircle2 className="h-5 w-5" aria-hidden />;
|
||||
return <Info className="h-5 w-5" aria-hidden />;
|
||||
}
|
||||
|
||||
function decisionStyle(decision: string): string {
|
||||
const d = decision.toLowerCase();
|
||||
if (d.includes('overload')) return 'bg-red-50 text-red-800 ring-red-200';
|
||||
if (d.includes('at capacity')) return 'bg-amber-50 text-amber-800 ring-amber-200';
|
||||
if (d.includes('ok')) return 'bg-emerald-50 text-emerald-800 ring-emerald-200';
|
||||
return 'bg-slate-50 text-slate-700 ring-slate-200';
|
||||
}
|
||||
|
||||
export default function ForecastPage() {
|
||||
const { filters, timelog, deliverable, projectSummary } = useDataContext();
|
||||
const [data, setData] = useState<ForecastResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const res = await api.getForecast({
|
||||
weeks_ahead: 4,
|
||||
from: filters.range?.from,
|
||||
timelogHash: timelog.hash ?? undefined,
|
||||
deliverableHash: deliverable.hash ?? undefined,
|
||||
projectSummaryHash: projectSummary.hash ?? undefined,
|
||||
brands: q.brands,
|
||||
divisions: q.divisions,
|
||||
hubs: q.hubs,
|
||||
userRoles: q.userRoles,
|
||||
departments: q.department,
|
||||
names: q.name,
|
||||
});
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, timelog.hash, deliverable.hash, projectSummary.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const chartData = (data?.weeks ?? []).map((w) => ({
|
||||
label: w.weekLabel,
|
||||
active: w.activeAssets,
|
||||
exiting: w.exitingAssets,
|
||||
exitRate: w.exitRatePct,
|
||||
capacity: w.deptCapacityAssetsPerWeek,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card">
|
||||
<h1 className="text-base font-semibold text-slate-800">Forecast — Next 4 weeks</h1>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Active asset count and exit rate per week, plus the department capacity baseline derived
|
||||
from your historical hours-per-asset and headcount. Upload a Project Summary file to
|
||||
improve accuracy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!timelog.hash && (
|
||||
<div className="card text-sm text-slate-600">
|
||||
Upload a time log on the Department tab to populate the forecast.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <ErrorBox message={error} onRetry={load} />}
|
||||
{loading && <Loading label="Computing forecast…" />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<div
|
||||
className={`card flex items-center gap-3 ring-1 ${decisionStyle(data.decision)}`}
|
||||
data-tutorial-id="capacity-decision"
|
||||
>
|
||||
{decisionIcon(data.decision)}
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide opacity-75">Capacity decision</div>
|
||||
<div className="text-sm font-semibold">{data.decision}</div>
|
||||
</div>
|
||||
<div className="ml-auto grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="opacity-75">Weekly throughput</div>
|
||||
<div className="font-semibold">{data.totals.weeklyThroughput}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-75">Capacity / week</div>
|
||||
<div className="font-semibold">{data.totals.deptCapacityAssetsPerWeek}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-75">Avg hrs / asset</div>
|
||||
<div className="font-semibold">{data.totals.avgHoursPerAsset}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" data-tutorial-id="forecast-canvas">
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Active + Exiting Assets</h3>
|
||||
<span className="text-xs text-slate-500">
|
||||
Headcount baseline: {data.totals.baselineHeadcount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} unit="%" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="active" stackId="a" name="Active assets" fill="#2563eb" />
|
||||
<Bar yAxisId="left" dataKey="exiting" stackId="a" name="Exiting assets" fill="#f59e0b" />
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="exitRate"
|
||||
name="Exit rate %"
|
||||
stroke="#dc2626"
|
||||
strokeWidth={2}
|
||||
dot
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="capacity"
|
||||
name="Capacity baseline"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">Week-by-week breakdown</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="py-2 pr-2">Week</th>
|
||||
<th className="py-2 pr-2">Starts</th>
|
||||
<th className="py-2 pr-2 text-right">Active</th>
|
||||
<th className="py-2 pr-2 text-right">Exiting</th>
|
||||
<th className="py-2 pr-2 text-right">Exit %</th>
|
||||
<th className="py-2 pr-2 text-right">Throughput</th>
|
||||
<th className="py-2 pr-2 text-right">Capacity</th>
|
||||
<th className="py-2 pr-2 text-right">Can take on</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.weeks.map((w) => (
|
||||
<tr key={w.weekStart} className="border-b border-slate-100">
|
||||
<td className="py-1.5 pr-2 font-medium text-slate-800">{w.weekLabel}</td>
|
||||
<td className="py-1.5 pr-2 text-slate-500">{w.weekStart}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.activeAssets}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitingAssets}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitRatePct}%</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.weeklyThroughput}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.deptCapacityAssetsPerWeek}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{w.canTakeOn}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/ProjectTypeSummary.tsx
Normal file
197
frontend/src/pages/ProjectTypeSummary.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import Loading from '../components/Loading';
|
||||
import ErrorBox from '../components/ErrorBox';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import type { ProjectTypeStat, ProjectTypesResponse } from '../api/types';
|
||||
import { filtersToQuery } from '../lib/filters';
|
||||
|
||||
type SortKey = keyof ProjectTypeStat;
|
||||
|
||||
export default function ProjectTypeSummaryPage() {
|
||||
const { filters, timelog, projectSummary } = useDataContext();
|
||||
const [data, setData] = useState<ProjectTypesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('totalHours');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = filtersToQuery(filters);
|
||||
const res = await api.getProjectTypes({
|
||||
timelogHash: timelog.hash ?? undefined,
|
||||
projectSummaryHash: projectSummary.hash ?? undefined,
|
||||
brands: q.brands,
|
||||
divisions: q.divisions,
|
||||
hubs: q.hubs,
|
||||
userRoles: q.userRoles,
|
||||
});
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, timelog.hash, projectSummary.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const sortedStats = useMemo(() => {
|
||||
const stats = [...(data?.stats ?? [])];
|
||||
stats.sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
const cmp =
|
||||
typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return stats;
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
const handleSort = (k: SortKey) => {
|
||||
if (sortKey === k) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else {
|
||||
setSortKey(k);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = sortedStats.slice(0, 10).map((s) => ({
|
||||
label: s.projectType,
|
||||
hoursPerAsset: s.avgHoursPerAsset,
|
||||
projectCount: s.projectCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card">
|
||||
<h1 className="text-base font-semibold text-slate-800">Project Type Summary</h1>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Effort, duration and hour distribution per project type. Sort columns to find concentration
|
||||
risks and outliers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!timelog.hash && (
|
||||
<div className="card text-sm text-slate-600">
|
||||
Upload a time log to compute project-type benchmarks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <ErrorBox message={error} onRetry={load} />}
|
||||
{loading && <Loading label="Computing project-type stats…" />}
|
||||
|
||||
{!loading && !error && data && data.stats.length > 0 && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">Hours / asset by type</h3>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} angle={-15} textAnchor="end" height={60} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="hoursPerAsset" name="h/asset" fill="#6366f1" />
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="projectCount"
|
||||
name="# projects"
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" data-tutorial-id="project-type-table">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700">
|
||||
{data.totals.totalTypes} types · {data.totals.totalProjects} projects ·{' '}
|
||||
{data.totals.totalHours}h
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<Th k="projectType" l="Project type" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<Th k="projectCount" l="# projects" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<Th k="totalHours" l="Total hours" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<Th k="totalAssets" l="Total assets" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<Th k="avgHoursPerAsset" l="h/asset" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<Th k="avgDurationDays" l="Avg dur. (wd)" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<Th k="concentrationPct" l="Concent. %" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
|
||||
<th className="py-2">Insight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedStats.map((s) => (
|
||||
<tr key={s.projectType} className="border-b border-slate-100">
|
||||
<td className="py-1.5 pr-2 font-medium text-slate-800">{s.projectType}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.projectCount}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalHours}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalAssets}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgHoursPerAsset}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgDurationDays}</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">{s.concentrationPct}%</td>
|
||||
<td className="py-1.5 pr-2 text-xs text-slate-500">{s.autoInsight}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({
|
||||
k,
|
||||
l,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
right,
|
||||
}: {
|
||||
k: SortKey;
|
||||
l: string;
|
||||
sortKey: SortKey;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: (k: SortKey) => void;
|
||||
right?: boolean;
|
||||
}) {
|
||||
const arrow = sortKey === k ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '';
|
||||
return (
|
||||
<th
|
||||
onClick={() => onSort(k)}
|
||||
className={`cursor-pointer py-2 pr-2 hover:text-slate-700 select-none ${right ? 'text-right' : ''}`}
|
||||
>
|
||||
{l}
|
||||
{arrow}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import KpiTiles from '../components/KpiTiles';
|
||||
|
|
@ -11,8 +11,8 @@ import Loading from '../components/Loading';
|
|||
import ErrorBox from '../components/ErrorBox';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import { useAirtableData } from '../hooks/useAirtableData';
|
||||
import { useTimelog } from '../hooks/useTimelog';
|
||||
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import { filtersToQuery } from '../lib/filters';
|
||||
import { downloadCsv, rowsToCsv } from '../lib/csv';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
|
|
@ -20,8 +20,7 @@ import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/t
|
|||
|
||||
export default function Resourcing() {
|
||||
const airtable = useAirtableData(false);
|
||||
const tl = useTimelog();
|
||||
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
|
||||
const { filters, dispatch, timelog, dimensions } = useDataContext();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
|
||||
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
|
||||
|
|
@ -35,10 +34,6 @@ export default function Resourcing() {
|
|||
[airtable.resources],
|
||||
);
|
||||
|
||||
// Belt-and-braces: even though the backend now narrows bookings by
|
||||
// department/name server-side, Airtable's filterByFormula against linked
|
||||
// lookup fields can be flaky — so we keep the client-side filter to make
|
||||
// sure the chart stays consistent with the FilterBar.
|
||||
const visibleBookings = useMemo(() => {
|
||||
const deptSet = new Set(filters.departments);
|
||||
const nameSet = new Set(filters.names);
|
||||
|
|
@ -69,8 +64,12 @@ export default function Resourcing() {
|
|||
department: q.department,
|
||||
name: q.name,
|
||||
billing_type: q.billing_type,
|
||||
brands: q.brands,
|
||||
divisions: q.divisions,
|
||||
hubs: q.hubs,
|
||||
userRoles: q.userRoles,
|
||||
period: filters.period,
|
||||
timelogHash: tl.hash ?? undefined,
|
||||
timelogHash: timelog.hash ?? undefined,
|
||||
}),
|
||||
]);
|
||||
setBookings(bk.bookings);
|
||||
|
|
@ -82,7 +81,7 @@ export default function Resourcing() {
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filters, tl.hash],
|
||||
[filters, timelog.hash],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -112,6 +111,10 @@ export default function Resourcing() {
|
|||
departments={airtable.meta?.departments ?? []}
|
||||
names={names}
|
||||
billingTypes={airtable.meta?.billingTypes ?? []}
|
||||
brands={dimensions.brands}
|
||||
divisions={dimensions.divisions}
|
||||
hubs={dimensions.hubs}
|
||||
userRoles={dimensions.userRoles}
|
||||
showForecastToggle={false}
|
||||
/>
|
||||
|
||||
|
|
@ -174,7 +177,7 @@ export default function Resourcing() {
|
|||
period={selectedPeriod}
|
||||
from={filters.range?.from}
|
||||
to={filters.range?.to}
|
||||
timelogHash={tl.hash}
|
||||
timelogHash={timelog.hash}
|
||||
onClose={() => setSelectedPeriod(null)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
202
frontend/src/pages/TimeLogDetail.tsx
Normal file
202
frontend/src/pages/TimeLogDetail.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Loading from '../components/Loading';
|
||||
import ErrorBox from '../components/ErrorBox';
|
||||
import * as api from '../api/endpoints';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useDataContext } from '../hooks/useDataContext';
|
||||
import type { TimelogRow } from '../api/types';
|
||||
|
||||
const COLS: { key: keyof TimelogRow; label: string; cls?: string; numeric?: boolean }[] = [
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'submitter', label: 'Submitter' },
|
||||
{ key: 'userRole', label: 'Role' },
|
||||
{ key: 'brand', label: 'Brand' },
|
||||
{ key: 'division', label: 'Division' },
|
||||
{ key: 'hub', label: 'Hub' },
|
||||
{ key: 'hoursLogged', label: 'Hours', numeric: true },
|
||||
{ key: 'projectNumber', label: 'Project #' },
|
||||
{ key: 'projectTitle', label: 'Title' },
|
||||
{ key: 'projectType', label: 'Type' },
|
||||
{ key: 'projectBillingType', label: 'Billing' },
|
||||
{ key: 'taskDescription', label: 'Task' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const ROW_HEIGHT = 32;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
export default function TimeLogDetailPage() {
|
||||
const { timelog } = useDataContext();
|
||||
const [rows, setRows] = useState<TimelogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<keyof TimelogRow>('date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Virtualisation refs.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportH, setViewportH] = useState(480);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!timelog.hash) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getTimelogRows({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
sort: `${String(sortKey)}:${sortDir}`,
|
||||
timelogHash: timelog.hash,
|
||||
});
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.detail : (err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timelog.hash, page, search, sortKey, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setViewportH(el.clientHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSort = (k: keyof TimelogRow) => {
|
||||
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
else {
|
||||
setSortKey(k);
|
||||
setSortDir('desc');
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const endIndex = Math.min(rows.length, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN);
|
||||
const visible = rows.slice(startIndex, endIndex);
|
||||
const totalHeight = rows.length * ROW_HEIGHT;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card">
|
||||
<h1 className="text-base font-semibold text-slate-800">Time Log Detail</h1>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Every parsed time entry. Server-side search and sort across the upload — the table below
|
||||
is virtualised so it stays smooth at 100k+ rows.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="card space-y-2" data-tutorial-id="time-log-table">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search name, role, project, brand…"
|
||||
className="input max-w-xs"
|
||||
/>
|
||||
<span className="ml-auto text-xs text-slate-500">
|
||||
{total.toLocaleString()} entries · page {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBox message={error} onRetry={load} />}
|
||||
{loading && <Loading label="Loading rows…" />}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="grid border-b border-slate-200 bg-slate-50 px-2 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
||||
style={{ gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{COLS.map((c) => (
|
||||
<div
|
||||
key={String(c.key)}
|
||||
className={`cursor-pointer select-none ${c.numeric ? 'text-right' : ''}`}
|
||||
onClick={() => handleSort(c.key)}
|
||||
>
|
||||
{c.label}
|
||||
{sortKey === c.key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
|
||||
style={{ height: 540 }}
|
||||
className="relative overflow-auto"
|
||||
>
|
||||
<div style={{ height: totalHeight }}>
|
||||
<div style={{ transform: `translateY(${startIndex * ROW_HEIGHT}px)` }}>
|
||||
{visible.map((row, i) => (
|
||||
<div
|
||||
key={`${row.date ?? ''}-${row.submitter ?? ''}-${row.projectNumber ?? ''}-${startIndex + i}`}
|
||||
style={{ height: ROW_HEIGHT, gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
|
||||
className="grid items-center border-b border-slate-100 px-2 text-xs text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{COLS.map((c) => {
|
||||
const v = row[c.key];
|
||||
return (
|
||||
<div
|
||||
key={String(c.key)}
|
||||
className={`truncate ${c.numeric ? 'text-right tabular-nums' : ''}`}
|
||||
title={v == null ? '' : String(v)}
|
||||
>
|
||||
{v == null ? '' : c.key === 'hoursLogged' ? (v as number).toFixed(1) : String(v)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<div className="p-6 text-center text-sm text-slate-500">
|
||||
{timelog.hash ? 'No rows match the current search.' : 'Upload a time log to see rows.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +1,45 @@
|
|||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { allSteps, type TutorialSection } from '../components/tutorial/steps';
|
||||
import { globalSteps } from '../components/tutorial/steps';
|
||||
|
||||
const TutorialOverlay = lazy(() => import('../components/tutorial/TutorialOverlay'));
|
||||
|
||||
const SECTIONS: { key: TutorialSection; label: string; blurb: string }[] = [
|
||||
{
|
||||
key: 'department',
|
||||
label: 'Department',
|
||||
blurb: 'Upload your timelog, filter by department and see monthly utilisation, booking-vs-actual and billability.',
|
||||
},
|
||||
{
|
||||
key: 'resourcing',
|
||||
label: 'Resourcing',
|
||||
blurb: 'Weekly utilisation, per-person project load, and FTE-vs-Freelancer side-by-side.',
|
||||
},
|
||||
{
|
||||
key: 'bookings',
|
||||
label: 'Bookings',
|
||||
blurb: 'Virtualised booking table with cache info and an Airtable sync button.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Tutorial() {
|
||||
const [activeSection, setActiveSection] = useState<TutorialSection | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card">
|
||||
<h1 className="text-lg font-semibold text-slate-900">Tutorial — Interactive Walkthrough</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
A walkthrough of every tab. Use the chapter buttons to replay any section — the overlay only highlights
|
||||
elements that are currently visible, so navigate to the matching tab first, then click <em>Replay</em>.
|
||||
A 9-step tour of the entire dashboard. The overlay only highlights elements that exist on
|
||||
the current page — start the tour, then navigate to other tabs to continue. The original
|
||||
video walkthrough has been retired.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Note: the original Video Walkthrough has been retired; the interactive tour below covers the same ground.
|
||||
Jump to any step using the in-overlay arrow controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{SECTIONS.map((s) => (
|
||||
<div key={s.key} className="card flex flex-col">
|
||||
<h2 className="text-base font-semibold text-slate-800">{s.label}</h2>
|
||||
<p className="mt-1 flex-1 text-sm text-slate-600">{s.blurb}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection(s.key)}
|
||||
className="btn-primary mt-3 self-start"
|
||||
data-tutorial-id={`replay-${s.key}`}
|
||||
onClick={() => setRunning(true)}
|
||||
className="btn-primary mt-3"
|
||||
data-tutorial-id="start-walkthrough"
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden /> Replay
|
||||
<Play className="h-4 w-4" aria-hidden /> Start walkthrough
|
||||
</button>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-slate-500">
|
||||
{allSteps[s.key].map((step) => (
|
||||
<li key={`${s.key}-${step.selector}-${step.title}`}>
|
||||
<strong className="text-slate-700">{step.title}:</strong> {step.description}
|
||||
</section>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold text-slate-800">Chapter list</h2>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-600">
|
||||
{globalSteps.map((step) => (
|
||||
<li key={step.selector}>
|
||||
<strong className="text-slate-800">{step.title}:</strong> {step.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{activeSection && (
|
||||
{running && (
|
||||
<Suspense fallback={null}>
|
||||
<TutorialOverlay section={activeSection} onClose={() => setActiveSection(null)} />
|
||||
<TutorialOverlay section="global" onClose={() => setRunning(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue