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:
parent
c9f9c5cced
commit
04edbfdd2c
80 changed files with 11856 additions and 41 deletions
37
.env.example
Normal file
37
.env.example
Normal 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
71
.gitignore
vendored
|
|
@ -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
209
README.md
Normal 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 8200–8299 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
48
backend/Dockerfile
Normal 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
3
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""utilisation-dept backend package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
25
backend/app/auth/azure.py
Normal file
25
backend/app/auth/azure.py
Normal 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
51
backend/app/auth/local.py
Normal 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"}
|
||||
58
backend/app/auth/session.py
Normal file
58
backend/app/auth/session.py
Normal 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
89
backend/app/config.py
Normal 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()
|
||||
0
backend/app/deps/__init__.py
Normal file
0
backend/app/deps/__init__.py
Normal file
46
backend/app/deps/airtable.py
Normal file
46
backend/app/deps/airtable.py
Normal 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
31
backend/app/deps/auth.py
Normal 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
109
backend/app/main.py
Normal 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()
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
132
backend/app/models/schemas.py
Normal file
132
backend/app/models/schemas.py
Normal 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
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
96
backend/app/routers/airtable.py
Normal file
96
backend/app/routers/airtable.py
Normal 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"]
|
||||
88
backend/app/routers/auth.py
Normal file
88
backend/app/routers/auth.py
Normal 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"])
|
||||
16
backend/app/routers/health.py
Normal file
16
backend/app/routers/health.py
Normal 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__)
|
||||
62
backend/app/routers/timelog.py
Normal file
62
backend/app/routers/timelog.py
Normal 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
|
||||
93
backend/app/routers/utilisation.py
Normal file
93
backend/app/routers/utilisation.py
Normal 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,
|
||||
},
|
||||
}
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
203
backend/app/services/airtable_fetch.py
Normal file
203
backend/app/services/airtable_fetch.py
Normal 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
|
||||
58
backend/app/services/cache.py
Normal file
58
backend/app/services/cache.py
Normal 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
|
||||
272
backend/app/services/merge.py
Normal file
272
backend/app/services/merge.py
Normal 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
|
||||
202
backend/app/services/zoho_parse.py
Normal file
202
backend/app/services/zoho_parse.py
Normal 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
0
backend/logs/.gitkeep
Normal file
5
backend/pytest.ini
Normal file
5
backend/pytest.ini
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
asyncio_mode = auto
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
113
backend/tests/conftest.py
Normal file
113
backend/tests/conftest.py
Normal 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},
|
||||
]
|
||||
5
backend/tests/fixtures/sample_zoho.csv
vendored
Normal file
5
backend/tests/fixtures/sample_zoho.csv
vendored
Normal 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
|
||||
|
78
backend/tests/test_airtable_cache.py
Normal file
78
backend/tests/test_airtable_cache.py
Normal 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
|
||||
88
backend/tests/test_auth.py
Normal file
88
backend/tests/test_auth.py
Normal 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
146
backend/tests/test_merge.py
Normal 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
|
||||
100
backend/tests/test_zoho_parse.py
Normal file
100
backend/tests/test_zoho_parse.py
Normal 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"]
|
||||
42
deploy/apache-utilisation-dept.conf.tmpl
Normal file
42
deploy/apache-utilisation-dept.conf.tmpl
Normal 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
225
deploy/deploy.sh
Executable 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
30
docker-compose.yml
Normal 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
24
frontend/.eslintrc.cjs
Normal 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
13
frontend/index.html
Normal 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
6727
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
65
frontend/src/App.tsx
Normal file
65
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/api/client.ts
Normal file
54
frontend/src/api/client.ts
Normal 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}` : '';
|
||||
}
|
||||
71
frontend/src/api/endpoints.ts
Normal file
71
frontend/src/api/endpoints.ts
Normal 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
92
frontend/src/api/types.ts
Normal 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;
|
||||
}
|
||||
12
frontend/src/components/AuthGate.tsx
Normal file
12
frontend/src/components/AuthGate.tsx
Normal 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}</>;
|
||||
}
|
||||
26
frontend/src/components/ErrorBox.tsx
Normal file
26
frontend/src/components/ErrorBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/FilterBar.tsx
Normal file
163
frontend/src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/Loading.tsx
Normal file
10
frontend/src/components/Loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/Navbar.tsx
Normal file
60
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/UploadButton.tsx
Normal file
91
frontend/src/components/UploadButton.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/charts/BillabilityBreakdown.tsx
Normal file
69
frontend/src/components/charts/BillabilityBreakdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/charts/BookingVsActual.tsx
Normal file
55
frontend/src/components/charts/BookingVsActual.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/charts/FTEvsFreelancer.tsx
Normal file
71
frontend/src/components/charts/FTEvsFreelancer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/charts/MonthlyUtilisation.tsx
Normal file
70
frontend/src/components/charts/MonthlyUtilisation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/charts/ProjectLoadPerPerson.tsx
Normal file
59
frontend/src/components/charts/ProjectLoadPerPerson.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/charts/WeeklyUtilisation.tsx
Normal file
69
frontend/src/components/charts/WeeklyUtilisation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/tutorial/TutorialOverlay.tsx
Normal file
41
frontend/src/components/tutorial/TutorialOverlay.tsx
Normal 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;
|
||||
}
|
||||
78
frontend/src/components/tutorial/steps.ts
Normal file
78
frontend/src/components/tutorial/steps.ts
Normal 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;
|
||||
47
frontend/src/hooks/useAirtableData.ts
Normal file
47
frontend/src/hooks/useAirtableData.ts
Normal 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 };
|
||||
}
|
||||
69
frontend/src/hooks/useAuth.tsx
Normal file
69
frontend/src/hooks/useAuth.tsx
Normal 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;
|
||||
}
|
||||
48
frontend/src/hooks/useTimelog.ts
Normal file
48
frontend/src/hooks/useTimelog.ts
Normal 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
41
frontend/src/index.css
Normal 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
37
frontend/src/lib/csv.ts
Normal 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
107
frontend/src/lib/dates.ts
Normal 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 0–1 range. UtilisationSummaryRow uses pct = 0..100 so caller must divide.
|
||||
return percentFormatter.format(fraction);
|
||||
}
|
||||
63
frontend/src/lib/filters.ts
Normal file
63
frontend/src/lib/filters.ts
Normal 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
18
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
172
frontend/src/pages/Bookings.tsx
Normal file
172
frontend/src/pages/Bookings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/pages/Department.tsx
Normal file
123
frontend/src/pages/Department.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/pages/Login.tsx
Normal file
98
frontend/src/pages/Login.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/pages/Resourcing.tsx
Normal file
109
frontend/src/pages/Resourcing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/pages/Tutorial.tsx
Normal file
69
frontend/src/pages/Tutorial.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/tailwind.config.js
Normal file
15
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal 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
30
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue