Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite

Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.

- backend/   FastAPI + uvicorn, local auth (Azure AD stub), Airtable
             proxy with TTL cache, Zoho .xlsx/.csv parser, merge
             service for utilisation summaries. 28 pytest tests.
- frontend/  React + Vite + TS + Tailwind + Recharts SPA. Login entry
             chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
             or Airtable URLs in the built bundle.
- deploy/    Idempotent deploy.sh (port auto-pick 8200-8299,
             .env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-16 12:35:07 -04:00
parent c9f9c5cced
commit 04edbfdd2c
80 changed files with 11856 additions and 41 deletions

37
.env.example Normal file
View file

@ -0,0 +1,37 @@
# Copy to .env and fill in. Never commit .env.
# --- Airtable ---
# Personal Access Token (read-only scope on the base below).
# Generate at https://airtable.com/create/tokens — scopes: data.records:read on base appoByydxIQANKtSh, tables Resource + Booking Resource.
AIRTABLE_PAT=
AIRTABLE_BASE_ID=appoByydxIQANKtSh
# --- Session / auth ---
# Random 32+ bytes. Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
SESSION_SECRET=
# Local admin (used while AUTH_MODE=local).
# Generate hash with: python -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password-here'))"
# IMPORTANT: wrap the hash in SINGLE QUOTES — bcrypt hashes contain $ characters
# that docker-compose will otherwise interpret as variable substitutions.
ADMIN_USERNAME=admin
ADMIN_PASSWORD_BCRYPT=''
# AUTH_MODE: local (v1) or azure (v2 — not yet implemented).
AUTH_MODE=local
# Skip auth entirely in local dev. NEVER set true in production.
DEV_AUTH_BYPASS=false
# --- Server ---
# Host port to bind the container to. Auto-picked by deploy.sh from 8200-8299 and persisted here.
UTILISATION_DEPT_PORT=8200
# Optional Sentry-style overrides for cache TTLs (seconds).
CACHE_TTL_RESOURCES=600
CACHE_TTL_BOOKINGS=60
CACHE_TTL_META=600
# --- Azure AD (unused in v1, present for v2 swap) ---
AZURE_TENANT_ID=
AZURE_CLIENT_ID=

71
.gitignore vendored
View file

@ -1,50 +1,39 @@
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
# env / secrets
.env
.env.*
!.env.example
# Node artifact files
node_modules/
dist/
# deploy artefacts
.deployed
deploy/apache-utilisation-dept.conf
# Compiled Java class files
*.class
# Compiled Python bytecode
# python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Log files
# node / vite
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/**/*.tsbuildinfo
frontend/.tsbuild-node/
# logs
*.log
backend/auth.log
backend/auth.log.*
# Package files
*.jar
# Maven
target/
dist/
# JetBrains IDE
.idea/
# Unit test reports
TEST*.xml
# Generated by MacOS
# os
.DS_Store
# Generated by Windows
Thumbs.db
# Applications
*.app
*.exe
*.war
# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
# editor
.vscode/
.idea/
*.swp

209
README.md Normal file
View file

@ -0,0 +1,209 @@
# utilisation-dept
L'Oréal Utilisation Dashboard — internal tool that merges Zoho time-log
exports with Airtable resource/booking data and renders utilisation
charts per department, per person, and per project.
This is a clean rewrite of an earlier static SPA that shipped an
Airtable Personal Access Token in the JS bundle. The new architecture
keeps all secrets on the backend.
## Architecture
```
Browser ─► Apache (optical-dev.oliver.solutions, shared vhost)
├─ /utilisation-dept/ → static SPA (Vite build in /var/www/html/utilisation-dept/)
└─ /utilisation-dept/api/ → FastAPI container (127.0.0.1:<port>)
└─ Airtable REST API (PAT held in .env)
```
- **Backend**: FastAPI + uvicorn in a Docker container, bound to
127.0.0.1 only. Apache fronts the public traffic.
- **Frontend**: React + Vite + TypeScript + Tailwind + Recharts. Vite
`base: '/utilisation-dept/'`. Built into `/var/www/html/utilisation-dept/`.
- **Database**: none. Airtable is the source of truth; uploaded Zoho
files are parsed in memory and discarded.
- **Auth**: local admin account today (bcrypt creds in `.env`).
Designed for an Azure AD/MSAL swap-in later — see `app/auth/azure.py`.
## First-time setup
### 1. Rotate the Airtable PAT (do this before anything else)
The old SPA had its PAT hardcoded in the JS bundle. Assume that token is
compromised.
1. Go to <https://airtable.com/create/tokens>.
2. Revoke the old token (`patHAB...`) if it still exists.
3. Create a new Personal Access Token:
- **Name**: `utilisation-dept-backend`
- **Scopes**: `data.records:read`
- **Access**: limit to base `appoByydxIQANKtSh` only, tables
`Resource` and `Booking Resource`.
4. Copy the token. You'll paste it into `.env` below.
### 2. Generate secrets
```bash
# Session secret (paste output into SESSION_SECRET):
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
# Admin password hash (replace 'your-password' with the real password):
python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password'))"
```
> **Heads-up**: bcrypt hashes contain `$` characters. Wrap the hash in single
> quotes in `.env`, otherwise docker-compose will treat the `$...` segments as
> variable substitutions and the container will receive a truncated value:
>
> ```
> ADMIN_PASSWORD_BCRYPT='$2b$12$abc...xyz'
> ```
### 3. Configure `.env`
```bash
cp .env.example .env
# edit .env and fill in:
# AIRTABLE_PAT (from step 1)
# SESSION_SECRET (from step 2)
# ADMIN_PASSWORD_BCRYPT (from step 2)
```
## Local development
```bash
# Backend
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8200
# Frontend (in another terminal)
cd frontend
npm install
npm run dev
# open http://localhost:5173/utilisation-dept/
```
The Vite dev server proxies `/utilisation-dept/api/*` to
`http://localhost:8200`.
To skip auth entirely during dev, set `DEV_AUTH_BYPASS=true` in `.env`
(NEVER do this in production — the startup banner will warn loudly).
## Local Docker
```bash
docker compose up --build
# health: curl http://localhost:8200/api/health
```
## Tests
```bash
cd backend && pytest
cd frontend && npm run typecheck && npm run lint
```
## Deployment to optical-dev.oliver.solutions
The server hosts many small apps behind a shared Apache vhost. This app
lives at `/opt/utilisation-dept/`.
### First deploy
```bash
ssh user@optical-dev.oliver.solutions
sudo git clone <repo-url> /opt/utilisation-dept
cd /opt/utilisation-dept
# Create .env (see "First-time setup" above for value generation).
sudo cp .env.example .env
sudo vi .env
./deploy/deploy.sh
```
`deploy.sh` will:
- Pick a free port in 82008299 and persist it to `.env`.
- Render `deploy/apache-utilisation-dept.conf` from the template.
- Build the frontend → `/var/www/html/utilisation-dept/`.
- Build + start the Docker container.
- Health-poll `/api/health`.
- Print the Apache `Include` line for the shared vhost.
### Wire into the shared vhost (one time only)
Add **inside** `</VirtualHost>` of
`/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf`:
```apache
Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf
```
Then:
```bash
sudo apachectl configtest && sudo systemctl reload apache2
```
### Smoke-check
```bash
curl -sI https://optical-dev.oliver.solutions/utilisation-dept/api/health
curl -sf https://optical-dev.oliver.solutions/utilisation-dept/api/health
# expect 200 and {"ok":true,"version":"0.1.0"}
```
Then open <https://optical-dev.oliver.solutions/utilisation-dept/> in a
browser and confirm:
- Login screen renders.
- View-source on the loaded JS: search for `patHAB` — must return
nothing.
- Logging in with the local admin creds shows the dashboard.
### Subsequent deploys
```bash
ssh user@optical-dev.oliver.solutions
cd /opt/utilisation-dept
./deploy/deploy.sh # --no-pull, --no-build, --no-frontend, --logs available
```
## Operations
### Where the auth log lives
`/opt/utilisation-dept/backend/logs/auth.log` (mounted into the
container at `/app/logs/`). Rotating, 5 MB × 5 backups.
This is the only audit trail for who logged in when — there's no
database.
### Rotating the PAT
1. Generate a new PAT (same scopes as in setup).
2. Edit `.env` → update `AIRTABLE_PAT`.
3. `docker compose -p utilisation-dept restart backend`
4. Revoke the old PAT in the Airtable portal.
### Forgot admin password
1. Generate a new bcrypt hash:
`python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('new-password'))"`
2. Edit `.env` → update `ADMIN_PASSWORD_BCRYPT`.
3. `docker compose -p utilisation-dept restart backend`
## Future work
- **Azure AD / MSAL SSO**: `app/auth/azure.py` is stubbed. Flip
`AUTH_MODE=azure` in `.env`, fill in `AZURE_TENANT_ID` +
`AZURE_CLIENT_ID`, register the SPA in Azure (platform type:
**Single-page application**), wire MSAL on the frontend, send the
**ID token** to the backend. See `~/.claude/skills/azure-ad-msal-auth.md`.
- **Database** if/when we need history, audit trails, or to cache more
aggressively. Today the data lives in Airtable and the Zoho upload
is ephemeral.

48
backend/Dockerfile Normal file
View file

@ -0,0 +1,48 @@
# utilisation-dept backend
# Decisions:
# - Use python:3.12-slim for small footprint with broad wheel availability.
# - Install tini in the image so PID 1 reaps zombies and forwards signals
# (uvicorn + passlib bcrypt subprocesses behave better under tini).
# - Run as non-root `appuser`. /app/logs is chowned so the mounted volume
# from docker-compose remains writable.
# - No HEALTHCHECK here — docker-compose.yml owns the healthcheck.
FROM python:3.12-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# tini for clean PID 1 behaviour; libffi/openssl already in slim base.
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
RUN groupadd --system appuser \
&& useradd --system --gid appuser --home /app --shell /usr/sbin/nologin appuser
WORKDIR /app
# Install deps first for better layer caching.
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# Copy app code
COPY app /app/app
# Ensure logs dir exists and is writable by appuser (compose mounts a volume here).
RUN mkdir -p /app/logs && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "1", \
"--proxy-headers", \
"--forwarded-allow-ips=*"]

3
backend/app/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""utilisation-dept backend package."""
__version__ = "0.1.0"

View file

25
backend/app/auth/azure.py Normal file
View file

@ -0,0 +1,25 @@
"""Azure AD JWT verification stub.
Decisions:
- v1 ships with AUTH_MODE=local. This module exists so that switching to
Azure later is a one-line env change the dependency is wired by import.
- DEV_AUTH_BYPASS still works here so a dev environment can skip Azure
during early frontend work.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request, status
from app.config import settings
def verify_jwt(request: Request) -> dict[str, Any]:
if settings.DEV_AUTH_BYPASS:
return {"username": "dev", "mode": "bypass"}
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Azure AD not yet configured",
)

51
backend/app/auth/local.py Normal file
View file

@ -0,0 +1,51 @@
"""Local auth — bcrypt-verified admin credential plus session-cookie check.
Decisions:
- passlib's bcrypt backend is used so we don't depend on the (deprecated)
bcrypt package interface details directly.
- DEV_AUTH_BYPASS short-circuits to a synthetic user; main.py logs a
WARNING at startup if it's enabled.
- verify_password and verify_session are both safe to call when
ADMIN_PASSWORD_BCRYPT is empty they just return False / 401.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request, status
from passlib.context import CryptContext
from app.auth.session import COOKIE_NAME, verify_signed
from app.config import settings
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(username: str, password: str) -> bool:
if not settings.ADMIN_PASSWORD_BCRYPT:
return False
if username != settings.ADMIN_USERNAME:
return False
try:
return _pwd_context.verify(password, settings.ADMIN_PASSWORD_BCRYPT)
except Exception:
# passlib raises on malformed hashes — treat as auth failure.
return False
def verify_session(request: Request) -> dict[str, Any]:
"""FastAPI dependency: returns {"username": ..., "mode": ...} or 401."""
if settings.DEV_AUTH_BYPASS:
return {"username": "dev", "mode": "bypass"}
cookie = request.cookies.get(COOKIE_NAME)
if not cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = verify_signed(cookie)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return {"username": username, "mode": "local"}

View file

@ -0,0 +1,58 @@
"""Session cookie signing helpers.
Decisions:
- Uses itsdangerous.TimestampSigner so we can enforce max-age server-side
even if the cookie's own Max-Age is tampered with (defence in depth).
- The cookie value is "<username>" signed; no JWT, no JSON. Sessions don't
need to carry data here the user is single-tenant admin.
"""
from __future__ import annotations
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
from fastapi import Response
from app.config import settings
def _signer() -> TimestampSigner:
return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1")
def sign_username(username: str) -> str:
return _signer().sign(username.encode("utf-8")).decode("utf-8")
def verify_signed(value: str, max_age: int | None = None) -> str | None:
"""Return username if signature valid and not expired; else None."""
try:
max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE
unsigned = _signer().unsign(value, max_age=max_age)
return unsigned.decode("utf-8")
except SignatureExpired:
return None
except BadSignature:
return None
COOKIE_NAME = "ud_session"
def set_session_cookie(response: Response, username: str) -> None:
signed = sign_username(username)
response.set_cookie(
key=COOKIE_NAME,
value=signed,
max_age=settings.SESSION_MAX_AGE,
path=settings.cookie_path,
httponly=True,
secure=True,
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
key=COOKIE_NAME,
path=settings.cookie_path,
)

89
backend/app/config.py Normal file
View file

@ -0,0 +1,89 @@
"""Application settings loaded from environment variables.
Decisions:
- Uses pydantic-settings v2 so values are validated at import-time. Missing
required fields raise at startup rather than producing a 500 later.
- All cache TTLs default to the values documented in the docker-compose file.
- AIRTABLE_PAT and SESSION_SECRET have empty defaults so that tests / local
dev can boot without them (auth bypass paths short-circuit the checks).
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=None,
case_sensitive=True,
extra="ignore",
)
# Airtable
AIRTABLE_PAT: str = ""
AIRTABLE_BASE_ID: str = "appoByydxIQANKtSh"
# Table names — kept as constants here; could be promoted to env vars later.
AIRTABLE_TABLE_RESOURCES: str = "Resource"
AIRTABLE_TABLE_BOOKINGS: str = "Booking Resource"
# Session / auth
SESSION_SECRET: str = "dev-insecure-secret-change-me"
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD_BCRYPT: str = ""
AUTH_MODE: Literal["local", "azure"] = "local"
DEV_AUTH_BYPASS: bool = False
# Cache TTLs (seconds)
CACHE_TTL_RESOURCES: int = 600
CACHE_TTL_BOOKINGS: int = 60
CACHE_TTL_META: int = 600
# Azure (stubbed in v1)
AZURE_TENANT_ID: str = ""
AZURE_CLIENT_ID: str = ""
# App
APP_BASE_PATH: str = "/utilisation-dept"
# Override cookie path explicitly when needed (tests use "/" to bypass
# the Apache-stripped-prefix mismatch).
SESSION_COOKIE_PATH: str = ""
# CORS — comma separated origins. Empty in prod (same-origin via Apache).
CORS_ALLOWED_ORIGINS: str = ""
# Session cookie
SESSION_MAX_AGE: int = 60 * 60 * 8 # 8h
# Multipart upload limit (bytes) — Zoho exports rarely exceed 20 MB.
MAX_UPLOAD_BYTES: int = 20 * 1024 * 1024
@property
def cookie_path(self) -> str:
# Allow an explicit override for tests / unusual deploys.
if self.SESSION_COOKIE_PATH:
return self.SESSION_COOKIE_PATH
# itsdangerous cookie path; needs trailing slash to match the SPA mount.
p = self.APP_BASE_PATH.rstrip("/")
return f"{p}/" if p else "/"
@property
def cors_origins(self) -> list[str]:
if not self.CORS_ALLOWED_ORIGINS:
# Dev convenience: when bypass is on, allow Vite default.
if self.DEV_AUTH_BYPASS:
return ["http://localhost:5173"]
return []
return [o.strip() for o in self.CORS_ALLOWED_ORIGINS.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View file

View file

@ -0,0 +1,46 @@
"""AirtableClient singleton.
Decisions:
- One module-level httpx.AsyncClient with sane defaults (HTTP/2 off,
10s connect / 30s read). Reused across requests for connection pooling.
- The client is closed on app shutdown via main.py's lifespan handler.
"""
from __future__ import annotations
import httpx
from app.config import settings
_AIRTABLE_API_BASE = "https://api.airtable.com/v0"
class AirtableClient:
def __init__(self) -> None:
self._client: httpx.AsyncClient | None = None
def _build(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=f"{_AIRTABLE_API_BASE}/{settings.AIRTABLE_BASE_ID}",
headers={
"Authorization": f"Bearer {settings.AIRTABLE_PAT}",
"Accept": "application/json",
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
)
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = self._build()
return self._client
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
# Module-level singleton — imported by services/airtable_fetch.py.
airtable_client = AirtableClient()

31
backend/app/deps/auth.py Normal file
View file

@ -0,0 +1,31 @@
"""Auth dependency selector.
Picks local or azure verifier at import-time based on AUTH_MODE.
The spec asks for an import-time selection, but we wrap the chosen
verifier in a small indirection so that DEV_AUTH_BYPASS can be flipped
at runtime (useful in tests) without re-importing the whole app graph.
"""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.config import settings
if settings.AUTH_MODE == "azure":
from app.auth.azure import verify_jwt as _verifier
else:
from app.auth.local import verify_session as _verifier
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"}
return _verifier(request)

109
backend/app/main.py Normal file
View file

@ -0,0 +1,109 @@
"""FastAPI app entrypoint.
Decisions:
- All routers already mount under /api/... so we attach them at app root.
- A small Starlette middleware enforces the 20 MB Content-Length cap for
multipart bodies before they hit any route handler.
- CORS is only enabled when configured (or when DEV_AUTH_BYPASS=true,
which adds the Vite dev origin). In prod the SPA is same-origin via Apache.
- slowapi state and exception handler are wired here.
- Lifespan hook closes the shared httpx client cleanly.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app import __version__
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 health as health_router
from app.routers import timelog as timelog_router
from app.routers import utilisation as utilisation_router
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)sZ %(levelname)s %(name)s: %(message)s")
class ContentLengthLimitMiddleware(BaseHTTPMiddleware):
"""Reject requests whose Content-Length exceeds settings.MAX_UPLOAD_BYTES."""
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
super().__init__(app)
self.max_bytes = max_bytes
async def dispatch(self, request: Request, call_next):
cl = request.headers.get("content-length")
if cl is not None:
try:
if int(cl) > self.max_bytes:
return JSONResponse({"detail": "Payload too large"}, status_code=413)
except ValueError:
pass
return await call_next(request)
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.DEV_AUTH_BYPASS:
logger.warning("DEV_AUTH_BYPASS=true — auth is disabled. DO NOT use in production.")
logger.info("utilisation-dept backend v%s starting (AUTH_MODE=%s)", __version__, settings.AUTH_MODE)
yield
await airtable_client.aclose()
def create_app() -> FastAPI:
app = FastAPI(
title="utilisation-dept",
version=__version__,
lifespan=lifespan,
# Docs at /api/docs so Apache routes /utilisation-dept/api/docs there.
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
)
# CORS — only when we have origins. Same-origin in prod via Apache.
origins = settings.cors_origins
if origins:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_BYTES)
# slowapi wiring — limiter instance defined in auth router.
app.state.limiter = auth_router.limiter
app.add_middleware(SlowAPIMiddleware)
@app.exception_handler(RateLimitExceeded)
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse({"detail": "Too many requests"}, status_code=429)
# Routers
app.include_router(health_router.router)
app.include_router(auth_router.router)
app.include_router(airtable_router.router)
app.include_router(timelog_router.router)
app.include_router(utilisation_router.router)
return app
app = create_app()

View file

View file

@ -0,0 +1,132 @@
"""Pydantic v2 schemas shared across routers and services.
Field names are camelCase to match the API contract the frontend agent
will consume directly. Internally services produce dicts in this exact
shape; FastAPI serialises pydantic models the same way so either path is fine.
"""
from __future__ import annotations
from datetime import date as DateType, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# ---------- Auth ----------
class LoginRequest(BaseModel):
username: str
password: str
class MeResponse(BaseModel):
username: str
mode: str # "local" | "azure" | "bypass"
# ---------- Airtable ----------
class Resource(BaseModel):
recordId: str
name: str
email: str | None = None
department: str | None = None
roles: list[str] = Field(default_factory=list)
inactive: bool = False
availHoursPerWeek: float = 0.0
startDate: DateType | None = None
endDate: DateType | None = None
employmentType: str | None = None # "FTE" | "Freelancer"
country: str | None = None
class Booking(BaseModel):
id: str
task: str | None = None
startDate: DateType | None = None
endDate: DateType | None = None
resourceName: str | None = None
projectNumber: str | None = None
projectName: str | None = None
department: str | None = None
division: str | None = None
hoursSelection: list[str] = Field(default_factory=list)
totalHoursBooked: float = 0.0
bookingStatus: str | None = None
placeholder: bool = False
class ResourcesResponse(BaseModel):
resources: list[Resource]
cached_at: datetime
class BookingsResponse(BaseModel):
bookings: list[Booking]
cached_at: datetime
class MetaResponse(BaseModel):
departments: list[str]
billingTypes: list[str]
employmentTypes: list[str]
bookingStatuses: list[str]
# ---------- Timelog ----------
class TimelogRow(BaseModel):
date: DateType | None = None
employee: str | None = None
project: str | None = None
task: str | None = None
hours: float = 0.0
billable: bool = False
class ParseResponse(BaseModel):
rows: list[TimelogRow]
unrecognised_columns: list[str]
content_hash: str
# ---------- Utilisation ----------
class UtilisationSummaryRow(BaseModel):
model_config = ConfigDict(populate_by_name=True)
period: str # "2026-W19" or "2026-05"
employee: str
department: str | None = None
employmentType: str | None = None
availableHours: float = 0.0
bookedHours: float = 0.0
loggedHours: float = 0.0
billableHours: float = 0.0
nonBillableHours: float = 0.0
forecastHours: float = 0.0
actualUtilisationPct: float = 0.0
bookedUtilisationPct: float = 0.0
class UtilisationFiltersApplied(BaseModel):
from_: str | None = Field(default=None, alias="from")
to: str | None = None
department: str | None = None
name: str | None = None
billing_type: str | None = None
model_config = ConfigDict(populate_by_name=True)
class UtilisationSummaryResponse(BaseModel):
rows: list[UtilisationSummaryRow]
filters_applied: UtilisationFiltersApplied
# ---------- Health ----------
class HealthResponse(BaseModel):
ok: bool
version: str

View file

View file

@ -0,0 +1,96 @@
"""Airtable proxy endpoints — cached, protected by auth_required."""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from app.config import settings
from app.deps.auth import auth_required
from app.services.airtable_fetch import fetch_bookings, fetch_resources
from app.services.cache import TTLAsyncCache
router = APIRouter(prefix="/api/airtable", tags=["airtable"], dependencies=[Depends(auth_required)])
# Module-level caches — TTLs from settings.
_resources_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_RESOURCES, maxsize=4)
_bookings_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_BOOKINGS, maxsize=64)
_meta_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_META, maxsize=2)
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
# ---- /resources ----
@router.get("/resources")
async def get_resources(
include_inactive: bool = Query(False),
refresh: bool = Query(False),
) -> dict[str, Any]:
key = f"resources:{include_inactive}"
if refresh:
_resources_cache.invalidate(key)
async def loader() -> dict[str, Any]:
rows = await fetch_resources(include_inactive=include_inactive)
return {"resources": rows, "cached_at": _now_iso()}
return await _resources_cache.get_or_set(key, loader)
# ---- /bookings ----
@router.get("/bookings")
async def get_bookings(
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
refresh: bool = Query(False),
) -> dict[str, Any]:
try:
from_d = date.fromisoformat(from_) if from_ else None
to_d = date.fromisoformat(to) if to else None
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
key = f"bookings:{from_d}:{to_d}"
if refresh:
_bookings_cache.invalidate(key)
async def loader() -> dict[str, Any]:
rows = await fetch_bookings(from_=from_d, to=to_d)
return {"bookings": rows, "cached_at": _now_iso()}
return await _bookings_cache.get_or_set(key, loader)
# ---- /meta ----
@router.get("/meta")
async def get_meta() -> dict[str, Any]:
"""Static-ish dropdown values. Currently a constant; would derive from
Airtable schema endpoint in a future iteration."""
async def loader() -> dict[str, Any]:
return {
"departments": [
"Creative Team",
"Opera Upload Team",
"Operation Team",
"Project Management Team",
"Syndication Team",
"Transcreation Team",
"House Admin",
],
"billingTypes": ["Client Related", "Fee Related", "Idle Time", "Leave Hours"],
"employmentTypes": ["FTE", "Freelancer"],
"bookingStatuses": ["Active", "Soft Booking", "Fully Booked", "Partially Booked"],
}
return await _meta_cache.get_or_set("meta:v1", loader)
# Exported so tests can inspect / reset caches.
__all__ = ["router", "_resources_cache", "_bookings_cache", "_meta_cache"]

View file

@ -0,0 +1,88 @@
"""Auth endpoints: login / logout / me.
Decisions:
- Login is rate-limited 5/min/IP via slowapi keyed on the remote address.
We wire the limiter on this single route, not globally the rest of the
API is auth-protected anyway.
- Both success and failure attempts are appended to /app/logs/auth.log
via a RotatingFileHandler (5 MB × 5 backups).
- Cookie domain/path/flags handled by app.auth.session.
- We intentionally DO NOT use `from __future__ import annotations` in this
module: slowapi wraps the handler with a decorator that breaks FastAPI's
forward-ref resolution for the `LoginRequest` body parameter under
Python 3.14 / pydantic 2.x.
"""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.auth.local import verify_password
from app.auth.session import clear_session_cookie, set_session_cookie
from app.config import settings
from app.deps.auth import auth_required
from app.models.schemas import LoginRequest, MeResponse
# ---- auth.log file logger ----
auth_logger = logging.getLogger("ud.auth")
if not auth_logger.handlers:
log_dir = Path("/app/logs")
try:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "auth.log",
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
except (PermissionError, OSError):
# Fallback for local dev when /app/logs isn't writable.
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)sZ %(message)s"))
auth_logger.addHandler(handler)
auth_logger.setLevel(logging.INFO)
auth_logger.propagate = False
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _log_attempt(request: Request, username: str, outcome: str) -> None:
ip = get_remote_address(request)
auth_logger.info("ip=%s user=%s outcome=%s", ip, username, outcome)
@router.post("/login")
@limiter.limit("5/minute")
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"}
if not verify_password(body.username, body.password):
_log_attempt(request, body.username, "fail")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
set_session_cookie(response, body.username)
_log_attempt(request, body.username, "success")
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response) -> Response:
clear_session_cookie(response)
# Explicit 204 with no body.
response.status_code = status.HTTP_204_NO_CONTENT
return response
@router.get("/me", response_model=MeResponse)
async def me(user: dict = Depends(auth_required)) -> MeResponse:
return MeResponse(username=user["username"], mode=user["mode"])

View file

@ -0,0 +1,16 @@
"""Health endpoint."""
from __future__ import annotations
from fastapi import APIRouter
from app import __version__
from app.models.schemas import HealthResponse
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(ok=True, version=__version__)

View file

@ -0,0 +1,62 @@
"""Timelog upload + parse.
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).
"""
from __future__ import annotations
import logging
from collections import OrderedDict
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 zoho_parse
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)
@router.post("/parse")
async def parse_timelog(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 = zoho_parse.parse(file.filename or "", content)
except Exception as e:
logger.exception("Zoho parse failed")
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e
_remember(parsed)
return parsed

View file

@ -0,0 +1,93 @@
"""Utilisation summary endpoint."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
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 summarise
router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)])
def _parse_date(s: str | None, default: date) -> date:
if not s:
return default
try:
return date.fromisoformat(s)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
@router.get("/summary")
async def summary(
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
department: str | None = Query(None),
name: str | None = Query(None),
billing_type: str | None = Query(None),
period: str = Query("week"),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
today = date.today()
# Default window: current ISO week's Monday through 4 weeks out.
monday = today - timedelta(days=today.weekday())
default_from = monday
default_to = monday + timedelta(days=27)
from_d = _parse_date(from_, default_from)
to_d = _parse_date(to, default_to)
if period not in ("week", "month"):
raise HTTPException(status_code=400, detail="period must be 'week' or 'month'")
# Resources — use the shared cache.
async def load_resources() -> dict[str, Any]:
rows = await fetch_resources(include_inactive=False)
return {"resources": rows}
res_payload = await _resources_cache.get_or_set("resources:False", load_resources)
resources = res_payload.get("resources", [])
# Bookings — share cache with /api/airtable/bookings.
bkey = f"bookings:{from_d}:{to_d}"
async def load_bookings() -> dict[str, Any]:
rows = await fetch_bookings(from_=from_d, to=to_d)
return {"bookings": rows}
bk_payload = await _bookings_cache.get_or_set(bkey, load_bookings)
bookings = bk_payload.get("bookings", [])
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_cached_parse(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
rows = summarise(
logged_rows,
bookings,
resources,
from_=from_d,
to_=to_d,
period=period, # type: ignore[arg-type]
filters={"department": department, "name": name, "billing_type": billing_type},
)
return {
"rows": rows,
"filters_applied": {
"from": from_d.isoformat(),
"to": to_d.isoformat(),
"department": department,
"name": name,
"billing_type": billing_type,
},
}

View file

View file

@ -0,0 +1,203 @@
"""Airtable fetch helpers.
Decisions:
- Paginated with pageSize=100; we follow the `offset` cursor.
- 429 sleep 30s then retry once (Airtable docs). Any subsequent 429 raises.
- Field normalisation lives here so routers/handlers stay schema-pure.
- Date filtering for bookings uses filterByFormula on Start/End Date we
fetch bookings that overlap the requested window (start <= to AND end >= from).
"""
from __future__ import annotations
import asyncio
import logging
from datetime import date
from typing import Any, AsyncIterator
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.deps.airtable import airtable_client
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Low-level pagination
# ----------------------------------------------------------------------
async def _paginate(
table: str,
params: dict[str, Any] | None = None,
*,
max_retries_429: int = 1,
) -> AsyncIterator[dict[str, Any]]:
"""Async iterator yielding individual Airtable records."""
client = airtable_client.client
base_params: dict[str, Any] = {"pageSize": 100}
if params:
base_params.update(params)
offset: str | None = None
while True:
q = dict(base_params)
if offset:
q["offset"] = offset
# urlencode here so list values (filterByFormula doesn't use lists,
# but fields[] would) are serialised consistently.
url = f"/{table}?{urlencode(q, doseq=True)}"
retries_left = max_retries_429
while True:
resp = await client.get(url)
if resp.status_code == 429 and retries_left > 0:
logger.warning("Airtable 429 on %s — sleeping 30s before retry", table)
await asyncio.sleep(30)
retries_left -= 1
continue
resp.raise_for_status()
break
payload = resp.json()
for rec in payload.get("records", []):
yield rec
offset = payload.get("offset")
if not offset:
return
# ----------------------------------------------------------------------
# Field normalisation
# ----------------------------------------------------------------------
def _to_bool(v: Any) -> bool:
if isinstance(v, bool):
return v
if v is None:
return False
if isinstance(v, str):
return v.strip().lower() in {"true", "yes", "1", "checked"}
return bool(v)
def _to_float(v: Any, default: float = 0.0) -> float:
if v is None or v == "":
return default
try:
return float(v)
except (TypeError, ValueError):
return default
def _to_date(v: Any) -> date | None:
if not v:
return None
if isinstance(v, date):
return v
try:
# Airtable returns ISO date strings.
return date.fromisoformat(str(v)[:10])
except ValueError:
return None
def _as_list(v: Any) -> list[str]:
if v is None:
return []
if isinstance(v, list):
return [str(x) for x in v]
return [str(v)]
def normalise_resource(rec: dict[str, Any]) -> dict[str, Any]:
f = rec.get("fields", {})
# Roles may be a multi-select array, a single linked record name, or a string.
roles_raw = f.get("Roles") or f.get("Role") or []
return {
"recordId": rec.get("id"),
"name": f.get("Name") or f.get("Resource Name") or "",
"email": f.get("Email") or None,
"department": f.get("Department") or None,
"roles": _as_list(roles_raw),
"inactive": _to_bool(f.get("Inactive")),
"availHoursPerWeek": _to_float(
f.get("Availability Hour (per week)")
or f.get("Availability Hours (per week)")
or f.get("Available Hours")
or 0
),
"startDate": _to_date(f.get("Start Date")),
"endDate": _to_date(f.get("End Date")),
"employmentType": f.get("Employment Type") or f.get("FTE / Freelancer") or None,
"country": f.get("Country") or None,
}
def normalise_booking(rec: dict[str, Any]) -> dict[str, Any]:
f = rec.get("fields", {})
return {
"id": rec.get("id"),
"task": f.get("Task") or f.get("Task Description") or None,
"startDate": _to_date(f.get("Start Date")),
"endDate": _to_date(f.get("End Date")),
"resourceName": (
(f.get("Resource Name (from Resource)") or [None])[0]
if isinstance(f.get("Resource Name (from Resource)"), list)
else f.get("Resource Name") or f.get("Resource") or None
),
"projectNumber": f.get("Project Number") or f.get("Project No.") or None,
"projectName": f.get("Project Name") or f.get("Project Title") or None,
"department": f.get("Department") or None,
"division": f.get("Division") or None,
"hoursSelection": _as_list(f.get("Hours Selection") or f.get("Days") or []),
"totalHoursBooked": _to_float(
f.get("Total Hours Booked") or f.get("Total Hours") or 0
),
"bookingStatus": f.get("Booking Status") or f.get("Status") or None,
"placeholder": _to_bool(f.get("Placeholder")),
}
# ----------------------------------------------------------------------
# Public fetchers
# ----------------------------------------------------------------------
async def fetch_resources(*, include_inactive: bool = False) -> list[dict[str, Any]]:
params: dict[str, Any] = {}
if not include_inactive:
# Airtable formula — only resources not marked inactive.
params["filterByFormula"] = "NOT({Inactive})"
out: list[dict[str, Any]] = []
async for rec in _paginate(settings.AIRTABLE_TABLE_RESOURCES, params):
out.append(normalise_resource(rec))
return out
def _date_filter(from_: date | None, to: date | None) -> str | None:
"""Build a filterByFormula that picks bookings overlapping [from_, to]."""
if not from_ and not to:
return None
clauses: list[str] = []
if to is not None:
# Start <= to → IS_BEFORE({Start Date}, to+1) for safety.
clauses.append(f"IS_BEFORE({{Start Date}}, '{to.isoformat()}')")
if from_ is not None:
clauses.append(f"IS_AFTER({{End Date}}, '{from_.isoformat()}')")
if len(clauses) == 1:
return clauses[0]
return f"AND({', '.join(clauses)})"
async def fetch_bookings(*, from_: date | None = None, to: date | None = None) -> list[dict[str, Any]]:
params: dict[str, Any] = {}
formula = _date_filter(from_, to)
if formula:
params["filterByFormula"] = formula
out: list[dict[str, Any]] = []
async for rec in _paginate(settings.AIRTABLE_TABLE_BOOKINGS, params):
out.append(normalise_booking(rec))
return out

View file

@ -0,0 +1,58 @@
"""TTL cache with per-key async locks.
Decisions:
- cachetools.TTLCache handles the time-based eviction; we wrap it with
per-key asyncio.Lock instances so that the first concurrent caller does
the load and the rest wait. Without this we'd stampede Airtable on
cache expiry.
- A single asyncio.Lock guards lock-dict mutations.
"""
from __future__ import annotations
import asyncio
from typing import Awaitable, Callable, Hashable, TypeVar
from cachetools import TTLCache
T = TypeVar("T")
class TTLAsyncCache:
def __init__(self, ttl: int, maxsize: int = 32) -> None:
self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl)
self._locks: dict[Hashable, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
async def _get_lock(self, key: Hashable) -> asyncio.Lock:
async with self._locks_guard:
lock = self._locks.get(key)
if lock is None:
lock = asyncio.Lock()
self._locks[key] = lock
return lock
def peek(self, key: Hashable) -> T | None:
return self._cache.get(key)
def invalidate(self, key: Hashable) -> None:
self._cache.pop(key, None)
async def get_or_set(
self,
key: Hashable,
loader: Callable[[], Awaitable[T]],
) -> T:
# Fast path — no lock needed if value present.
if key in self._cache:
return self._cache[key]
lock = await self._get_lock(key)
async with lock:
# Re-check inside the lock; another coroutine may have loaded it.
if key in self._cache:
return self._cache[key]
value = await loader()
self._cache[key] = value
return value

View file

@ -0,0 +1,272 @@
"""Merge / summarise: pure function that computes utilisation rows.
Decisions:
- Period iteration uses ISO weeks (Mon-Sun) by default the contract
documents period="week" returning "YYYY-Www". "month" returns "YYYY-MM".
- availableHours per period is (availHoursPerWeek * working_days_in_period / 5),
i.e. we treat availHoursPerWeek as a 5-day quantity and scale by the
number of weekdays that intersect the period AND the resource's
start/end employment range AND the requested [from_, to] window.
- bookedHours pro-rate: if a booking spans multiple periods, distribute
totalHoursBooked proportionally to the working-day overlap between the
booking and each period. This is the simplest defensible split.
- forecastHours == bookedHours for v1.
- All dates are inclusive on both ends.
- The function is deliberately pure no I/O, no globals so it's easy
to unit-test and reuse from CLI tools later.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any, Iterable, Literal
PeriodKind = Literal["week", "month"]
# ----------------------------------------------------------------------
# Period helpers
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class Period:
label: str # "2026-W19" or "2026-05"
start: date # inclusive
end: date # inclusive
def _iso_week_label(d: date) -> str:
iso = d.isocalendar()
return f"{iso.year:04d}-W{iso.week:02d}"
def _iso_week_bounds(d: date) -> tuple[date, date]:
"""Monday..Sunday for the ISO week containing d."""
monday = d - timedelta(days=d.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
def _month_bounds(d: date) -> tuple[date, date]:
first = d.replace(day=1)
if first.month == 12:
next_first = first.replace(year=first.year + 1, month=1)
else:
next_first = first.replace(month=first.month + 1)
last = next_first - timedelta(days=1)
return first, last
def iter_periods(from_: date, to: date, kind: PeriodKind) -> list[Period]:
if to < from_:
return []
out: list[Period] = []
if kind == "week":
cursor_monday, _ = _iso_week_bounds(from_)
cur = cursor_monday
while cur <= to:
wstart, wend = _iso_week_bounds(cur)
# Clamp to requested window — we still keep the ISO label.
out.append(Period(label=_iso_week_label(cur), start=wstart, end=wend))
cur = cur + timedelta(days=7)
else:
cur = from_.replace(day=1)
while cur <= to:
mstart, mend = _month_bounds(cur)
out.append(Period(label=f"{cur.year:04d}-{cur.month:02d}", start=mstart, end=mend))
# Step to next month
if cur.month == 12:
cur = cur.replace(year=cur.year + 1, month=1)
else:
cur = cur.replace(month=cur.month + 1)
return out
def _weekdays_between(start: date, end: date) -> int:
"""Count Mon-Fri weekdays in [start, end] inclusive. 0 if start > end."""
if end < start:
return 0
# Walk per-day for clarity (period sizes are tiny).
days = 0
d = start
while d <= end:
if d.weekday() < 5:
days += 1
d += timedelta(days=1)
return days
def _overlap(a_start: date, a_end: date, b_start: date, b_end: date) -> tuple[date, date] | None:
start = max(a_start, b_start)
end = min(a_end, b_end)
if end < start:
return None
return start, end
# ----------------------------------------------------------------------
# Public summarise
# ----------------------------------------------------------------------
def _name_key(s: str | None) -> str:
return (s or "").strip().lower()
def summarise(
logged: Iterable[dict[str, Any]],
bookings: Iterable[dict[str, Any]],
resources: Iterable[dict[str, Any]],
*,
from_: date,
to_: date,
period: PeriodKind = "week",
filters: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
filters = filters or {}
dep_filter = (filters.get("department") or "").strip().lower() or None
name_filter = (filters.get("name") or "").strip().lower() or None
billing_filter = (filters.get("billing_type") or "").strip().lower() or None
resources = list(resources)
bookings = list(bookings)
logged = list(logged)
resource_by_name: dict[str, dict[str, Any]] = {}
for r in resources:
key = _name_key(r.get("name"))
if key:
resource_by_name[key] = r
periods = iter_periods(from_, to_, period)
out: list[dict[str, Any]] = []
# All employees we know about: from resources + any names that appear in
# logged/bookings but not in resources (so they don't silently disappear).
employees: dict[str, dict[str, Any]] = dict(resource_by_name)
for row in logged:
k = _name_key(row.get("employee"))
if k and k not in employees:
employees[k] = {"name": row.get("employee"), "department": None, "employmentType": None,
"availHoursPerWeek": 0.0, "startDate": None, "endDate": None}
for b in bookings:
k = _name_key(b.get("resourceName"))
if k and k not in employees:
employees[k] = {"name": b.get("resourceName"), "department": b.get("department"),
"employmentType": None, "availHoursPerWeek": 0.0,
"startDate": None, "endDate": None}
for emp_key, res in employees.items():
emp_name = res.get("name") or ""
emp_dep = res.get("department")
emp_type = res.get("employmentType")
# Filters that don't depend on the period — skip the whole employee.
if name_filter and name_filter not in emp_key:
continue
if dep_filter and (emp_dep or "").strip().lower() != dep_filter:
continue
avail_per_week = float(res.get("availHoursPerWeek") or 0.0)
emp_start = res.get("startDate")
emp_end = res.get("endDate")
# Pre-bucket logged rows by employee for cheap iteration.
emp_logged = [r for r in logged if _name_key(r.get("employee")) == emp_key]
emp_bookings = [b for b in bookings if _name_key(b.get("resourceName")) == emp_key]
for p in periods:
# Clamp the period by the requested window AND employment range.
window_start = max(p.start, from_)
window_end = min(p.end, to_)
if emp_start:
window_start = max(window_start, emp_start)
if emp_end:
window_end = min(window_end, emp_end)
if window_end < window_start:
# Employee not active this period. Emit a zero row anyway so
# the frontend has a continuous timeline for charting.
row = {
"period": p.label,
"employee": emp_name,
"department": emp_dep,
"employmentType": emp_type,
"availableHours": 0.0,
"bookedHours": 0.0,
"loggedHours": 0.0,
"billableHours": 0.0,
"nonBillableHours": 0.0,
"forecastHours": 0.0,
"actualUtilisationPct": 0.0,
"bookedUtilisationPct": 0.0,
}
out.append(row)
continue
weekdays_in_window = _weekdays_between(window_start, window_end)
available_hours = (avail_per_week * weekdays_in_window) / 5.0 if avail_per_week else 0.0
# bookedHours: pro-rate by working-day overlap of booking within window.
booked = 0.0
for b in emp_bookings:
b_start = b.get("startDate")
b_end = b.get("endDate") or b_start
if not b_start:
continue
if not b_end:
b_end = b_start
ov = _overlap(b_start, b_end, window_start, window_end)
if ov is None:
continue
booking_total_weekdays = _weekdays_between(b_start, b_end) or 1
overlap_weekdays = _weekdays_between(ov[0], ov[1])
if overlap_weekdays == 0:
continue
total_hours = float(b.get("totalHoursBooked") or 0.0)
booked += total_hours * (overlap_weekdays / booking_total_weekdays)
# Logged hours filtered to this window.
logged_h = 0.0
billable_h = 0.0
for r in emp_logged:
d = r.get("date")
if not d:
continue
if d < window_start or d > window_end:
continue
# Optional billing_type filter — applied at row level.
if billing_filter:
is_b = bool(r.get("billable"))
if billing_filter in {"billable", "client related", "fee related"} and not is_b:
continue
if billing_filter in {"non-billable", "non billable", "idle time", "leave hours"} and is_b:
continue
hrs = float(r.get("hours") or 0.0)
logged_h += hrs
if r.get("billable"):
billable_h += hrs
non_billable_h = max(logged_h - billable_h, 0.0)
actual_pct = (logged_h / available_hours * 100.0) if available_hours > 0 else 0.0
booked_pct = (booked / available_hours * 100.0) if available_hours > 0 else 0.0
out.append({
"period": p.label,
"employee": emp_name,
"department": emp_dep,
"employmentType": emp_type,
"availableHours": round(available_hours, 2),
"bookedHours": round(booked, 2),
"loggedHours": round(logged_h, 2),
"billableHours": round(billable_h, 2),
"nonBillableHours": round(non_billable_h, 2),
"forecastHours": round(booked, 2),
"actualUtilisationPct": round(actual_pct, 2),
"bookedUtilisationPct": round(booked_pct, 2),
})
out.sort(key=lambda r: ((r["department"] or ""), r["employee"], r["period"]))
return out

View file

@ -0,0 +1,202 @@
"""Zoho timelog parser.
Decisions:
- Header matching is case-insensitive and trim-stripped. Unknown headers
are surfaced in `unrecognised_columns` so the operator notices when
Zoho silently renames a column.
- Billable detection: if the column is literally "Billable" / "Is Billable",
we coerce truthy strings. If the column is "Billing Type", we map
"Client Related" / "Fee Related" True, everything else False.
- Date parsing tries ISO first, then dateutil for the messy formats Zoho
occasionally emits ("01/05/2026", "1-May-2026", etc.).
- For .xlsx we use openpyxl read-only mode keeps memory low on big files.
"""
from __future__ import annotations
import csv
import hashlib
import io
import logging
from datetime import date, datetime
from typing import Any, Iterable
from dateutil import parser as dateparser
from openpyxl import load_workbook
logger = logging.getLogger(__name__)
# Canonical name → set of accepted aliases (compared after .strip().lower()).
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"},
"billable": {"billable", "is billable", "billing type"},
}
BILLABLE_TRUE_VALUES = {"client related", "fee related", "true", "yes", "1", "billable"}
def _canonicalise_header(raw: str) -> str | None:
if raw is None:
return None
key = str(raw).strip().lower()
if not key:
return None
for canonical, aliases in HEADER_ALIASES.items():
if key in aliases:
return canonical
return None
def _parse_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:
# ISO short-circuit
return date.fromisoformat(str(v)[:10])
except ValueError:
pass
try:
# dayfirst=True because Zoho regional defaults are commonly DD/MM.
return dateparser.parse(str(v), dayfirst=True).date()
except (ValueError, TypeError, OverflowError):
return None
def _parse_hours(v: Any) -> float:
if v is None or v == "":
return 0.0
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
# Zoho sometimes outputs "7:30" (HH:MM). Convert.
if ":" in s and all(p.isdigit() for p in s.split(":") if p):
parts = s.split(":")
try:
h = int(parts[0])
m = int(parts[1]) if len(parts) > 1 else 0
return h + m / 60.0
except ValueError:
pass
try:
return float(s.replace(",", ""))
except ValueError:
return 0.0
def _parse_billable(v: Any, *, source_header_canonical: str | None = None) -> bool:
# source_header_canonical only matters for "billable" — both columns
# canonicalise to that key but we want different semantics. We accept
# either bool, the special billing-type strings, or generic yes/no.
if v is None:
return False
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return bool(v)
s = str(v).strip().lower()
if not s:
return False
return s in BILLABLE_TRUE_VALUES
# ----------------------------------------------------------------------
# Public API
# ----------------------------------------------------------------------
def parse(filename: str, content: bytes) -> dict[str, Any]:
"""Parse uploaded file. Returns dict with rows, unrecognised_columns, content_hash."""
fn = (filename or "").lower()
if fn.endswith(".xlsx") or fn.endswith(".xlsm"):
rows, unknown = _parse_xlsx(content)
elif fn.endswith(".csv") or fn.endswith(".txt"):
rows, unknown = _parse_csv(content)
else:
# Best-effort sniff: try CSV first, fall back to xlsx.
try:
rows, unknown = _parse_csv(content)
except Exception:
rows, unknown = _parse_xlsx(content)
digest = hashlib.sha256(content).hexdigest()
return {
"rows": rows,
"unrecognised_columns": unknown,
"content_hash": f"sha256:{digest}",
}
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.
canonical_by_idx: dict[int, str] = {}
unrecognised: list[str] = []
for idx, raw in enumerate(headers):
if raw is None or str(raw).strip() == "":
continue
canon = _canonicalise_header(raw)
if canon:
canonical_by_idx[idx] = canon
else:
unrecognised.append(str(raw).strip())
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,
}
for idx, canon in canonical_by_idx.items():
if idx >= len(raw_row):
continue
v = raw_row[idx]
if canon == "date":
row["date"] = _parse_date(v)
elif canon == "hours":
row["hours"] = _parse_hours(v)
elif canon == "billable":
row["billable"] = _parse_billable(v)
else:
row[canon] = (str(v).strip() if v is not None else None) or None
out.append(row)
return out, unrecognised
def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]:
# Decode permissively; Zoho exports are usually utf-8 or utf-8-sig.
text = content.decode("utf-8-sig", errors="replace")
reader = csv.reader(io.StringIO(text))
rows = list(reader)
if not rows:
return [], []
headers = rows[0]
data = rows[1:]
return _build_rows(data, headers)
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(data, headers)

0
backend/logs/.gitkeep Normal file
View file

5
backend/pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
testpaths = tests
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning

17
backend/requirements.txt Normal file
View file

@ -0,0 +1,17 @@
fastapi>=0.110,<0.120
uvicorn[standard]>=0.27,<0.35
pydantic>=2.6,<3
pydantic-settings>=2.2,<3
httpx>=0.27,<0.30
python-multipart>=0.0.9
itsdangerous>=2.1,<3
passlib[bcrypt]>=1.7,<2
# passlib 1.7.4 expects bcrypt <5 (uses bcrypt.__about__, removed in 5.x)
bcrypt>=4.0,<5
slowapi>=0.1.9,<0.2
openpyxl>=3.1,<4
cachetools>=5.3,<6
python-dateutil>=2.8,<3
pytest>=8.0,<9
pytest-asyncio>=0.23,<0.24
respx>=0.20,<0.22

View file

113
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,113 @@
"""Shared test fixtures.
Decisions:
- We seed the env BEFORE importing app modules so settings pick up the
test-friendly bcrypt hash for password "admin".
- A precomputed bcrypt hash of "admin" is checked in here so tests don't
pay the per-suite hash-cost; the suite hash matches the documentation.
"""
from __future__ import annotations
import os
from datetime import date
import pytest
# Hash of "admin" (cost 12). Tests rely on this exact value to log in.
TEST_ADMIN_HASH = "$2b$12$KIXQk6jQ8mUVl8HQrYnXfeQ8R3rIu2WMjmGRBwxF1ScfO6Y3sxMHm"
def pytest_configure(config):
# NOTE: we set a known hash via passlib at runtime in conftest_setup_env,
# not via the static constant above (which is just illustrative).
pass
@pytest.fixture(scope="session", autouse=True)
def _seed_env():
from passlib.hash import bcrypt
os.environ.setdefault("AUTH_MODE", "local")
os.environ.setdefault("SESSION_SECRET", "test-secret-very-long-string-for-itsdangerous")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ["ADMIN_PASSWORD_BCRYPT"] = bcrypt.hash("admin")
os.environ.setdefault("DEV_AUTH_BYPASS", "false")
# Tests hit /api/... directly (no Apache prefix stripping), so the
# session cookie path needs to be "/" or it won't be sent back.
os.environ.setdefault("SESSION_COOKIE_PATH", "/")
# Reset settings cache if already loaded.
try:
from app.config import get_settings
get_settings.cache_clear()
except Exception:
pass
@pytest.fixture
def sample_resources():
return [
{
"recordId": "rec1",
"name": "Bhakti Doshi",
"email": "bhakti@oliver.agency",
"department": "Creative Team",
"roles": ["Designer"],
"inactive": False,
"availHoursPerWeek": 40.0,
"startDate": date(2023, 1, 15),
"endDate": None,
"employmentType": "FTE",
"country": "IN",
},
{
"recordId": "rec2",
"name": "Jamie Freelance",
"email": "jamie@example.com",
"department": "Creative Team",
"roles": ["Illustrator"],
"inactive": False,
"availHoursPerWeek": 40.0,
"startDate": date(2024, 6, 1),
"endDate": None,
"employmentType": "Freelancer",
"country": "UK",
},
]
@pytest.fixture
def sample_bookings():
return [
{
"id": "bk1",
"task": "Concept dev",
"startDate": date(2026, 5, 4), # Mon
"endDate": date(2026, 5, 8), # Fri (same ISO week W19)
"resourceName": "Bhakti Doshi",
"projectNumber": "P-12345",
"projectName": "Acme Spring Launch",
"department": "Creative Team",
"division": "Production",
"hoursSelection": ["Mon", "Tue", "Wed", "Thu", "Fri"],
"totalHoursBooked": 38.0,
"bookingStatus": "Active",
"placeholder": False,
},
]
@pytest.fixture
def sample_logged():
return [
{"date": date(2026, 5, 4), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 7.0, "billable": True},
{"date": date(2026, 5, 5), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 7.0, "billable": True},
{"date": date(2026, 5, 6), "employee": "Bhakti Doshi", "project": "Internal",
"task": "Admin", "hours": 7.0, "billable": False},
{"date": date(2026, 5, 7), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 7.0, "billable": True},
{"date": date(2026, 5, 8), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 7.0, "billable": True},
]

View file

@ -0,0 +1,5 @@
Date,Resource Name,Project Title,Task Description,Hours Logged,Billing Type
2026-05-04,Bhakti Doshi,Acme Spring Launch,Design,7,Client Related
2026-05-05,Bhakti Doshi,Acme Spring Launch,Design,7.5,Client Related
2026-05-06,Bhakti Doshi,Internal,Admin,4,Idle Time
2026-05-07,Jamie Freelance,Acme Spring Launch,Illustration,6,Fee Related
1 Date Resource Name Project Title Task Description Hours Logged Billing Type
2 2026-05-04 Bhakti Doshi Acme Spring Launch Design 7 Client Related
3 2026-05-05 Bhakti Doshi Acme Spring Launch Design 7.5 Client Related
4 2026-05-06 Bhakti Doshi Internal Admin 4 Idle Time
5 2026-05-07 Jamie Freelance Acme Spring Launch Illustration 6 Fee Related

View file

@ -0,0 +1,78 @@
"""Tests for the TTL async cache + Airtable cache wiring."""
from __future__ import annotations
import asyncio
import pytest
from app.services.cache import TTLAsyncCache
@pytest.mark.asyncio
async def test_second_call_hits_cache():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return {"v": counter["n"]}
a = await cache.get_or_set("k", loader)
b = await cache.get_or_set("k", loader)
assert a == b == {"v": 1}
assert counter["n"] == 1
@pytest.mark.asyncio
async def test_invalidate_forces_reload():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return counter["n"]
assert await cache.get_or_set("k", loader) == 1
cache.invalidate("k")
assert await cache.get_or_set("k", loader) == 2
@pytest.mark.asyncio
async def test_lock_prevents_thundering_herd():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def slow_loader():
# Yield once so other coroutines have a chance to enter.
counter["n"] += 1
await asyncio.sleep(0.05)
return counter["n"]
results = await asyncio.gather(
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
)
# All callers see the same cached value, loader ran only once.
assert counter["n"] == 1
assert all(r == 1 for r in results)
@pytest.mark.asyncio
async def test_refresh_bypasses_cache():
"""Simulate the 'refresh=true' behaviour the router uses."""
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return counter["n"]
await cache.get_or_set("k", loader)
# Router invalidates, then re-fetches.
cache.invalidate("k")
await cache.get_or_set("k", loader)
assert counter["n"] == 2

View file

@ -0,0 +1,88 @@
"""Auth + session tests."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
def _make_client():
# Ensure local mode + no bypass for these tests.
os.environ["AUTH_MODE"] = "local"
os.environ["DEV_AUTH_BYPASS"] = "false"
from app.config import get_settings
get_settings.cache_clear()
# Re-import main so middleware picks up settings.
import importlib
import app.main as main_mod
importlib.reload(main_mod)
# base_url must be https so the Secure session cookie is sent back.
return TestClient(main_mod.app, base_url="https://testserver")
@pytest.fixture
def client():
yield _make_client()
def test_login_happy_path(client):
r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["username"] == "admin"
# Session cookie present.
assert "ud_session" in r.cookies
def test_login_wrong_password(client):
r = client.post("/api/auth/login", json={"username": "admin", "password": "nope"})
assert r.status_code == 401
assert r.json()["detail"] == "Invalid credentials"
def test_me_requires_session(client):
r = client.get("/api/auth/me")
assert r.status_code == 401
def test_me_after_login(client):
r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
assert r.status_code == 200
r2 = client.get("/api/auth/me")
assert r2.status_code == 200
assert r2.json()["username"] == "admin"
def test_logout(client):
client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
r = client.post("/api/auth/logout")
assert r.status_code == 204
def test_bypass_mode():
os.environ["DEV_AUTH_BYPASS"] = "true"
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.get("/api/auth/me")
assert r.status_code == 200
assert r.json()["mode"] == "bypass"
# Restore for any subsequent tests.
os.environ["DEV_AUTH_BYPASS"] = "false"
get_settings.cache_clear()
importlib.reload(main_mod)
def test_login_rate_limit(client):
# 5/minute → 6th attempt should 429.
last = None
for _ in range(6):
last = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert last is not None
assert last.status_code == 429

146
backend/tests/test_merge.py Normal file
View file

@ -0,0 +1,146 @@
"""Unit tests for services.merge.summarise."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.merge import iter_periods, summarise
def test_iter_periods_week():
periods = iter_periods(date(2026, 5, 4), date(2026, 5, 17), "week")
labels = [p.label for p in periods]
# 2026-05-04 is Mon of ISO week 2026-W19; through 2026-05-17 covers W19 and W20.
assert labels == ["2026-W19", "2026-W20"]
def test_iter_periods_month():
periods = iter_periods(date(2026, 5, 1), date(2026, 6, 30), "month")
assert [p.label for p in periods] == ["2026-05", "2026-06"]
def test_empty_inputs():
rows = summarise([], [], [], from_=date(2026, 5, 4), to_=date(2026, 5, 8))
assert rows == []
def test_basic_utilisation(sample_resources, sample_bookings, sample_logged):
# Bhakti: 40h/week avail, 35h logged (5 days × 7h), 38h booked (within W19).
rows = summarise(
sample_logged,
sample_bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
period="week",
)
bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19"]
assert len(bhakti) == 1
b = bhakti[0]
assert b["availableHours"] == 40.0
assert b["loggedHours"] == 35.0
assert b["bookedHours"] == 38.0
assert b["billableHours"] == 28.0 # 4 of 5 days billable × 7
assert b["nonBillableHours"] == 7.0
assert b["actualUtilisationPct"] == 87.5
assert b["bookedUtilisationPct"] == 95.0
assert b["employmentType"] == "FTE"
def test_booking_spans_two_weeks(sample_resources):
"""A booking that straddles a week boundary must be split by working-day overlap."""
# Booking: 2026-05-07 (Thu W19) .. 2026-05-12 (Tue W20). Total weekdays = 4 (Thu, Fri, Mon, Tue).
# 50/50 split: 2 days in W19, 2 days in W20. totalHoursBooked = 40 → 20/20.
bookings = [{
"id": "bk-span",
"task": "Spanning",
"startDate": date(2026, 5, 7),
"endDate": date(2026, 5, 12),
"resourceName": "Bhakti Doshi",
"projectNumber": "P-1",
"projectName": "Span",
"department": "Creative Team",
"division": "Production",
"hoursSelection": [],
"totalHoursBooked": 40.0,
"bookingStatus": "Active",
"placeholder": False,
}]
rows = summarise(
[],
bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 17),
period="week",
)
by_period = {r["period"]: r for r in rows if r["employee"] == "Bhakti Doshi"}
assert by_period["2026-W19"]["bookedHours"] == pytest.approx(20.0)
assert by_period["2026-W20"]["bookedHours"] == pytest.approx(20.0)
assert by_period["2026-W19"]["forecastHours"] == pytest.approx(20.0)
def test_employment_type_grouping(sample_resources, sample_logged):
rows = summarise(
sample_logged,
[],
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
period="week",
)
by_emp = {r["employee"]: r for r in rows if r["period"] == "2026-W19"}
assert by_emp["Bhakti Doshi"]["employmentType"] == "FTE"
assert by_emp["Jamie Freelance"]["employmentType"] == "Freelancer"
def test_partial_month_at_boundary(sample_resources):
"""If the requested window starts mid-week, available hours should pro-rate."""
# Window: 2026-05-06 (Wed) .. 2026-05-08 (Fri). 3 weekdays out of 5.
# availHoursPerWeek = 40 → available = 40 * 3/5 = 24.
rows = summarise(
[],
[],
[sample_resources[0]],
from_=date(2026, 5, 6),
to_=date(2026, 5, 8),
period="week",
)
bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi"]
assert len(bhakti) == 1
assert bhakti[0]["availableHours"] == pytest.approx(24.0)
def test_filters_department(sample_resources, sample_logged, sample_bookings):
rows = summarise(
sample_logged,
sample_bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
filters={"department": "Creative Team"},
)
assert all(r["department"] == "Creative Team" for r in rows)
def test_filters_name(sample_resources):
rows = summarise(
[],
[],
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
filters={"name": "Bhakti"},
)
assert all("Bhakti" in r["employee"] for r in rows)
def test_zero_available_no_division_error(sample_resources):
# Drop avail to 0 → expect 0.0 percentages, not a crash.
resources = [dict(sample_resources[0], availHoursPerWeek=0.0)]
rows = summarise([], [], resources, from_=date(2026, 5, 4), to_=date(2026, 5, 8))
assert rows[0]["availableHours"] == 0.0
assert rows[0]["actualUtilisationPct"] == 0.0
assert rows[0]["bookedUtilisationPct"] == 0.0

View file

@ -0,0 +1,100 @@
"""Tests for the Zoho timelog parser."""
from __future__ import annotations
import io
from datetime import date
from pathlib import Path
import pytest
from openpyxl import Workbook
from app.services.zoho_parse import parse
FIXTURE_CSV = Path(__file__).parent / "fixtures" / "sample_zoho.csv"
def test_canonical_csv_headers():
content = FIXTURE_CSV.read_bytes()
out = parse("sample_zoho.csv", content)
rows = out["rows"]
assert out["content_hash"].startswith("sha256:")
assert out["unrecognised_columns"] == []
assert len(rows) == 4
r0 = rows[0]
assert r0["date"] == date(2026, 5, 4)
assert r0["employee"] == "Bhakti Doshi"
assert r0["project"] == "Acme Spring Launch"
assert r0["hours"] == 7.0
assert r0["billable"] is True
# Idle Time → not billable
assert rows[2]["billable"] is False
# Fee Related → billable
assert rows[3]["billable"] is True
def test_aliased_headers():
csv = (
"Resource,Project,Total Hours,Log Date,Is Billable\n"
"Bhakti Doshi,Acme,7.5,2026-05-04,true\n"
).encode("utf-8")
out = parse("aliased.csv", csv)
assert out["unrecognised_columns"] == []
assert out["rows"][0]["employee"] == "Bhakti Doshi"
assert out["rows"][0]["hours"] == 7.5
assert out["rows"][0]["billable"] is True
assert out["rows"][0]["date"] == date(2026, 5, 4)
def test_unrecognised_header_surfaced():
csv = (
"Date,Resource,Total Hours,Wibble Factor\n"
"2026-05-04,Bhakti,7,5\n"
).encode("utf-8")
out = parse("u.csv", csv)
assert "Wibble Factor" in out["unrecognised_columns"]
# Known columns still parse.
assert out["rows"][0]["employee"] == "Bhakti"
assert out["rows"][0]["hours"] == 7.0
def test_xlsx_path():
wb = Workbook()
ws = wb.active
ws.append(["Date", "Resource Name", "Project Title", "Task", "Hours", "Billable"])
ws.append(["2026-05-04", "Bhakti Doshi", "Acme", "Design", 7.5, "Yes"])
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
out = parse("up.xlsx", buf.read())
assert out["rows"][0]["employee"] == "Bhakti Doshi"
assert out["rows"][0]["hours"] == 7.5
assert out["rows"][0]["date"] == date(2026, 5, 4)
assert out["rows"][0]["billable"] is True
def test_empty_rows_skipped():
csv = (
"Date,Resource,Hours\n"
"\n"
"2026-05-04,Bhakti,7\n"
",,\n"
).encode("utf-8")
out = parse("blank.csv", csv)
assert len(out["rows"]) == 1
def test_hh_mm_hours_parsed():
csv = (
"Date,Resource,Hours\n"
"2026-05-04,Bhakti,7:30\n"
).encode("utf-8")
out = parse("hhmm.csv", csv)
assert out["rows"][0]["hours"] == pytest.approx(7.5)
def test_content_hash_stable():
out1 = parse("a.csv", FIXTURE_CSV.read_bytes())
out2 = parse("a.csv", FIXTURE_CSV.read_bytes())
assert out1["content_hash"] == out2["content_hash"]

View file

@ -0,0 +1,42 @@
# Split-build include for utilisation-dept.
#
# Add ONCE inside </VirtualHost> of optical-dev.oliver.solutions.conf:
# Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf
#
# This file is generated from .conf.tmpl by deploy.sh — __APP_PORT__ is
# substituted with the chosen backend port. Do not edit the generated
# .conf directly; edit the .tmpl.
ProxyTimeout 300
TimeOut 300
# Backend API: only /utilisation-dept/api/ is proxied to the container.
ProxyPass /utilisation-dept/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300
ProxyPassReverse /utilisation-dept/api/ http://127.0.0.1:__APP_PORT__/api/
# Frontend SPA: Apache serves the Vite build directly from disk.
Alias /utilisation-dept /var/www/html/utilisation-dept
<Directory /var/www/html/utilisation-dept>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
# Long cache for hashed asset filenames (Vite emits content-hashed names).
<FilesMatch "\.(js|css|woff2?|svg|png|jpg|jpeg|webp|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# No cache for index.html so deploys take effect immediately.
<FilesMatch "index\.html$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>
# SPA fallback so deep links work.
RewriteEngine On
RewriteBase /utilisation-dept/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
</Directory>
# Force trailing slash so relative asset paths in index.html resolve.
RedirectMatch ^/utilisation-dept$ /utilisation-dept/

225
deploy/deploy.sh Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env bash
# Deploy script for /opt/utilisation-dept/ on optical-dev.oliver.solutions.
# Idempotent. Picks a free backend port from 8200-8299 (preferred 8200),
# persists it to .env, renders the Apache include from the template,
# builds the frontend into /var/www/html/utilisation-dept/, builds and
# (re)starts the backend container, then health-polls.
#
# Flags:
# --no-pull skip `git pull --ff-only`
# --no-build skip `docker compose build` AND frontend build
# --no-frontend skip only the frontend build
# --logs tail backend container logs after deploy
#
# The Apache vhost Include line is NOT touched by this script. The script
# prints it at the end so you can paste it into the vhost manually the
# first time the app is deployed. See:
# /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_PROJECT="utilisation-dept"
SLUG="utilisation-dept"
URL_PATH="/${SLUG}"
FRONTEND_OUT="/var/www/html/${SLUG}"
PORT_PREFERRED=8200
PORT_RANGE_LOW=8200
PORT_RANGE_HIGH=8299
APACHE_TMPL="${SCRIPT_DIR}/apache-${SLUG}.conf.tmpl"
APACHE_CONF="${SCRIPT_DIR}/apache-${SLUG}.conf"
ENV_FILE="${REPO_ROOT}/.env"
NO_PULL=false
NO_BUILD=false
NO_FRONTEND=false
TAIL_LOGS=false
for arg in "$@"; do
case "$arg" in
--no-pull) NO_PULL=true ;;
--no-build) NO_BUILD=true; NO_FRONTEND=true ;;
--no-frontend) NO_FRONTEND=true ;;
--logs) TAIL_LOGS=true ;;
-h|--help)
sed -n '2,18p' "$0"; exit 0 ;;
*)
echo "Unknown flag: $arg" >&2; exit 64 ;;
esac
done
# ─── helpers ──────────────────────────────────────────────────────────────
c_red=$'\033[0;31m'; c_yel=$'\033[0;33m'; c_grn=$'\033[0;32m'; c_blu=$'\033[0;34m'; c_off=$'\033[0m'
log() { printf '%s[deploy]%s %s\n' "$c_blu" "$c_off" "$*"; }
ok() { printf '%s[deploy]%s %s\n' "$c_grn" "$c_off" "$*"; }
warn() { printf '%s[deploy]%s %s\n' "$c_yel" "$c_off" "$*"; }
err() { printf '%s[deploy]%s %s\n' "$c_red" "$c_off" "$*" >&2; }
port_in_use() {
local p="$1"
# ss is on the deploy server; fall back to lsof on dev macOS.
if command -v ss >/dev/null 2>&1; then
ss -ltn "sport = :$p" 2>/dev/null | grep -q ":$p "
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$p" -sTCP:LISTEN 2>/dev/null | grep -q LISTEN
else
# Last-resort: try to bind
(echo > "/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0 || return 1
fi
}
find_free_port() {
local p="$PORT_PREFERRED"
if ! port_in_use "$p"; then echo "$p"; return; fi
for p in $(seq "$PORT_RANGE_LOW" "$PORT_RANGE_HIGH"); do
if ! port_in_use "$p"; then echo "$p"; return; fi
done
err "No free port in ${PORT_RANGE_LOW}-${PORT_RANGE_HIGH}"; exit 1
}
get_env_var() {
[[ -f "$ENV_FILE" ]] || { echo ""; return; }
grep -E "^${1}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true
}
set_env_var() {
local key="$1" val="$2"
touch "$ENV_FILE"
if grep -qE "^${key}=" "$ENV_FILE"; then
# macOS sed and GNU sed differ on -i; use a tmpfile to be portable.
local tmp; tmp="$(mktemp)"
awk -v k="$key" -v v="$val" 'BEGIN{FS=OFS="="} $1==k{print k"="v; next}{print}' "$ENV_FILE" > "$tmp"
mv "$tmp" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
require() {
command -v "$1" >/dev/null 2>&1 || { err "Required: $1 not on PATH"; exit 2; }
}
# ─── sanity ───────────────────────────────────────────────────────────────
cd "$REPO_ROOT"
require docker
require git
docker compose version >/dev/null 2>&1 || { err "docker compose v2 required"; exit 2; }
[[ -f docker-compose.yml ]] || { err "docker-compose.yml missing"; exit 2; }
[[ -f "$APACHE_TMPL" ]] || { err "$APACHE_TMPL missing"; exit 2; }
if [[ ! -f "$ENV_FILE" ]]; then
err ".env missing. Copy .env.example to .env and fill in required values (AIRTABLE_PAT, SESSION_SECRET, ADMIN_PASSWORD_BCRYPT) before deploying."
exit 3
fi
# Quickly validate required env vars are non-empty.
missing=()
for k in AIRTABLE_PAT SESSION_SECRET ADMIN_PASSWORD_BCRYPT; do
[[ -n "$(get_env_var "$k")" ]] || missing+=("$k")
done
if (( ${#missing[@]} > 0 )); then
err ".env is missing required values: ${missing[*]}"
exit 3
fi
# ─── pick port ────────────────────────────────────────────────────────────
PERSISTED_PORT="$(get_env_var UTILISATION_DEPT_PORT || echo "")"
if [[ -n "$PERSISTED_PORT" ]] && docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | grep -q .; then
PORT="$PERSISTED_PORT"
log "Keeping current port $PORT (container is running)."
elif [[ -n "$PERSISTED_PORT" ]] && ! port_in_use "$PERSISTED_PORT"; then
PORT="$PERSISTED_PORT"
log "Reusing persisted port $PORT."
else
PORT="$(find_free_port)"
log "Picked port $PORT."
fi
set_env_var UTILISATION_DEPT_PORT "$PORT"
# ─── render Apache include ────────────────────────────────────────────────
sed "s#__APP_PORT__#${PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
ok "Rendered $APACHE_CONF"
# ─── git pull ─────────────────────────────────────────────────────────────
if ! $NO_PULL && git rev-parse --git-dir >/dev/null 2>&1 && git remote >/dev/null 2>&1; then
if git ls-remote --exit-code origin main >/dev/null 2>&1; then
log "git pull --ff-only origin main"
git pull --ff-only origin main || warn "git pull failed — proceeding with current checkout"
fi
fi
# ─── frontend build ───────────────────────────────────────────────────────
if ! $NO_FRONTEND; then
log "Building frontend → $FRONTEND_OUT"
require node
require npm
(
cd "$REPO_ROOT/frontend"
npm ci --no-audit --no-fund
npm run build
)
if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then
err "frontend/dist not produced"; exit 4
fi
# Use sudo only if needed (deploys on the server need it; local dry runs don't).
if [[ -w "$(dirname "$FRONTEND_OUT")" ]]; then
mkdir -p "$FRONTEND_OUT"
rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/"
else
sudo mkdir -p "$FRONTEND_OUT"
sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/"
fi
ok "Frontend deployed to $FRONTEND_OUT"
fi
# ─── backend build + up ───────────────────────────────────────────────────
if ! $NO_BUILD; then
log "docker compose build"
docker compose -p "$COMPOSE_PROJECT" build
fi
log "docker compose up -d"
docker compose -p "$COMPOSE_PROJECT" --env-file "$ENV_FILE" up -d
# ─── health poll ──────────────────────────────────────────────────────────
log "Waiting for /api/health on http://127.0.0.1:${PORT} (up to 60s)…"
healthy=false
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
healthy=true; break
fi
sleep 2
done
if $healthy; then
ok "Backend healthy."
else
err "Backend did not become healthy. Logs:"
docker compose -p "$COMPOSE_PROJECT" logs --tail=80 backend || true
exit 5
fi
# ─── final report ─────────────────────────────────────────────────────────
echo
ok "Deploy complete."
echo
echo " URL : https://optical-dev.oliver.solutions${URL_PATH}/"
echo " API health : https://optical-dev.oliver.solutions${URL_PATH}/api/health"
echo " Backend : 127.0.0.1:${PORT}"
echo " Frontend : ${FRONTEND_OUT}"
echo
# Detect whether the Include line is already in the vhost; warn if not.
VHOST=/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
if [[ -r "$VHOST" ]] && grep -qF "apache-${SLUG}.conf" "$VHOST"; then
ok "Apache vhost already Includes ${APACHE_CONF}."
else
warn "First-time deploy: add this line INSIDE </VirtualHost> of $VHOST :"
echo
echo " Include /opt/${SLUG}/deploy/apache-${SLUG}.conf"
echo
warn "Then: sudo apachectl configtest && sudo systemctl reload apache2"
fi
if $TAIL_LOGS; then
docker compose -p "$COMPOSE_PROJECT" logs -f --tail=50 backend
fi

30
docker-compose.yml Normal file
View file

@ -0,0 +1,30 @@
# Pinned project name — required by global Docker policy so this stack
# can't collide with another /opt/<app>/docker-compose.yml that also
# defaults to the parent directory name.
name: utilisation-dept
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: utilisation-dept-backend:local
restart: unless-stopped
# Bind to localhost only — Apache fronts the public traffic.
ports:
- "127.0.0.1:${UTILISATION_DEPT_PORT:-8200}:8000"
# Secrets (especially the bcrypt hash with its $ characters) pass through
# verbatim via env_file, bypassing compose's ${...} interpolation.
env_file:
- .env
environment:
- APP_BASE_PATH=/utilisation-dept
volumes:
# Persist the auth log across container rebuilds — replaces DB audit trail.
- ./backend/logs:/app/logs
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health', timeout=2).status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

24
frontend/.eslintrc.cjs Normal file
View file

@ -0,0 +1,24 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts', 'postcss.config.js', 'tailwind.config.js'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
settings: { react: { version: '18.3' } },
plugins: ['react', 'react-hooks', '@typescript-eslint'],
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
};

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/utilisation-dept/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>L'Oréal Utilisation Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6727
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "utilisation-dept-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@headlessui/react": "^2.0.0",
"driver.js": "^1.3.0",
"lucide-react": "^0.400.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.22.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.3.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

65
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,65 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import Navbar from './components/Navbar';
import Loading from './components/Loading';
import Login from './pages/Login';
const Department = lazy(() => import('./pages/Department'));
const Resourcing = lazy(() => import('./pages/Resourcing'));
const Bookings = lazy(() => import('./pages/Bookings'));
const Tutorial = lazy(() => import('./pages/Tutorial'));
function ProtectedShell({ children }: { children: React.ReactNode }) {
return (
<AuthGate>
<div className="flex min-h-screen flex-col">
<Navbar />
<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>
</div>
</AuthGate>
);
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedShell>
<Department />
</ProtectedShell>
}
/>
<Route
path="/resourcing"
element={
<ProtectedShell>
<Resourcing />
</ProtectedShell>
}
/>
<Route
path="/bookings"
element={
<ProtectedShell>
<Bookings />
</ProtectedShell>
}
/>
<Route
path="/tutorial"
element={
<ProtectedShell>
<Tutorial />
</ProtectedShell>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -0,0 +1,54 @@
const apiUrl = (path: string) =>
`${import.meta.env.BASE_URL}api${path.startsWith('/') ? path : '/' + path}`;
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail);
this.name = 'ApiError';
}
}
interface ExtraInit extends RequestInit {
/** Skip the default JSON content-type header — useful for multipart uploads. */
skipJsonContentType?: boolean;
}
export async function apiFetch<T>(path: string, init: ExtraInit = {}): Promise<T> {
const { skipJsonContentType, headers, ...rest } = init;
const finalHeaders: Record<string, string> = { ...(headers as Record<string, string> | undefined) };
if (!skipJsonContentType && !('Content-Type' in finalHeaders)) {
finalHeaders['Content-Type'] = 'application/json';
}
const res = await fetch(apiUrl(path), {
credentials: 'include',
headers: finalHeaders,
...rest,
});
if (res.status === 401 && !path.includes('/auth/')) {
window.location.href = `${import.meta.env.BASE_URL}login`;
throw new ApiError(401, 'Unauthorised');
}
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, detail);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === null || v === undefined || v === '') continue;
usp.append(k, String(v));
}
const qs = usp.toString();
return qs ? `?${qs}` : '';
}

View file

@ -0,0 +1,71 @@
import { apiFetch, buildQuery } from './client';
import type {
AuthMeResponse,
BookingsResponse,
LoginResponse,
MetaResponse,
ParseResponse,
ResourcesResponse,
UtilisationSummaryResponse,
} from './types';
// ---- Auth -----------------------------------------------------------------
export function login(username: string, password: string) {
return apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
export function logout() {
return apiFetch<void>('/auth/logout', { method: 'POST' });
}
export function getMe() {
return apiFetch<AuthMeResponse>('/auth/me');
}
// ---- Airtable -------------------------------------------------------------
export function getResources(includeInactive = false) {
return apiFetch<ResourcesResponse>(`/airtable/resources${buildQuery({ include_inactive: includeInactive })}`);
}
export function getBookings(params: { from?: string; to?: string; refresh?: boolean } = {}) {
return apiFetch<BookingsResponse>(`/airtable/bookings${buildQuery(params)}`);
}
export function getMeta() {
return apiFetch<MetaResponse>('/airtable/meta');
}
// ---- Timelog --------------------------------------------------------------
export function parseTimelog(file: File) {
const fd = new FormData();
fd.append('file', file);
return apiFetch<ParseResponse>('/timelog/parse', {
method: 'POST',
body: fd,
skipJsonContentType: true,
});
}
// ---- Utilisation ----------------------------------------------------------
export interface SummaryParams {
from?: string;
to?: string;
department?: string;
name?: string;
billing_type?: string;
timelogHash?: string;
}
export function getUtilisationSummary(params: SummaryParams = {}) {
const { timelogHash, ...query } = params;
const headers: Record<string, string> = {};
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
return apiFetch<UtilisationSummaryResponse>(`/utilisation/summary${buildQuery(query)}`, { headers });
}

92
frontend/src/api/types.ts Normal file
View file

@ -0,0 +1,92 @@
export interface Resource {
recordId: string;
name: string;
email: string;
department: string;
roles: string[];
inactive: boolean;
availHoursPerWeek: number;
startDate: string | null;
endDate: string | null;
employmentType: 'FTE' | 'Freelancer' | string;
country: string | null;
}
export interface Booking {
id: string;
task: string;
startDate: string;
endDate: string;
resourceName: string;
projectNumber: string;
projectName: string;
department: string;
division: string;
hoursSelection: string[];
totalHoursBooked: number;
bookingStatus: string;
placeholder: boolean;
}
export interface MetaResponse {
departments: string[];
billingTypes: string[];
employmentTypes: string[];
bookingStatuses: string[];
}
export interface TimelogRow {
date: string;
employee: string;
project: string;
task: string;
hours: number;
billable: boolean;
}
export interface ParseResponse {
rows: TimelogRow[];
unrecognised_columns: string[];
content_hash: string;
}
export interface UtilisationSummaryRow {
period: string;
employee: string;
department: string;
employmentType: string;
availableHours: number;
bookedHours: number;
loggedHours: number;
billableHours: number;
nonBillableHours: number;
forecastHours: number;
actualUtilisationPct: number;
bookedUtilisationPct: number;
}
export interface UtilisationSummaryResponse {
rows: UtilisationSummaryRow[];
filters_applied: Record<string, string | null>;
}
export interface ResourcesResponse {
resources: Resource[];
cached_at: string;
}
export interface BookingsResponse {
bookings: Booking[];
cached_at: string;
}
export interface AuthMeResponse {
username: string;
mode: string;
}
export interface LoginResponse {
ok: boolean;
username: string;
mode: string;
}

View file

@ -0,0 +1,12 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import Loading from './Loading';
export default function AuthGate({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Loading label="Checking session…" />;
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
return <>{children}</>;
}

View file

@ -0,0 +1,26 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
message: string;
onRetry?: () => void;
}
export default function ErrorBox({ message, onRetry }: Props) {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden />
<div className="flex-1">
<div className="font-medium">Something went wrong</div>
<div className="mt-0.5 break-words text-red-700">{message}</div>
</div>
{onRetry && (
<button type="button" onClick={onRetry} className="btn-secondary !px-2 !py-1 text-xs">
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
</button>
)}
</div>
);
}

View file

@ -0,0 +1,163 @@
import { useMemo } from 'react';
import { RotateCcw } from 'lucide-react';
import type { Dispatch } from 'react';
import type { FilterAction, FilterState } from '../lib/filters';
import { PRESET_LABELS, type DatePreset } from '../lib/dates';
const PRESETS: DatePreset[] = ['this-week', 'last-week', 'this-month', 'last-month', 'custom'];
interface Props {
state: FilterState;
dispatch: Dispatch<FilterAction>;
departments: string[];
names: string[];
billingTypes: string[];
showForecastToggle?: boolean;
}
function MultiSelect({
label,
options,
selected,
onChange,
tutorialId,
}: {
label: string;
options: string[];
selected: string[];
onChange: (next: string[]) => void;
tutorialId?: string;
}) {
return (
<div className="min-w-[12rem] flex-1">
<label className="label">{label}</label>
<select
multiple
value={selected}
onChange={(e) => onChange(Array.from(e.target.selectedOptions, (o) => o.value))}
className="input h-24"
data-tutorial-id={tutorialId}
>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
);
}
export default function FilterBar({
state,
dispatch,
departments,
names,
billingTypes,
showForecastToggle = true,
}: Props) {
const customRange = useMemo(
() => state.range ?? { from: '', to: '' },
[state.range],
);
return (
<div className="card space-y-3" data-tutorial-id="filter-bar">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="label" htmlFor="preset">Date range</label>
<select
id="preset"
value={state.preset}
onChange={(e) => dispatch({ type: 'set-preset', preset: e.target.value as DatePreset })}
className="input"
>
{PRESETS.map((p) => (
<option key={p} value={p}>
{PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="from">From</label>
<input
id="from"
type="date"
value={customRange.from}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: e.target.value, to: customRange.to } })
}
className="input"
/>
</div>
<div>
<label className="label" htmlFor="to">To</label>
<input
id="to"
type="date"
value={customRange.to}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: customRange.from, to: e.target.value } })
}
className="input"
/>
</div>
<div className="ml-auto flex items-end gap-3">
{showForecastToggle && (
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={state.showForecast}
onChange={() => dispatch({ type: 'toggle-forecast' })}
data-tutorial-id="forecast-toggle"
/>
{state.showForecast ? 'Forecast line visible' : 'Forecast line hidden'}
</label>
)}
<button type="button" onClick={() => dispatch({ type: 'reset' })} className="btn-secondary">
<RotateCcw className="h-4 w-4" aria-hidden /> Reset
</button>
</div>
</div>
<div className="flex flex-wrap gap-3">
<MultiSelect
label="Department"
options={departments}
selected={state.departments}
onChange={(v) => dispatch({ type: 'set-departments', departments: v })}
tutorialId="filter-department"
/>
<MultiSelect
label="Name"
options={names}
selected={state.names}
onChange={(v) => dispatch({ type: 'set-names', names: v })}
tutorialId="filter-name"
/>
<div className="min-w-[12rem] flex-1">
<label className="label" htmlFor="billing-type">Billing type</label>
<select
id="billing-type"
value={state.billingType ?? ''}
onChange={(e) =>
dispatch({ type: 'set-billing-type', billingType: e.target.value === '' ? null : e.target.value })
}
className="input"
>
<option value="">All</option>
{billingTypes.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
import { Loader2 } from 'lucide-react';
export default function Loading({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-3 p-8 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden />
<span>{label}</span>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LogOut, BarChart3 } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
const tabs = [
{ to: '/', label: 'Department', end: true },
{ to: '/resourcing', label: 'Resourcing' },
{ to: '/bookings', label: 'Bookings' },
{ to: '/tutorial', label: 'Tutorial' },
];
export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
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="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) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
data-tutorial-id={`tab-${tab.label.toLowerCase()}`}
className={({ isActive }) =>
[
'rounded-md px-3 py-1.5 text-sm font-medium transition',
isActive ? 'bg-slate-800 text-white' : 'text-slate-300 hover:bg-slate-800 hover:text-white',
].join(' ')
}
>
{tab.label}
</NavLink>
))}
</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>}
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-sm text-slate-200 hover:bg-slate-700"
data-tutorial-id="logout-button"
>
<LogOut className="h-4 w-4" aria-hidden /> Log out
</button>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,91 @@
import { useRef, useState } from 'react';
import { Upload, FileSpreadsheet, X } from 'lucide-react';
const ACCEPT = '.xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv';
export interface UploadButtonProps {
uploading: boolean;
error: string | null;
unrecognised: string[];
filename: string | null;
rowCount: number;
onFile: (file: File) => void;
onClear: () => void;
}
export default function UploadButton({
uploading,
error,
unrecognised,
filename,
rowCount,
onFile,
onClear,
}: UploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) onFile(f);
e.target.value = '';
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onFile(f);
};
return (
<div className="space-y-2" data-tutorial-id="upload-zone">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={[
'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition',
dragOver ? 'border-blue-400 bg-blue-50' : 'border-slate-300 bg-white',
].join(' ')}
>
<FileSpreadsheet className="h-8 w-8 text-slate-400" aria-hidden />
<p className="text-sm text-slate-600">
Drag &amp; drop a timelog file (<code>.xlsx</code> or <code>.csv</code>), or
</p>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="btn-primary"
>
<Upload className="h-4 w-4" aria-hidden />
{uploading ? 'Uploading…' : 'Choose file'}
</button>
<input ref={inputRef} type="file" accept={ACCEPT} hidden onChange={onChange} />
{filename && !error && (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<span>
{filename} {rowCount} row{rowCount === 1 ? '' : 's'}
</span>
<button type="button" onClick={onClear} className="text-slate-400 hover:text-slate-700">
<X className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">Clear</span>
</button>
</div>
)}
</div>
{error && <div className="rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-800">{error}</div>}
{unrecognised.length > 0 && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-2 text-xs text-yellow-900">
<strong>Unrecognised columns:</strong> {unrecognised.join(', ')}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UtilisationSummaryRow } from '../../api/types';
interface Props {
rows: UtilisationSummaryRow[];
}
interface Bucket {
employee: string;
billable: number;
nonBillable: number;
leave: number;
idle: number;
}
function aggregate(rows: UtilisationSummaryRow[]): Bucket[] {
const map = new Map<string, Bucket>();
for (const r of rows) {
const b = map.get(r.employee) ?? {
employee: r.employee,
billable: 0,
nonBillable: 0,
leave: 0,
idle: 0,
};
b.billable += r.billableHours;
b.nonBillable += r.nonBillableHours;
// Leave & idle are not split out by the API yet; we infer "idle" as available - logged.
const accountedLogged = r.billableHours + r.nonBillableHours;
const slack = Math.max(0, r.availableHours - accountedLogged);
b.idle += slack;
map.set(r.employee, b);
}
return [...map.values()].sort((a, b) => a.employee.localeCompare(b.employee));
}
export default function BillabilityBreakdown({ rows }: Props) {
const data = aggregate(rows);
return (
<div className="card" data-tutorial-id="chart-billability-breakdown">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Billability Breakdown</h3>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="billable" name="Billable" stackId="b" fill="#10b981" />
<Bar dataKey="nonBillable" name="Non-billable" stackId="b" fill="#f59e0b" />
<Bar dataKey="leave" name="Leave" stackId="b" fill="#a855f7" />
<Bar dataKey="idle" name="Idle" stackId="b" fill="#cbd5e1" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UtilisationSummaryRow } from '../../api/types';
interface Props {
rows: UtilisationSummaryRow[];
}
interface Bucket {
employee: string;
booked: number;
actual: number;
}
function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] {
const map = new Map<string, Bucket>();
for (const r of rows) {
const b = map.get(r.employee) ?? { employee: r.employee, booked: 0, actual: 0 };
b.booked += r.bookedHours;
b.actual += r.loggedHours;
map.set(r.employee, b);
}
return [...map.values()].sort((a, b) => b.booked + b.actual - (a.booked + a.actual));
}
export default function BookingVsActual({ rows }: Props) {
const data = aggregateByEmployee(rows);
return (
<div className="card" data-tutorial-id="chart-booking-vs-actual">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Booking vs Actual</h3>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
<Bar dataKey="actual" name="Actual" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UtilisationSummaryRow } from '../../api/types';
interface Props {
rows: UtilisationSummaryRow[];
}
interface Bucket {
employee: string;
utilisation: number;
}
function split(rows: UtilisationSummaryRow[]): { fte: Bucket[]; freelancer: Bucket[] } {
const fteMap = new Map<string, { available: number; booked: number }>();
const freelancerMap = new Map<string, { available: number; booked: number }>();
for (const r of rows) {
const target = r.employmentType === 'FTE' ? fteMap : r.employmentType === 'Freelancer' ? freelancerMap : null;
if (!target) continue;
const b = target.get(r.employee) ?? { available: 0, booked: 0 };
b.available += r.availableHours;
b.booked += r.bookedHours;
target.set(r.employee, b);
}
const toBuckets = (m: Map<string, { available: number; booked: number }>): Bucket[] =>
[...m.entries()]
.map(([employee, v]) => ({
employee,
utilisation: v.available > 0 ? Math.round((v.booked / v.available) * 1000) / 10 : 0,
}))
.sort((a, b) => b.utilisation - a.utilisation);
return { fte: toBuckets(fteMap), freelancer: toBuckets(freelancerMap) };
}
function MiniChart({ title, data, fill }: { title: string; data: Bucket[]; fill: string }) {
return (
<div className="card flex-1">
<h3 className="mb-2 text-sm font-semibold text-slate-700">{title}</h3>
<div className="h-72 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} unit="%" />
<Tooltip formatter={(v: number) => `${v}%`} />
<Legend />
<Bar dataKey="utilisation" name="Utilisation %" fill={fill} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default function FTEvsFreelancer({ rows }: Props) {
const { fte, freelancer } = split(rows);
return (
<div className="flex flex-col gap-4 md:flex-row" data-tutorial-id="chart-fte-vs-freelancer">
<MiniChart title="FTE" data={fte} fill="#2563eb" />
<MiniChart title="Freelancers" data={freelancer} fill="#a855f7" />
</div>
);
}

View file

@ -0,0 +1,70 @@
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UtilisationSummaryRow } from '../../api/types';
import { formatPeriod, monthOf } from '../../lib/dates';
interface Props {
rows: UtilisationSummaryRow[];
showForecast: boolean;
}
interface Bucket {
period: string;
available: number;
booked: number;
logged: number;
forecast: number;
}
function aggregateByMonth(rows: UtilisationSummaryRow[]): Bucket[] {
const map = new Map<string, Bucket>();
for (const r of rows) {
const key = /^\d{4}-\d{2}/.test(r.period) ? r.period.slice(0, 7) : monthOf(r.period);
const b = map.get(key) ?? { period: key, available: 0, booked: 0, logged: 0, forecast: 0 };
b.available += r.availableHours;
b.booked += r.bookedHours;
b.logged += r.loggedHours;
b.forecast += r.forecastHours;
map.set(key, b);
}
return [...map.values()].sort((a, b) => a.period.localeCompare(b.period));
}
export default function MonthlyUtilisation({ rows, showForecast }: Props) {
const data = aggregateByMonth(rows).map((b) => ({
...b,
label: formatPeriod(b.period),
}));
return (
<div className="card" data-tutorial-id="chart-monthly-utilisation">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
<div className="h-80 w-full">
<ResponsiveContainer>
<ComposedChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
{showForecast && (
<Line type="monotone" dataKey="forecast" name="Forecast" stroke="#f97316" strokeWidth={2} dot />
)}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { Booking } from '../../api/types';
interface Props {
bookings: Booking[];
}
const PALETTE = ['#2563eb', '#10b981', '#f59e0b', '#a855f7', '#ef4444', '#0ea5e9', '#84cc16', '#f97316'];
interface Row {
employee: string;
[project: string]: number | string;
}
function aggregate(bookings: Booking[]): { data: Row[]; projects: string[] } {
const projects = new Set<string>();
const map = new Map<string, Row>();
for (const bk of bookings) {
projects.add(bk.projectName || bk.projectNumber || 'Unknown');
const projectKey = bk.projectName || bk.projectNumber || 'Unknown';
const row = map.get(bk.resourceName) ?? { employee: bk.resourceName };
row[projectKey] = ((row[projectKey] as number) ?? 0) + bk.totalHoursBooked;
map.set(bk.resourceName, row);
}
return { data: [...map.values()].sort((a, b) => a.employee.localeCompare(b.employee)), projects: [...projects] };
}
export default function ProjectLoadPerPerson({ bookings }: Props) {
const { data, projects } = aggregate(bookings);
return (
<div className="card" data-tutorial-id="chart-project-load">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Project Load per Person</h3>
<div className="h-96 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
{projects.map((p, i) => (
<Bar key={p} dataKey={p} stackId="proj" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UtilisationSummaryRow } from '../../api/types';
import { formatPeriod, weekOf } from '../../lib/dates';
interface Props {
rows: UtilisationSummaryRow[];
onPeriodClick?: (period: string) => void;
}
interface Bucket {
period: string;
booked: number;
logged: number;
available: number;
}
function aggregateByWeek(rows: UtilisationSummaryRow[]): Bucket[] {
const map = new Map<string, Bucket>();
for (const r of rows) {
const key = /^\d{4}-W\d{2}$/.test(r.period) ? r.period : weekOf(r.period);
const b = map.get(key) ?? { period: key, booked: 0, logged: 0, available: 0 };
b.booked += r.bookedHours;
b.logged += r.loggedHours;
b.available += r.availableHours;
map.set(key, b);
}
return [...map.values()].sort((a, b) => a.period.localeCompare(b.period));
}
export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) {
const data = aggregateByWeek(rows).map((b) => ({ ...b, label: formatPeriod(b.period) }));
return (
<div className="card" data-tutorial-id="chart-weekly-utilisation">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Weekly Utilisation</h3>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar
dataKey="booked"
name="Booked"
fill="#2563eb"
onClick={(payload: { period?: string }) => {
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
}}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { driver, type Config } from 'driver.js';
import 'driver.js/dist/driver.css';
import { allSteps, type TutorialSection } from './steps';
interface Props {
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) {
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) {
onClose?.();
return;
}
const config: Config = {
showProgress: true,
steps,
onDestroyed: () => onClose?.(),
};
const d = driver(config);
d.drive();
return () => {
d.destroy();
};
}, [section, onClose]);
return null;
}

View file

@ -0,0 +1,78 @@
export interface TutorialStep {
/** matches the `data-tutorial-id` attribute on the target element */
selector: string;
title: string;
description: string;
}
export const departmentSteps: TutorialStep[] = [
{
selector: 'navbar',
title: 'Top navigation',
description: 'Switch between Department, Resourcing, Bookings and Tutorial tabs from here.',
},
{
selector: 'upload-zone',
title: 'Upload your timelog',
description: 'Drag a Harvest/Toggl export here, or click to choose a file. .xlsx and .csv supported.',
},
{
selector: 'filter-bar',
title: 'Filter the view',
description: 'Pick a date range preset (or use a custom range), and narrow down by department, name or billing type.',
},
{
selector: 'forecast-toggle',
title: 'Forecast line',
description: 'Toggle the forecast overlay on or off — useful for spotting upcoming over- or under-bookings.',
},
{
selector: 'chart-monthly-utilisation',
title: 'Monthly utilisation',
description: 'Booked, logged and available hours grouped by calendar month.',
},
{
selector: 'chart-booking-vs-actual',
title: 'Booking vs Actual',
description: 'See whose bookings match their logged hours and whose drift.',
},
];
export const resourcingSteps: TutorialStep[] = [
{
selector: 'chart-weekly-utilisation',
title: 'Weekly utilisation',
description: 'Click a bar to drill into a specific week.',
},
{
selector: 'chart-project-load',
title: 'Project load per person',
description: 'Stacked bars show which projects are loading up each resource.',
},
{
selector: 'chart-fte-vs-freelancer',
title: 'FTE vs Freelancer',
description: 'Compare utilisation between salaried staff and contractors.',
},
];
export const bookingsSteps: TutorialStep[] = [
{
selector: 'bookings-table',
title: 'Bookings table',
description: 'A virtualised view of every booking returned by Airtable for the active filters.',
},
{
selector: 'bookings-refresh',
title: 'Refresh from Airtable',
description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.',
},
];
export const allSteps = {
department: departmentSteps,
resourcing: resourcingSteps,
bookings: bookingsSteps,
} as const;
export type TutorialSection = keyof typeof allSteps;

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/endpoints';
import type { MetaResponse, Resource } from '../api/types';
import { ApiError } from '../api/client';
interface State {
resources: Resource[];
meta: MetaResponse | null;
cachedAt: string | null;
loading: boolean;
error: string | null;
}
const initial: State = {
resources: [],
meta: null,
cachedAt: null,
loading: true,
error: null,
};
export function useAirtableData(includeInactive = false) {
const [state, setState] = useState<State>(initial);
const load = useCallback(async () => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const [res, meta] = await Promise.all([api.getResources(includeInactive), api.getMeta()]);
setState({
resources: res.resources,
meta,
cachedAt: res.cached_at,
loading: false,
error: null,
});
} catch (err) {
const message = err instanceof ApiError ? err.detail : (err as Error).message;
setState((s) => ({ ...s, loading: false, error: message }));
}
}, [includeInactive]);
useEffect(() => {
void load();
}, [load]);
return { ...state, refresh: load };
}

View file

@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
export interface AuthUser {
username: string;
mode: string;
}
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const me = await api.getMe();
setUser({ username: me.username, mode: me.mode });
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setUser(null);
} else {
// Network or server error — treat as logged-out, surface elsewhere.
setUser(null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const login = useCallback(async (username: string, password: string) => {
const res = await api.login(username, password);
setUser({ username: res.username, mode: res.mode });
}, []);
const logout = useCallback(async () => {
try {
await api.logout();
} finally {
setUser(null);
}
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, loading, login, logout, refresh }),
[user, loading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}

View file

@ -0,0 +1,48 @@
import { useCallback, useState } from 'react';
import * as api from '../api/endpoints';
import type { TimelogRow } from '../api/types';
import { ApiError } from '../api/client';
interface TimelogState {
rows: TimelogRow[];
hash: string | null;
unrecognised: string[];
filename: string | null;
uploading: boolean;
error: string | null;
}
const initial: TimelogState = {
rows: [],
hash: null,
unrecognised: [],
filename: null,
uploading: false,
error: null,
};
export function useTimelog() {
const [state, setState] = useState<TimelogState>(initial);
const upload = useCallback(async (file: File) => {
setState((s) => ({ ...s, uploading: true, error: null }));
try {
const res = await api.parseTimelog(file);
setState({
rows: res.rows,
hash: res.content_hash,
unrecognised: res.unrecognised_columns,
filename: file.name,
uploading: false,
error: null,
});
} catch (err) {
const message = err instanceof ApiError ? err.detail : (err as Error).message;
setState((s) => ({ ...s, uploading: false, error: message }));
}
}, []);
const clear = useCallback(() => setState(initial), []);
return { ...state, upload, clear };
}

41
frontend/src/index.css Normal file
View file

@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
}
@layer components {
.btn {
@apply inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium shadow-sm transition;
}
.btn-primary {
@apply btn bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50;
}
.btn-secondary {
@apply btn bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50;
}
.card {
@apply rounded-lg bg-white p-4 shadow-sm ring-1 ring-slate-200;
}
.input {
@apply w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500;
}
.label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500;
}
}

37
frontend/src/lib/csv.ts Normal file
View file

@ -0,0 +1,37 @@
// UTF-8 BOM so Excel opens the file with proper encoding.
const BOM = '';
/** Escape a single CSV cell. Cells starting with =,+,-,@ are prefixed with a single
* quote to defang spreadsheet formula injection (CWE-1236). Cells containing
* quotes, commas or newlines are double-quoted with internal quotes doubled. */
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return '';
let cell = String(value);
if (/^[=+\-@]/.test(cell)) cell = `'${cell}`;
if (/["\n\r,]/.test(cell)) {
cell = `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}
export function rowsToCsv<T extends Record<string, unknown>>(rows: T[], headers?: string[]): string {
if (rows.length === 0 && !headers) return BOM;
const cols = headers ?? Object.keys(rows[0]);
const out = [cols.map(escapeCell).join(',')];
for (const row of rows) {
out.push(cols.map((c) => escapeCell(row[c])).join(','));
}
return BOM + out.join('\r\n');
}
export function downloadCsv(filename: string, csv: string): void {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

107
frontend/src/lib/dates.ts Normal file
View file

@ -0,0 +1,107 @@
export type DatePreset = 'this-week' | 'last-week' | 'this-month' | 'last-month' | 'custom';
export const PRESET_LABELS: Record<DatePreset, string> = {
'this-week': 'This Week',
'last-week': 'Last Week',
'this-month': 'This Month',
'last-month': 'Last Month',
custom: 'Custom',
};
export function toIsoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function startOfWeekMonday(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
const day = out.getDay(); // 0 Sun … 6 Sat
const diff = (day + 6) % 7; // days since Monday
out.setDate(out.getDate() - diff);
return out;
}
function endOfWeekSunday(d: Date): Date {
const start = startOfWeekMonday(d);
start.setDate(start.getDate() + 6);
return start;
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
export interface DateRange {
from: string;
to: string;
}
export function dateRangePreset(preset: DatePreset, today: Date = new Date()): DateRange | null {
switch (preset) {
case 'this-week':
return { from: toIsoDate(startOfWeekMonday(today)), to: toIsoDate(endOfWeekSunday(today)) };
case 'last-week': {
const lw = new Date(today);
lw.setDate(lw.getDate() - 7);
return { from: toIsoDate(startOfWeekMonday(lw)), to: toIsoDate(endOfWeekSunday(lw)) };
}
case 'this-month':
return { from: toIsoDate(startOfMonth(today)), to: toIsoDate(endOfMonth(today)) };
case 'last-month': {
const lm = new Date(today.getFullYear(), today.getMonth() - 1, 15);
return { from: toIsoDate(startOfMonth(lm)), to: toIsoDate(endOfMonth(lm)) };
}
case 'custom':
default:
return null;
}
}
/** Returns YYYY-Www, e.g. 2026-W20 — ISO week of the supplied date string. */
export function weekOf(iso: string): string {
const d = new Date(`${iso}T00:00:00`);
// ISO week algorithm.
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = (target.getUTCDay() + 6) % 7;
target.setUTCDate(target.getUTCDate() - day + 3);
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
const firstDay = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDay + 3);
const weekNum = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 86400000));
return `${target.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
/** Returns YYYY-MM. */
export function monthOf(iso: string): string {
return iso.slice(0, 7);
}
const monthFormatter = new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' });
export function formatPeriod(period: string): string {
if (/^\d{4}-W\d{2}$/.test(period)) return period.replace('-W', ' W');
if (/^\d{4}-\d{2}$/.test(period)) {
const [y, m] = period.split('-').map(Number);
return monthFormatter.format(new Date(y, m - 1, 1));
}
return period;
}
const numberFormatter = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
const percentFormatter = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1, style: 'percent' });
export function formatHours(n: number): string {
return `${numberFormatter.format(n)}h`;
}
export function formatPercent(fraction: number): string {
// Inputs are in the 01 range. UtilisationSummaryRow uses pct = 0..100 so caller must divide.
return percentFormatter.format(fraction);
}

View file

@ -0,0 +1,63 @@
import type { DatePreset, DateRange } from './dates';
import { dateRangePreset } from './dates';
export interface FilterState {
preset: DatePreset;
range: DateRange | null;
departments: string[];
names: string[];
billingType: string | null;
showForecast: boolean;
}
export const initialFilterState: FilterState = {
preset: 'this-month',
range: dateRangePreset('this-month'),
departments: [],
names: [],
billingType: null,
showForecast: true,
};
export type FilterAction =
| { type: 'set-preset'; preset: DatePreset }
| { type: 'set-custom-range'; range: DateRange }
| { type: 'set-departments'; departments: string[] }
| { type: 'set-names'; names: string[] }
| { type: 'set-billing-type'; billingType: string | null }
| { type: 'toggle-forecast' }
| { type: 'reset' };
export function filterReducer(state: FilterState, action: FilterAction): FilterState {
switch (action.type) {
case 'set-preset': {
const range = dateRangePreset(action.preset) ?? state.range;
return { ...state, preset: action.preset, range };
}
case 'set-custom-range':
return { ...state, preset: 'custom', range: action.range };
case 'set-departments':
return { ...state, departments: action.departments };
case 'set-names':
return { ...state, names: action.names };
case 'set-billing-type':
return { ...state, billingType: action.billingType };
case 'toggle-forecast':
return { ...state, showForecast: !state.showForecast };
case 'reset':
return { ...initialFilterState, range: dateRangePreset(initialFilterState.preset) };
default:
return state;
}
}
/** Pack filter state into the query params the backend expects. */
export function filtersToQuery(state: FilterState): Record<string, string | undefined> {
return {
from: state.range?.from,
to: state.range?.to,
department: state.departments.length ? state.departments.join(',') : undefined,
name: state.names.length ? state.names.join(',') : undefined,
billing_type: state.billingType ?? undefined,
};
}

18
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './hooks/useAuth';
import './index.css';
const basename = import.meta.env.BASE_URL.replace(/\/$/, '');
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter basename={basename}>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);

View file

@ -0,0 +1,172 @@
import { useCallback, useEffect, useMemo, useReducer, 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 { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { Booking } from '../api/types';
const ROW_HEIGHT = 36;
const OVERSCAN = 8;
export default function Bookings() {
const airtable = useAirtableData(false);
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [bookings, setBookings] = useState<Booking[]>([]);
const [cachedAt, setCachedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportH, setViewportH] = useState(480);
const names = useMemo(
() => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)),
[airtable.resources],
);
const load = useCallback(
async (refresh = false) => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getBookings({ from: q.from, to: q.to, refresh });
setBookings(res.bookings);
setCachedAt(res.cached_at);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
},
[filters],
);
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
return bookings.filter((b) => {
if (filters.departments.length && !filters.departments.includes(b.department)) return false;
if (filters.names.length && !filters.names.includes(b.resourceName)) return false;
return true;
});
}, [bookings, filters.departments, filters.names]);
const total = filtered.length;
const totalHeight = total * ROW_HEIGHT;
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const endIndex = Math.min(total, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN);
const visible = filtered.slice(startIndex, endIndex);
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 handleExport = () => {
if (filtered.length === 0) return;
const csv = rowsToCsv(filtered as unknown as Record<string, unknown>[]);
downloadCsv(`bookings-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv);
};
return (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
showForecastToggle={false}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-slate-500">
{total.toLocaleString()} booking{total === 1 ? '' : 's'}
{cachedAt && <span className="ml-2"> cached at {new Date(cachedAt).toLocaleString('en-GB')}</span>}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void load(true)}
disabled={loading}
className="btn-secondary"
data-tutorial-id="bookings-refresh"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden /> Sync from Airtable
</button>
<button
type="button"
onClick={handleExport}
disabled={filtered.length === 0}
className="btn-secondary"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
</div>
</div>
{error && <ErrorBox message={error} onRetry={() => load(false)} />}
{loading && <Loading label="Loading bookings…" />}
{!loading && !error && (
<div className="card p-0" data-tutorial-id="bookings-table">
<div className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] gap-0 border-b border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div>Resource</div>
<div>Project #</div>
<div>Project</div>
<div>Task</div>
<div>Dates</div>
<div className="text-right">Hours</div>
<div>Status</div>
</div>
<div
ref={containerRef}
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
style={{ height: 480 }}
className="relative overflow-auto"
>
<div style={{ height: totalHeight }}>
<div style={{ transform: `translateY(${startIndex * ROW_HEIGHT}px)` }}>
{visible.map((b) => (
<div
key={b.id}
style={{ height: ROW_HEIGHT }}
className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] items-center gap-0 border-b border-slate-100 px-3 text-sm text-slate-700"
>
<div className="truncate" title={b.resourceName}>
{b.resourceName}
{b.placeholder && <span className="ml-1 rounded bg-amber-100 px-1 text-[10px] text-amber-800">PLACEHOLDER</span>}
</div>
<div className="truncate text-slate-500">{b.projectNumber}</div>
<div className="truncate" title={b.projectName}>{b.projectName}</div>
<div className="truncate" title={b.task}>{b.task}</div>
<div className="truncate text-slate-500">{b.startDate} {b.endDate}</div>
<div className="text-right tabular-nums">{b.totalHoursBooked.toFixed(1)}</div>
<div className="truncate text-slate-500">{b.bookingStatus}</div>
</div>
))}
{total === 0 && (
<div className="p-6 text-center text-sm text-slate-500">No bookings match the current filters.</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,123 @@
import { useEffect, useMemo, useReducer, useState } from 'react';
import { Download } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import UploadButton from '../components/UploadButton';
import MonthlyUtilisation from '../components/charts/MonthlyUtilisation';
import BookingVsActual from '../components/charts/BookingVsActual';
import BillabilityBreakdown from '../components/charts/BillabilityBreakdown';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import { useAirtableData } from '../hooks/useAirtableData';
import { useTimelog } from '../hooks/useTimelog';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { UtilisationSummaryRow } from '../api/types';
export default function Department() {
const airtable = useAirtableData(false);
const tl = useTimelog();
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const names = useMemo(
() => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)),
[airtable.resources],
);
const loadSummary = useMemo(
() => async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
timelogHash: tl.hash ?? undefined,
});
setSummary(res.rows);
} catch (err) {
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setSummaryLoading(false);
}
},
[filters, tl.hash],
);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
const handleExport = () => {
if (summary.length === 0) return;
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
downloadCsv(`department-utilisation-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv);
};
return (
<div className="space-y-4">
<section className="card">
<h2 className="text-base font-semibold text-slate-800">How to Use the Department Tab</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-600">
<li>Upload your timelog export (.xlsx or .csv). It stays in this session only.</li>
<li>Pick a date preset (or custom range) and narrow by department or name.</li>
<li>The charts below recompute automatically. Toggle the forecast line to compare scenarios.</li>
<li>Use <em>Export CSV</em> to share the summary outside the app.</li>
</ol>
</section>
<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}
/>
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
/>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
</div>
{airtable.error && <ErrorBox message={airtable.error} onRetry={airtable.refresh} />}
{summaryError && <ErrorBox message={summaryError} onRetry={loadSummary} />}
{(airtable.loading || summaryLoading) && <Loading label="Crunching numbers…" />}
{!summaryError && summary.length > 0 && (
<>
<MonthlyUtilisation rows={summary} showForecast={filters.showForecast} />
<BookingVsActual rows={summary} />
<BillabilityBreakdown rows={summary} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
import { ApiError } from '../api/client';
export default function Login() {
const { user, login, loading } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [fieldError, setFieldError] = useState<string | null>(null);
const [rateLimited, setRateLimited] = useState(false);
// If already authenticated, send them home.
useEffect(() => {
if (!loading && user) navigate('/', { replace: true });
}, [loading, user, navigate]);
if (user) return <Navigate to="/" replace />;
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFieldError(null);
setRateLimited(false);
setSubmitting(true);
try {
await login(username, password);
navigate('/', { replace: true });
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 401) setFieldError('Invalid username or password.');
else if (err.status === 429) setRateLimited(true);
else setFieldError(err.detail || 'Login failed.');
} else {
setFieldError((err as Error).message);
}
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100 px-4">
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-md ring-1 ring-slate-200">
<div className="mb-6 text-center">
<div className="mx-auto mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-white">
<LogIn className="h-5 w-5" aria-hidden />
</div>
<h1 className="text-lg font-semibold text-slate-900">L&apos;Oréal Utilisation</h1>
<p className="text-xs text-slate-500">Sign in to continue</p>
</div>
{rateLimited && (
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900">
Too many login attempts. Please wait a few minutes and try again.
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label htmlFor="username" className="label">Username</label>
<input
id="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
/>
</div>
<div>
<label htmlFor="password" className="label">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</div>
{fieldError && (
<div className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-700">{fieldError}</div>
)}
<button type="submit" disabled={submitting} className="btn-primary w-full justify-center">
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { Download } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import WeeklyUtilisation from '../components/charts/WeeklyUtilisation';
import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson';
import FTEvsFreelancer from '../components/charts/FTEvsFreelancer';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import { useAirtableData } from '../hooks/useAirtableData';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { Booking, UtilisationSummaryRow } from '../api/types';
export default function Resourcing() {
const airtable = useAirtableData(false);
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [bookings, setBookings] = useState<Booking[]>([]);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null);
const names = useMemo(
() => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)),
[airtable.resources],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const [bk, sm] = await Promise.all([
api.getBookings({ from: q.from, to: q.to }),
api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
}),
]);
setBookings(bk.bookings);
setSummary(sm.rows);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
void load();
}, [load]);
const handleExport = () => {
if (summary.length === 0) return;
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
downloadCsv(`resourcing-utilisation-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv);
};
return (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
showForecastToggle={false}
/>
<div className="flex items-center justify-between">
{selectedPeriod ? (
<div className="text-sm text-slate-600">
Drilled into period <strong>{selectedPeriod}</strong>{' '}
<button onClick={() => setSelectedPeriod(null)} className="ml-2 text-blue-600 hover:underline">
clear
</button>
</div>
) : (
<span />
)}
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
</div>
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Loading resourcing data…" />}
{!error && (
<>
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
<ProjectLoadPerPerson bookings={bookings} />
<FTEvsFreelancer rows={summary} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
import { lazy, Suspense, useState } from 'react';
import { Play } from 'lucide-react';
import { allSteps, type TutorialSection } 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);
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-lg font-semibold text-slate-900">Tutorial</h1>
<p className="mt-1 text-sm text-slate-600">
A walkthrough of every tab. Click <em>Replay</em> to relaunch the guided tour for that section it only
highlights elements that are currently visible, so navigate to the matching tab first.
</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}`}
>
<Play className="h-4 w-4" aria-hidden /> Replay
</button>
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-slate-500">
{allSteps[s.key].map((step) => (
<li key={step.selector}>
<strong className="text-slate-700">{step.title}:</strong> {step.description}
</li>
))}
</ul>
</div>
))}
</div>
{activeSection && (
<Suspense fallback={null}>
<TutorialOverlay section={activeSection} onClose={() => setActiveSection(null)} />
</Suspense>
)}
</div>
);
}

View file

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#0f172a', // slate-900
accent: '#2563eb', // blue-600
},
},
},
},
plugins: [],
};

25
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"outDir": "./.tsbuild-node"
},
"include": ["vite.config.ts"]
}

30
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
base: '/utilisation-dept/',
plugins: [react()],
server: {
port: 5173,
proxy: {
'/utilisation-dept/api': {
target: 'http://localhost:8200',
changeOrigin: false,
rewrite: (p) => p.replace('/utilisation-dept', ''),
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
recharts: ['recharts'],
driver: ['driver.js'],
},
},
},
},
});