Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).
Backend — new endpoints + services:
- POST /api/deliverable/parse — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse — parse Project Summary CSV/XLSX
- GET /api/timelog/rows — paginated, searchable, sortable view
over the parsed Zoho upload
- GET /api/forecast — 4-week pipeline + capacity decision
- GET /api/project-types — hours/asset, duration, concentration
per project type + auto-insights
- POST /api/chat — Claude API proxy. 503s gracefully
when ANTHROPIC_API_KEY is unset.
Prompt-cached system prompt;
rate-limited 20/min/IP.
- GET /api/auth/me now returns role.
Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
projectType, assetCount, projectStatus, project start/end dates,
userAgency, employingCompany, sageJobProfile, …) with back-compat
aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.
Frontend — new pages + components:
- pages/Forecast.tsx — ComposedChart (stacked bars + line)
+ capacity-decision banner + table
- pages/ProjectTypeSummary.tsx — sortable table + small trend chart
- pages/TimeLogDetail.tsx — virtualised, searchable, sortable
view over all parsed timelog rows
- components/ChatView.tsx — floating side panel with Claude.
6 preset prompts mirroring the
original. Visible only for roles
with chat access.
- components/ChatToggle.tsx — bottom-right FAB.
- components/StatsBar.tsx — always-visible: Time Entries /
People / Projects / Total Hours /
Date Range.
- hooks/useDataContext.tsx — single source of truth for filter
state + parsed upload + filter
dimensions (brands/divs/hubs/
roles derived from uploads).
Frontend — modified:
- App.tsx, Navbar.tsx — 7 tabs + role gating per the
original TAB_ACCESS matrix.
- hooks/useAuth.tsx — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx — Brand / Division / Hub / Role
multiselects added (additive — keep
Department / Name / Billing).
- pages/Department, Resourcing,
Bookings, Tutorial.tsx — wired into DataContext; tutorial
is now a single 9-step global tour
mirroring the original's narrative.
Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
2.5 KiB
Python
80 lines
2.5 KiB
Python
"""Shared in-process cache for parsed uploads.
|
|
|
|
Decisions:
|
|
- We don't persist uploads to disk — they're held in memory keyed by
|
|
content sha256. This mirrors the original SPA's localStorage model:
|
|
the user re-uploads after a server restart.
|
|
- Each upload kind gets its own TTLCache (size-bounded, time-bounded).
|
|
Timelog stays small (8 entries, no explicit TTL — bounded LRU is fine).
|
|
Deliverable and ProjectSummary uploads use a 1h TTL to match the brief.
|
|
- All entries are stored as the raw dict the parser returned
|
|
({"rows", "unrecognised_columns", "content_hash"}).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from typing import Any
|
|
|
|
from cachetools import TTLCache
|
|
|
|
|
|
# Bounded LRU — keeps the most recent N timelog uploads for the session.
|
|
_MAX_TIMELOG = 8
|
|
_timelog_store: "OrderedDict[str, dict[str, Any]]" = OrderedDict()
|
|
|
|
# 1h TTL caches for deliverable + projectsummary, max 16 each.
|
|
_TTL_SECONDS = 60 * 60
|
|
_deliverable_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
|
|
_project_summary_store: TTLCache = TTLCache(maxsize=16, ttl=_TTL_SECONDS)
|
|
|
|
|
|
def _put_lru(store: "OrderedDict[str, dict[str, Any]]", parsed: dict[str, Any], max_size: int) -> None:
|
|
h = parsed["content_hash"]
|
|
store[h] = parsed
|
|
store.move_to_end(h)
|
|
while len(store) > max_size:
|
|
store.popitem(last=False)
|
|
|
|
|
|
# ---- Timelog ----------------------------------------------------------
|
|
|
|
def remember_timelog(parsed: dict[str, Any]) -> None:
|
|
_put_lru(_timelog_store, parsed, _MAX_TIMELOG)
|
|
|
|
|
|
def get_timelog(content_hash: str) -> dict[str, Any] | None:
|
|
return _timelog_store.get(content_hash)
|
|
|
|
|
|
# ---- Deliverable ------------------------------------------------------
|
|
|
|
def remember_deliverable(parsed: dict[str, Any]) -> None:
|
|
_deliverable_store[parsed["content_hash"]] = parsed
|
|
|
|
|
|
def get_deliverable(content_hash: str) -> dict[str, Any] | None:
|
|
try:
|
|
return _deliverable_store[content_hash]
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
# ---- Project Summary --------------------------------------------------
|
|
|
|
def remember_project_summary(parsed: dict[str, Any]) -> None:
|
|
_project_summary_store[parsed["content_hash"]] = parsed
|
|
|
|
|
|
def get_project_summary(content_hash: str) -> dict[str, Any] | None:
|
|
try:
|
|
return _project_summary_store[content_hash]
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
def clear_all() -> None:
|
|
"""Test helper — wipes every store."""
|
|
_timelog_store.clear()
|
|
_deliverable_store.clear()
|
|
_project_summary_store.clear()
|