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>
102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
"""Builds the dashboard-context system prompt the AI chat reads from.
|
|
|
|
Mirrors `src/lib/aiContextBuilder.ts` in the original SPA — kept small
|
|
and human-readable so the LLM can cite specific numbers back to the
|
|
user.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from typing import Any, Iterable
|
|
|
|
from app.services.project_types import build_project_types
|
|
|
|
|
|
def _is_complete(s: str | None) -> bool:
|
|
if not s:
|
|
return False
|
|
n = s.upper()
|
|
return n in {"APPROVED", "COMPLETE", "CANCELLED", "REJECTED", "DECLINED"}
|
|
|
|
|
|
def build_dashboard_context(
|
|
*,
|
|
logs: list[dict[str, Any]],
|
|
project_summary: list[dict[str, Any]] | None = None,
|
|
) -> str:
|
|
today = date.today().isoformat()
|
|
lines: list[str] = [f"Today: {today}", ""]
|
|
|
|
# Time-log summary.
|
|
people: set[str] = set()
|
|
projects: set[str] = set()
|
|
total_hours = 0.0
|
|
dates: list[str] = []
|
|
for r in logs:
|
|
ident = r.get("submitterEmail") or r.get("submitter") or ""
|
|
if ident:
|
|
people.add(str(ident))
|
|
title = r.get("projectTitle") or r.get("projectNumber") or ""
|
|
if title:
|
|
projects.add(str(title))
|
|
total_hours += float(r.get("hoursLogged") or r.get("hours") or 0)
|
|
d = r.get("date")
|
|
if d:
|
|
dates.append(str(d))
|
|
dates.sort()
|
|
lines.append("## TIME LOG")
|
|
lines.append(f"{len(logs):,} entries | {len(people)} people | {len(projects)} projects | {total_hours:.0f}h total")
|
|
if dates:
|
|
lines.append(f"Date range: {dates[0]} to {dates[-1]}")
|
|
lines.append("")
|
|
|
|
# Benchmark by project type (top types).
|
|
types = build_project_types(logs=logs, project_summary=project_summary or None)
|
|
stats = types.get("stats", []) or []
|
|
if stats:
|
|
lines.append(f"## BENCHMARK ({sum(s['projectCount'] for s in stats)} projects across {len(stats)} types)")
|
|
for s in stats[:12]:
|
|
lines.append(
|
|
f" {s['projectType']}: "
|
|
f"{s['avgHoursPerAsset']:.1f}h/asset | "
|
|
f"{s['projectCount']} projects | "
|
|
f"{s['totalAssets']:.0f} assets | "
|
|
f"avg {s['avgDurationDays']:.0f}d"
|
|
)
|
|
lines.append("")
|
|
|
|
# Active pipeline from Project Summary.
|
|
if project_summary:
|
|
active = [ps for ps in project_summary
|
|
if not _is_complete(ps.get("projectStatus"))
|
|
and ps.get("projectStartDate")
|
|
and ps["projectStartDate"] <= today
|
|
and (not ps.get("projectEndDate") or ps["projectEndDate"] >= today)]
|
|
upcoming = [ps for ps in project_summary
|
|
if not _is_complete(ps.get("projectStatus"))
|
|
and (ps.get("projectStartDate") or "") > today]
|
|
# Exiting this week (Mon-Fri).
|
|
d = date.today()
|
|
mon = d - timedelta(days=d.weekday())
|
|
fri = mon + timedelta(days=4)
|
|
exiting = [ps for ps in project_summary
|
|
if not _is_complete(ps.get("projectStatus"))
|
|
and ps.get("projectEndDate")
|
|
and mon.isoformat() <= ps["projectEndDate"] <= fri.isoformat()]
|
|
total_active_assets = sum(float(ps.get("assetCount") or 0) for ps in active)
|
|
lines.append("## ACTIVE PIPELINE")
|
|
lines.append(
|
|
f"Active: {len(active)} projects ({total_active_assets:,.0f} assets) | "
|
|
f"Upcoming: {len(upcoming)} | Exiting this week: {len(exiting)}"
|
|
)
|
|
if exiting:
|
|
lines.append("Exiting this week:")
|
|
for ps in exiting[:20]:
|
|
lines.append(
|
|
f" {ps.get('projectNumber','')} | {ps.get('projectTitle','')} | "
|
|
f"{(ps.get('assetCount') or 0):.0f} assets | ends {ps.get('projectEndDate')}"
|
|
)
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|