From 8806b868b9ab501a89965bcd4ec6a8be6e61da94 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:14:50 +0200 Subject: [PATCH] feat(admin): scaffold de l'application d'administration apps/admin-web Nouvelle app Vite/React independante (workspace apps/*), calquee sur apps/web : port 5174, sa propre image Docker (nginx statique), son propre domaine/deploiement. - AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError. - AdminAuthContext / RequireAdmin : restaure la session admin via GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth). - LoginPage (/login) : validation cliente via adminLoginSchema partage, erreurs traduites via ErrorMessageService. - AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) + deconnexion, rendu une fois autour du groupe RequireAdmin. - Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux PR 3-5). - i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss copies de apps/web (extraction en package partage : suivi separe). - Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT, VITE_ADMIN_API_URL en build arg). - Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout .cy.ts (redirection /login sans session, nav entre sections, logout). Job "Run admin-web E2E tests" ajoute a ci.yml. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 7 + apps/admin-web/.env.example | 6 + apps/admin-web/Dockerfile | 25 ++ apps/admin-web/cypress.config.ts | 28 ++ apps/admin-web/cypress/e2e/admin-layout.cy.ts | 57 +++ apps/admin-web/cypress/e2e/login.feature | 24 ++ apps/admin-web/cypress/e2e/login.ts | 24 ++ apps/admin-web/cypress/support/e2e.ts | 3 + .../support/step_definitions/common.steps.ts | 54 +++ apps/admin-web/index.html | 12 + apps/admin-web/nginx.conf | 20 + apps/admin-web/package.json | 42 +++ apps/admin-web/src/App.tsx | 34 ++ apps/admin-web/src/api/client.ts | 96 +++++ .../src/features/auth/AdminAuthContext.tsx | 71 ++++ .../src/features/auth/RequireAdmin.tsx | 21 ++ .../src/features/auth/admin-auth.scss | 84 +++++ apps/admin-web/src/i18n/i18n.ts | 22 ++ apps/admin-web/src/layouts/AdminLayout.scss | 108 ++++++ apps/admin-web/src/layouts/AdminLayout.tsx | 83 +++++ apps/admin-web/src/lib/zod-errors.ts | 18 + .../admin-web/src/locales/fr/translation.json | 42 +++ apps/admin-web/src/main.tsx | 24 ++ apps/admin-web/src/pages/admin-page.scss | 26 ++ .../src/pages/corrections/CorrectionsPage.tsx | 19 + .../src/pages/dashboard/DashboardPage.tsx | 17 + apps/admin-web/src/pages/login/LoginPage.tsx | 85 +++++ .../src/pages/monitoring/MonitoringPage.tsx | 18 + .../src/services/error-message.service.ts | 19 + apps/admin-web/src/styles/_theme.scss | 171 +++++++++ apps/admin-web/src/styles/global.scss | 149 ++++++++ apps/admin-web/src/vite-env.d.ts | 5 + apps/admin-web/tsconfig.app.json | 14 + apps/admin-web/tsconfig.json | 8 + apps/admin-web/tsconfig.node.json | 12 + apps/admin-web/vite.config.ts | 19 + docker-compose.yml | 18 + pnpm-lock.yaml | 343 ++++++++++++++++++ 38 files changed, 1828 insertions(+) create mode 100644 apps/admin-web/.env.example create mode 100644 apps/admin-web/Dockerfile create mode 100644 apps/admin-web/cypress.config.ts create mode 100644 apps/admin-web/cypress/e2e/admin-layout.cy.ts create mode 100644 apps/admin-web/cypress/e2e/login.feature create mode 100644 apps/admin-web/cypress/e2e/login.ts create mode 100644 apps/admin-web/cypress/support/e2e.ts create mode 100644 apps/admin-web/cypress/support/step_definitions/common.steps.ts create mode 100644 apps/admin-web/index.html create mode 100644 apps/admin-web/nginx.conf create mode 100644 apps/admin-web/package.json create mode 100644 apps/admin-web/src/App.tsx create mode 100644 apps/admin-web/src/api/client.ts create mode 100644 apps/admin-web/src/features/auth/AdminAuthContext.tsx create mode 100644 apps/admin-web/src/features/auth/RequireAdmin.tsx create mode 100644 apps/admin-web/src/features/auth/admin-auth.scss create mode 100644 apps/admin-web/src/i18n/i18n.ts create mode 100644 apps/admin-web/src/layouts/AdminLayout.scss create mode 100644 apps/admin-web/src/layouts/AdminLayout.tsx create mode 100644 apps/admin-web/src/lib/zod-errors.ts create mode 100644 apps/admin-web/src/locales/fr/translation.json create mode 100644 apps/admin-web/src/main.tsx create mode 100644 apps/admin-web/src/pages/admin-page.scss create mode 100644 apps/admin-web/src/pages/corrections/CorrectionsPage.tsx create mode 100644 apps/admin-web/src/pages/dashboard/DashboardPage.tsx create mode 100644 apps/admin-web/src/pages/login/LoginPage.tsx create mode 100644 apps/admin-web/src/pages/monitoring/MonitoringPage.tsx create mode 100644 apps/admin-web/src/services/error-message.service.ts create mode 100644 apps/admin-web/src/styles/_theme.scss create mode 100644 apps/admin-web/src/styles/global.scss create mode 100644 apps/admin-web/src/vite-env.d.ts create mode 100644 apps/admin-web/tsconfig.app.json create mode 100644 apps/admin-web/tsconfig.json create mode 100644 apps/admin-web/tsconfig.node.json create mode 100644 apps/admin-web/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a2bbbf..d6890d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,3 +181,10 @@ jobs: # `component.devServer`), unlike `e2e` above which needs the real app # running first. - run: pnpm --filter web cy:run:component + # The admin app's own Cypress suite (`apps/admin-web`) — its own dev + # server on :5174, all `/admin/*` calls mocked via `cy.intercept` + # (no live backend needed), same as the `web` e2e run above. + - name: Run admin-web E2E tests + env: + HOST: "0.0.0.0" + run: pnpm --filter admin-web e2e diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example new file mode 100644 index 0000000..5559b08 --- /dev/null +++ b/apps/admin-web/.env.example @@ -0,0 +1,6 @@ +# Vite only exposes vars prefixed with VITE_ to client code. +# Base URL of the API's /admin/* surface. Empty string = same origin as the +# page (correct behind a shared reverse proxy). Native dev overrides it in +# apps/admin-web/.env since the Vite dev server (5174) and the API (3000) +# are different origins. +VITE_ADMIN_API_URL=http://localhost:3000 diff --git a/apps/admin-web/Dockerfile b/apps/admin-web/Dockerfile new file mode 100644 index 0000000..bf8d9a4 --- /dev/null +++ b/apps/admin-web/Dockerfile @@ -0,0 +1,25 @@ +# Its own image (not built into apps/api's) — the admin app is deployed +# independently of the main app. Build stage compiles the Vite bundle from +# the monorepo; runtime is a plain static nginx serving that bundle. +# +# Build context is the repo root (like apps/api/Dockerfile) — the workspace +# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve. +FROM node:22-slim AS build +RUN corepack enable +WORKDIR /repo +# Skip Cypress's Electron binary download — this image never runs it. +ENV CYPRESS_INSTALL_BINARY=0 +COPY . . +RUN pnpm install --frozen-lockfile +# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default "" +# (same-origin — correct behind a shared reverse proxy); override with +# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin +# app is served from a different origin than the API. +ARG VITE_ADMIN_API_URL="" +ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL +RUN pnpm --filter admin-web build + +FROM nginx:alpine AS runtime +COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/admin-web/cypress.config.ts b/apps/admin-web/cypress.config.ts new file mode 100644 index 0000000..e799b01 --- /dev/null +++ b/apps/admin-web/cypress.config.ts @@ -0,0 +1,28 @@ +import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor"; +import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild"; +import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; +import { defineConfig } from "cypress"; + +// Disable GPU for headless/sandboxed environments where no GPU device is +// available — same helper as apps/web's cypress.config.ts. +function disableGpu(on: Cypress.PluginEvents) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium") { + launchOptions.args.push("--disable-gpu", "--no-sandbox"); + } + return launchOptions; + }); +} + +export default defineConfig({ + e2e: { + baseUrl: "http://localhost:5174", + specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"], + async setupNodeEvents(on, config) { + disableGpu(on); + await addCucumberPreprocessorPlugin(on, config); + on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] })); + return config; + }, + }, +}); diff --git a/apps/admin-web/cypress/e2e/admin-layout.cy.ts b/apps/admin-web/cypress/e2e/admin-layout.cy.ts new file mode 100644 index 0000000..33cfff2 --- /dev/null +++ b/apps/admin-web/cypress/e2e/admin-layout.cy.ts @@ -0,0 +1,57 @@ +// Mocks the admin API via cy.intercept — no live backend (apps/api's Mocha +// suite covers real /admin/* behaviour). + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +describe("Admin layout", () => { + it("redirects to /login when there is no admin session", () => { + cy.intercept("GET", "**/admin/auth/me", { + statusCode: 401, + body: { code: 4011, message: "no" }, + }); + cy.visit("/monitoring"); + cy.url().should("include", "/login"); + cy.contains("h1", "Administration").should("be.visible"); + }); + + it("shows the sidebar and navigates between the three sections", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.visit("/"); + + cy.contains("h1", "Tableau de bord").should("be.visible"); + cy.contains(".admin-sidebar__who", "Ops").should("be.visible"); + + cy.contains("nav a", "Monitoring").click(); + cy.url().should("include", "/monitoring"); + cy.contains("h1", "Monitoring").should("be.visible"); + cy.contains("nav a", "Monitoring").should("have.class", "active"); + + cy.contains("nav a", "Corrections").click(); + cy.url().should("include", "/corrections"); + cy.contains("h1", "Corrections").should("be.visible"); + + cy.contains("nav a", "Tableau de bord").click(); + cy.url().should("eq", `${Cypress.config().baseUrl}/`); + }); + + it("logs out back to /login", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 }); + cy.visit("/"); + + // Wait until the guarded layout has actually mounted before acting. + cy.contains("h1", "Tableau de bord").should("be.visible"); + + // Logout clears the in-memory admin state, which is what bounces the + // guard to /login — no fresh `me` round-trip involved, so nothing to + // re-stub here. + cy.contains("button", "Se déconnecter").click(); + cy.url().should("include", "/login"); + }); +}); diff --git a/apps/admin-web/cypress/e2e/login.feature b/apps/admin-web/cypress/e2e/login.feature new file mode 100644 index 0000000..27ba050 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.feature @@ -0,0 +1,24 @@ +Feature: Admin login + As an operator + I want to sign in to the admin application + So that I can reach the metrics, monitoring and correction-triage sections + + Scenario: A wrong password shows a translated error, no redirect + Given the admin session check returns unauthenticated + And admin login fails with invalid credentials + When I visit "/login" + And I fill in the "email" field with "ops@example.com" + And I fill in the "password" field with "wrong" + And I click the button "Se connecter" + Then I should see "Email ou mot de passe incorrect" + And the URL should include "/login" + + Scenario: A correct login lands on the dashboard + Given the admin session check returns unauthenticated + And admin login succeeds as "Ops" + When I visit "/login" + And I fill in the "email" field with "ops@example.com" + And I fill in the "password" field with "correct-horse" + And I click the button "Se connecter" + Then the URL should not include "/login" + And I should see the heading "Tableau de bord" diff --git a/apps/admin-web/cypress/e2e/login.ts b/apps/admin-web/cypress/e2e/login.ts new file mode 100644 index 0000000..36baea8 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.ts @@ -0,0 +1,24 @@ +import { Given } from "@badeball/cypress-cucumber-preprocessor"; + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +Given("admin login fails with invalid credentials", () => { + cy.intercept("POST", "**/admin/auth/login", { + statusCode: 401, + body: { code: 4010, message: "Invalid email or password" }, + }); +}); + +Given("admin login succeeds as {string}", (name: string) => { + const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` }; + cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body }); + // After navigate("/"), RequireAdmin re-checks the session — from now on it + // must report authenticated (last matching intercept wins). + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body }); +}); diff --git a/apps/admin-web/cypress/support/e2e.ts b/apps/admin-web/cypress/support/e2e.ts new file mode 100644 index 0000000..62aa998 --- /dev/null +++ b/apps/admin-web/cypress/support/e2e.ts @@ -0,0 +1,3 @@ +// Cypress support file — global config and custom commands go here as the +// admin app grows. Same minimal starting point as apps/web's e2e.ts. +export {}; diff --git a/apps/admin-web/cypress/support/step_definitions/common.steps.ts b/apps/admin-web/cypress/support/step_definitions/common.steps.ts new file mode 100644 index 0000000..0ed80bf --- /dev/null +++ b/apps/admin-web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,54 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +// Steps shared across admin feature specs — navigation and generic UI +// assertions. Anything specific to one feature (its own API mocks, its own +// DOM structure) lives in that feature's own `.ts` step file. +// +// Every admin API call is mocked via `cy.intercept` — the Cypress suite +// never runs a live backend; apps/api's own Mocha suite covers real +// `/admin/*` behaviour. + +Given("the admin session check returns unauthenticated", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } }); +}); + +Given("I am signed in as admin {string}", (name: string) => { + cy.intercept("GET", "**/admin/auth/me", { + statusCode: 200, + body: { + id: 1, + email: `${name.toLowerCase()}@example.com`, + name, + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", + }, + }); +}); + +When("I visit {string}", (path: string) => { + cy.visit(path); +}); + +When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).clear().type(value); +}); + +When("I click the button {string}", (text: string) => { + cy.contains("button", text).click(); +}); + +Then("the URL should include {string}", (fragment: string) => { + cy.url().should("include", fragment); +}); + +Then("the URL should not include {string}", (fragment: string) => { + cy.url().should("not.include", fragment); +}); + +Then("I should see {string}", (text: string) => { + cy.contains(text).should("be.visible"); +}); + +Then("I should see the heading {string}", (text: string) => { + cy.contains("h1", text).should("be.visible"); +}); diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 0000000..8c44b55 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + batchCooking — Admin + + +
+ + + diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf new file mode 100644 index 0000000..cf18ff6 --- /dev/null +++ b/apps/admin-web/nginx.conf @@ -0,0 +1,20 @@ +# Static host for the built admin SPA. Client-side routing (react-router) +# means any unknown path must fall back to index.html rather than 404 — +# same reason apps/api serves its own SPA that way. +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache the fingerprinted assets Vite emits; never cache the HTML + # entry point so a new deploy is picked up immediately. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 0000000..fb4fabe --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,42 @@ +{ + "name": "admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5174", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5174", + "test": "echo \"no unit tests yet\" && exit 0", + "cy:open": "cypress open", + "cy:run": "cypress run", + "e2e": "start-server-and-test dev http://localhost:5174 cy:run" + }, + "dependencies": { + "@batch-cooking/date-tools": "workspace:*", + "@batch-cooking/shared": "workspace:*", + "i18next": "^26.3.6", + "lucide-react": "^1.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", + "react-router-dom": "^7.18.2", + "recharts": "^2.15.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@badeball/cypress-cucumber-preprocessor": "22.2.0", + "@bahmutov/cypress-esbuild-preprocessor": "2.2.8", + "@cypress/vite-dev-server": "5.2.1", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "cypress": "13.17.0", + "esbuild": "0.21.5", + "sass": "^1.102.0", + "start-server-and-test": "^2.0.8", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx new file mode 100644 index 0000000..ad9ba3d --- /dev/null +++ b/apps/admin-web/src/App.tsx @@ -0,0 +1,34 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { RequireAdmin } from "./features/auth/RequireAdmin"; +import { AdminLayout } from "./layouts/AdminLayout"; +import { CorrectionsPage } from "./pages/corrections/CorrectionsPage"; +import { DashboardPage } from "./pages/dashboard/DashboardPage"; +import { LoginPage } from "./pages/login/LoginPage"; +import { MonitoringPage } from "./pages/monitoring/MonitoringPage"; + +/** + * Admin app route table. `/login` is the only unauthenticated route; + * everything else is nested under one `RequireAdmin` + `AdminLayout` parent + * (the guard + sidebar chrome applied once), same shape as apps/web's + * `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login` + * when there's no admin session. + */ +export function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts new file mode 100644 index 0000000..132e39d --- /dev/null +++ b/apps/admin-web/src/api/client.ts @@ -0,0 +1,96 @@ +import { + type AdminLoginInput, + type AdminUserView, + type ApiErrorResponse, + ErrorCode, +} from "@batch-cooking/shared"; + +/** + * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` + * (see `.env.example`). Defaults to `""` (same origin) — correct behind a + * shared reverse proxy; native dev overrides it to `http://localhost:3000` + * in `apps/admin-web/.env` since the Vite dev server (5174) and the API + * (3000) are different origins. + */ +const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? ""; + +/** + * Thrown by {@link AdminApiClient} on any non-2xx response — carries the + * same {@link ErrorCode} the API returned. Same shape as apps/web's + * `ApiError`; kept separate rather than shared so the two apps' transport + * layers stay independent. + */ +export class ApiError extends Error { + public readonly status: number; + public readonly code: ErrorCode; + public readonly fieldErrors?: Record; + + public constructor(status: number, body: ApiErrorResponse) { + super(body.message); + this.name = "ApiError"; + this.status = status; + this.code = body.code; + this.fieldErrors = body.details; + } +} + +/** + * Thin fetch wrapper around the `/admin/*` endpoints — same design as + * apps/web's `ApiClient` (a class for cohesion/extensibility, one shared + * stateless instance). Every request sends credentials so the + * `admin_session` httpOnly cookie round-trips. + */ +export class AdminApiClient { + /** + * Performs a JSON request against the admin API and returns the parsed body. + * + * @throws {ApiError} if the response status is not in the 2xx range. + */ + private async _request( + path: string, + options: RequestInit = {}, + ): Promise { + try { + const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, { + ...options, + credentials: "include", + headers: { "Content-Type": "application/json", ...options.headers }, + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; + throw new ApiError( + response.status, + body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, + ); + } + + if (response.status === 204) { + return undefined as TResponseBody; + } + return (await response.json()) as TResponseBody; + } catch (err) { + // Rethrown as-is — callers surface it their own way; this is just the + // one place the fetch/`await` sits in a try/catch per the repo's rule. + throw err; + } + } + + /** Verifies admin credentials and starts an admin session. */ + public login(input: AdminLoginInput): Promise { + return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) }); + } + + /** Ends the current admin session. */ + public logout(): Promise { + return this._request("/admin/auth/logout", { method: "POST" }); + } + + /** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */ + public me(): Promise { + return this._request("/admin/auth/me"); + } +} + +/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ +export const adminApiClient = new AdminApiClient(); diff --git a/apps/admin-web/src/features/auth/AdminAuthContext.tsx b/apps/admin-web/src/features/auth/AdminAuthContext.tsx new file mode 100644 index 0000000..12611bd --- /dev/null +++ b/apps/admin-web/src/features/auth/AdminAuthContext.tsx @@ -0,0 +1,71 @@ +import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; +import { adminApiClient } from "../../api/client"; + +/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */ +interface AdminAuthContextValue { + /** Currently authenticated admin, or `null` if no active session. */ + admin: AdminUserView | null; + /** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */ + isLoading: boolean; + /** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */ + login: (input: AdminLoginInput) => Promise; + /** Ends the session and clears `admin`. */ + logout: () => Promise; +} + +const AdminAuthContext = createContext(null); + +/** + * Provides admin authentication state to the whole app. On mount, calls + * `GET /admin/auth/me` once to restore the session from the `admin_session` + * httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as + * apps/web's `AuthProvider`. + */ +export function AdminAuthProvider({ children }: { children: ReactNode }) { + const [admin, setAdmin] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + adminApiClient + .me() + .then(setAdmin) + // No/invalid session — the normal state for a first visit, not an error. + .catch(() => setAdmin(null)) + .finally(() => setIsLoading(false)); + }, []); + + const login = useCallback(async (input: AdminLoginInput) => { + try { + setAdmin(await adminApiClient.login(input)); + } catch (err) { + // Rethrown as-is — `LoginPage`'s submit handler catches and displays + // it; this callback just isn't allowed a bare `await`. + throw err; + } + }, []); + + const logout = useCallback(async () => { + try { + await adminApiClient.logout(); + setAdmin(null); + } catch (err) { + throw err; // see login()'s catch comment + } + }, []); + + return ( + + {children} + + ); +} + +/** Reads the current admin auth state/actions. Must be called within an {@link AdminAuthProvider}. */ +export function useAdminAuth(): AdminAuthContextValue { + const ctx = useContext(AdminAuthContext); + if (!ctx) { + throw new Error("useAdminAuth must be used within an AdminAuthProvider"); + } + return ctx; +} diff --git a/apps/admin-web/src/features/auth/RequireAdmin.tsx b/apps/admin-web/src/features/auth/RequireAdmin.tsx new file mode 100644 index 0000000..19e3fce --- /dev/null +++ b/apps/admin-web/src/features/auth/RequireAdmin.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAdminAuth } from "./AdminAuthContext"; + +/** + * Route guard for every admin page. Renders nothing while the initial + * `GET /admin/auth/me` check is pending (avoids a flash-then-redirect); + * once resolved, renders `children` or redirects to `/login`. Mirror of + * apps/web's `RequireAuth`. + */ +export function RequireAdmin({ children }: { children: ReactNode }) { + const { admin, isLoading } = useAdminAuth(); + + if (isLoading) { + return null; + } + if (!admin) { + return ; + } + return <>{children}; +} diff --git a/apps/admin-web/src/features/auth/admin-auth.scss b/apps/admin-web/src/features/auth/admin-auth.scss new file mode 100644 index 0000000..a47fb41 --- /dev/null +++ b/apps/admin-web/src/features/auth/admin-auth.scss @@ -0,0 +1,84 @@ +// ============================================================================= +// Admin login card — the only unauthenticated screen. A centered card on a +// plain background, same language as apps/web's auth-form.scss (kept its own +// copy rather than shared, the two apps' chrome is independent). +// ============================================================================= + +.admin-auth-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-lg); + background: var(--color-background); +} + +.admin-auth-card { + width: 100%; + max-width: var(--max-width-form); + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + + h1 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-sm); + } + + label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); + } + + input { + padding: var(--space-sm); + font-size: var(--font-size-base); + font-family: var(--font-body); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + + &:focus-visible { + border-color: var(--color-primary); + } + } + + button[type="submit"] { + margin-top: var(--space-sm); + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-base); + font-weight: 600; + font-family: var(--font-body); + color: var(--color-surface); + background: var(--color-primary); + border: none; + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + .field-error { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-error); + } + + .form-error { + margin: var(--space-xs) 0 0; + font-size: var(--font-size-sm); + color: var(--color-error); + } +} diff --git a/apps/admin-web/src/i18n/i18n.ts b/apps/admin-web/src/i18n/i18n.ts new file mode 100644 index 0000000..96e7f52 --- /dev/null +++ b/apps/admin-web/src/i18n/i18n.ts @@ -0,0 +1,22 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import fr from "../locales/fr/translation.json"; + +/** + * i18next instance for the admin app, imported once for its side effect + * (`main.tsx`) before anything renders. Only French exists today — same + * setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the + * two apps' copy never has to be kept identical. `packages/shared`'s + * `ErrorCode` member names double as keys under the `errors` namespace + * (see `services/error-message.service.ts`). + */ +void i18next.use(initReactI18next).init({ + resources: { + fr: { translation: fr }, + }, + lng: "fr", + fallbackLng: "fr", + interpolation: { escapeValue: false }, +}); + +export default i18next; diff --git a/apps/admin-web/src/layouts/AdminLayout.scss b/apps/admin-web/src/layouts/AdminLayout.scss new file mode 100644 index 0000000..ca14d96 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.scss @@ -0,0 +1,108 @@ +// ============================================================================= +// Admin app shell — a fixed left sidebar + scrollable main content area. +// Simpler than apps/web's AppLayout (no collapsible rail, no nested submenu) +// — an internal ops tool, three sections. +// ============================================================================= + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + flex-shrink: 0; + width: 15rem; + display: flex; + flex-direction: column; + padding: var(--space-lg) var(--space-md); + background: var(--color-surface); + border-right: 1px solid var(--color-border); + + &__brand { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-text); + margin-bottom: var(--space-lg); + + span { + color: var(--color-accent); + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 0.15rem; + + a { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm); + border-radius: var(--radius-base); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: 600; + + &:hover { + background: var(--color-surface-alt); + color: var(--color-text); + } + + &.active { + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + } + } + } + + &__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); + } + + &__who { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__footer button { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + font-family: var(--font-body); + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + text-align: left; + + &:hover { + color: var(--color-text); + border-color: var(--color-primary); + } + } + + &__version { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +} + +.admin-content { + flex: 1; + min-width: 0; + padding: var(--space-xl); + overflow: auto; +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..341fce7 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -0,0 +1,83 @@ +import { Activity, LayoutDashboard, ListChecks } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAdminAuth } from "../features/auth/AdminAuthContext"; +import "./AdminLayout.scss"; + +/** + * One entry in the admin sidebar's nav. `key` maps to `admin.nav.` in + * the locale file — adding a section is one array entry plus one locale key. + */ +const NAV_ITEMS = [ + { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, + { to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, + { to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, +] as const; + +/** + * Shell for every authenticated admin page: a fixed sidebar (brand, section + * nav, the signed-in admin's name + logout) plus a main area rendering the + * matched child route via ``. Mounted once as the parent of the + * whole `RequireAdmin`-guarded route group (see `App.tsx`), so `admin` is + * guaranteed non-null here. + */ +export function AdminLayout() { + const { t } = useTranslation(); + const { admin, logout } = useAdminAuth(); + const navigate = useNavigate(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + async function handleLogout() { + setIsLoggingOut(true); + try { + await logout(); + void navigate("/login"); + } catch { + // Even if the network call failed, the local session state was + // cleared optimistically enough for the guard to bounce to /login; + // nothing useful to show the operator here. + void navigate("/login"); + } + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/admin-web/src/lib/zod-errors.ts b/apps/admin-web/src/lib/zod-errors.ts new file mode 100644 index 0000000..ab9174b --- /dev/null +++ b/apps/admin-web/src/lib/zod-errors.ts @@ -0,0 +1,18 @@ +import type { ZodError } from "zod"; + +/** + * Flattens a zod validation error into `{ fieldName: firstMessage }` for + * inline display under each form field — verbatim copy of apps/web's + * `lib/zod-errors.ts` (only the first message per field, enough for the + * single-rule-per-field schemas used here). + */ +export function fieldErrorsFrom(error: ZodError): Record { + const fieldErrors = error.flatten().fieldErrors; + const firstMessagePerField: Record = {}; + for (const [field, messages] of Object.entries(fieldErrors)) { + if (messages?.[0]) { + firstMessagePerField[field] = messages[0]; + } + } + return firstMessagePerField; +} diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json new file mode 100644 index 0000000..198b1d2 --- /dev/null +++ b/apps/admin-web/src/locales/fr/translation.json @@ -0,0 +1,42 @@ +{ + "errors": { + "VALIDATION_ERROR": "Erreur de validation", + "INVALID_CREDENTIALS": "Email ou mot de passe incorrect", + "NOT_AUTHENTICATED": "Vous devez être connecté", + "NOT_FOUND": "Ressource introuvable", + "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", + "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" + }, + "admin": { + "common": { + "comingSoon": "Section à venir." + }, + "login": { + "title": "Administration", + "emailLabel": "Email", + "passwordLabel": "Mot de passe", + "submit": "Se connecter", + "submitting": "Connexion…" + }, + "nav": { + "dashboard": "Tableau de bord", + "monitoring": "Monitoring", + "corrections": "Corrections" + }, + "layout": { + "logout": "Se déconnecter" + }, + "dashboard": { + "title": "Tableau de bord", + "lead": "Métriques d'utilisation de l'application." + }, + "monitoring": { + "title": "Monitoring", + "lead": "Santé des microservices et de la base de données." + }, + "corrections": { + "title": "Corrections", + "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP." + } + } +} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx new file mode 100644 index 0000000..a713866 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -0,0 +1,24 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import { AdminAuthProvider } from "./features/auth/AdminAuthContext"; +// Side-effect import: initializes i18next before anything renders. +import "./i18n/i18n"; +// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import. +import "./styles/global.scss"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render( + + + + + + + , +); diff --git a/apps/admin-web/src/pages/admin-page.scss b/apps/admin-web/src/pages/admin-page.scss new file mode 100644 index 0000000..34eefbe --- /dev/null +++ b/apps/admin-web/src/pages/admin-page.scss @@ -0,0 +1,26 @@ +// ============================================================================= +// Shared chrome for every routed admin page — a page title and an optional +// lead paragraph. Individual pages add their own colocated .scss for their +// specific content (charts, tables, status board) on top. +// ============================================================================= + +.admin-page { + &__title { + font-size: var(--font-size-2xl); + margin-bottom: var(--space-xs); + } + + &__lead { + margin: 0 0 var(--space-lg); + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__placeholder { + padding: var(--space-xl); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + text-align: center; + } +} diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx new file mode 100644 index 0000000..a197e9d --- /dev/null +++ b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Tech-step correction triage — review `TechStepTrainingSuggestion` / + * `StepTechStepCorrection`, mark applied/rejected, generate the + * `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder + * until PR 5 (correction triage + retrain). + */ +export function CorrectionsPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.corrections.title")}

+

{t("admin.corrections.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..5eab1a5 --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Usage-metrics dashboard — KPI tiles + trend charts fed by + * `GET /admin/metrics`. Placeholder until PR 3 (metrics) fills it in. + */ +export function DashboardPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.dashboard.title")}

+

{t("admin.dashboard.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/pages/login/LoginPage.tsx b/apps/admin-web/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..f984391 --- /dev/null +++ b/apps/admin-web/src/pages/login/LoginPage.tsx @@ -0,0 +1,85 @@ +import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared"; +import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ApiError } from "../../api/client"; +import { useAdminAuth } from "../../features/auth/AdminAuthContext"; +import "../../features/auth/admin-auth.scss"; +import { fieldErrorsFrom } from "../../lib/zod-errors"; +import { errorMessageService } from "../../services/error-message.service"; + +/** + * The admin login screen — the only unauthenticated route. Client-side + * validation via the shared `adminLoginSchema` (same rules the API + * enforces), then `POST /admin/auth/login`; any API failure is translated + * to a localized label via {@link ErrorMessageService}. Same structure as + * apps/web's `LoginPage`. + */ +export function LoginPage() { + const { login } = useAdminAuth(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + + const result = adminLoginSchema.safeParse({ email, password }); + if (!result.success) { + setFieldErrors(fieldErrorsFrom(result.error)); + return; + } + setFieldErrors({}); + + setIsSubmitting(true); + try { + await login(result.data); + void navigate("/"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

{t("admin.login.title")}

+ + + setEmail(e.target.value)} + autoComplete="email" + /> + {fieldErrors.email &&

{fieldErrors.email}

} + + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {fieldErrors.password &&

{fieldErrors.password}

} + + {formError &&

{formError}

} + + +
+
+ ); +} diff --git a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx new file mode 100644 index 0000000..f531842 --- /dev/null +++ b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Microservice health board — active probes of Postgres, the API, the + * intent-service and the LLM worker's heartbeat, fed by + * `GET /admin/monitoring`. Placeholder until PR 4 (monitoring). + */ +export function MonitoringPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.monitoring.title")}

+

{t("admin.monitoring.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/services/error-message.service.ts b/apps/admin-web/src/services/error-message.service.ts new file mode 100644 index 0000000..e1ea838 --- /dev/null +++ b/apps/admin-web/src/services/error-message.service.ts @@ -0,0 +1,19 @@ +import { ErrorCode } from "@batch-cooking/shared"; +import i18n from "../i18n/i18n"; + +/** + * Localized label for an {@link ErrorCode} returned by the admin API — + * verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the + * numeric enum value to its member name (`4010` → `"INVALID_CREDENTIALS"`) + * and looks it up under the `errors` namespace, falling back to + * `INTERNAL_ERROR` for a code this client doesn't recognise. + */ +export class ErrorMessageService { + public getLabel(code: ErrorCode): string { + const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR]; + return i18n.t(`errors.${memberName}`); + } +} + +/** Single shared instance — stateless. */ +export const errorMessageService = new ErrorMessageService(); diff --git a/apps/admin-web/src/styles/_theme.scss b/apps/admin-web/src/styles/_theme.scss new file mode 100644 index 0000000..22b765d --- /dev/null +++ b/apps/admin-web/src/styles/_theme.scss @@ -0,0 +1,171 @@ +// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these +// tokens into a shared package (consumed by both apps) is tracked separately +// — keep the two files in sync by hand until then. +// ============================================================================= +// Design tokens — the single source of truth for colors, spacing, typography +// and other reusable values across the whole app. +// +// Exposed as CSS custom properties on :root (not plain SCSS variables) so +// they're available at *runtime*, not just compile time — this is what lets +// dark mode work below by simply redefining these variables instead of +// rebuilding the stylesheet. Every other .scss file should reference +// `var(--token-name)`, never a hardcoded color/size. +// +// Palette name: "Mise en Place" — a kitchen-operations identity (batch +// cooking as logistics: everything labeled and in its place before you +// start) rather than a food-blog one. See the design proposal for the full +// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f +// +// Import this partial once, globally (see global.scss) — never re-import it +// from a component-level .scss file, `:root` only needs to be declared once. +// ============================================================================= + +:root { + // --- Surfaces & ink --------------------------------------------------- + // Neutral surface: page background ("porcelaine") vs. the card surface + // content sits on, plus a recessed variant for panels/table headers. + --color-background: #eef2ed; + --color-surface: #ffffff; + --color-surface-alt: #e2e8e0; + // Text. + --color-text: #1f2a22; + --color-text-muted: #57685a; + --color-border: #c7d0c4; + + // --- Brand accents, each with one job -------------------------------- + // Basil — primary actions, links, brand presence. + --color-primary: #2e6b4a; + --color-primary-hover: #244f38; + // Vermillion — secondary accent for urgency/strong calls to action + // (e.g. a timer, "start session"). Never reused for allergen alerts + // below — those need their own, unambiguous color. + --color-accent: #cc4b26; + --color-accent-hover: #a83c1c; + // Turmeric — category/classification tags. + --color-tag: #c98a1b; + --color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only. + + // --- Feedback ----------------------------------------------------------- + --color-success: #2e6b4a; + --color-warning: #c98a1b; + --color-error: #b3271e; + + // --- Allergens / intolerances -------------------------------------------- + // A 3-tier food-safety scale, kept distinct from --color-error so an + // allergen warning is never confused with a form validation error: + // - critical (declared allergen): its own color, solid/inverted fill + // - moderate (intolerance): reuses --color-warning, tinted fill + // - trace ("may contain traces of…"): neutral, dashed outline + // The severity is carried by the FILL TREATMENT, not the hue alone, so + // it stays legible for color-blind users. See the design proposal's + // "Alertes & allergènes" section for the full component set. + --color-allergen: #a8123f; + --color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only. + + // --- Spacing scale --------------------------------------------------------- + // Multiples of a 4px base unit — use these instead of ad hoc px values so + // spacing stays visually consistent as the app grows. + --space-xs: 0.25rem; // 4px + --space-sm: 0.5rem; // 8px + --space-md: 1rem; // 16px + --space-lg: 1.5rem; // 24px + --space-xl: 2rem; // 32px + --space-2xl: 3rem; // 48px — inter-section spacing + + // --- Typography -------------------------------------------------------- + // System font stacks only — no remote webfont, so there's zero loading + // latency and no flash of unstyled text, which fits an app meant to be + // used quickly under time pressure. Three roles: a condensed "label" + // face for headings/eyebrows, a humanist face for body copy, and a + // monospace for anything that lines up in columns (times, quantities). + --font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif; + --font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace; + + --font-size-xs: 0.75rem; // 12px — captions, meta + --font-size-sm: 0.875rem; // 14px — labels, secondary text + --font-size-base: 1rem; // 16px — body + --font-size-md: 1.125rem; // 18px — lead paragraph + --font-size-lg: 1.375rem; // 22px — H3 / card titles + --font-size-xl: 1.75rem; // 28px — H2 / section titles + --font-size-2xl: 2.25rem; // 36px — H1 / page titles + --font-size-3xl: 3rem; // 48px — display, exceptional use only + + // --- Shape / elevation -------------------------------------------------- + --radius-base: 4px; // controls (inputs, buttons) — deliberately flat + --radius-md: 10px; // cards + --radius-lg: 18px; // panels, modals + --radius-pill: 999px; // tags, badges + --max-width-form: 22rem; + + --shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06); + --shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08); +} + +// Lets the browser pick sensible default colors (form controls, scrollbars) +// for whichever mode the user ends up in. +:root { + color-scheme: light dark; +} + +// --- Dark mode --------------------------------------------------------- +// Follows the OS/browser preference by default. Guarded with +// `:root:not([data-theme="light"])` so an explicit "light" choice (see +// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all — +// this block then decides) can override a dark OS setting. +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --color-background: #14181a; + --color-surface: #1c221e; + --color-surface-alt: #262e27; + --color-text: #edf1ea; + --color-text-muted: #a9b5a6; + --color-border: #384038; + + --color-primary: #5fae7e; + --color-primary-hover: #7cc496; + --color-accent: #ea7a48; + --color-accent-hover: #f0946c; + --color-tag: #e8b84b; + --color-tag-ink: #2a2005; + + --color-success: #5fae7e; + --color-warning: #e8b84b; + --color-error: #e5675a; + + --color-allergen: #e2547b; + --color-allergen-ink: #3a0416; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); + } +} + +// Mirrors the block above for an explicit "dark" choice (`ThemeContext` +// sets `data-theme="dark"` on ``), so it wins over the OS setting in +// both directions. +:root[data-theme="dark"] { + --color-background: #14181a; + --color-surface: #1c221e; + --color-surface-alt: #262e27; + --color-text: #edf1ea; + --color-text-muted: #a9b5a6; + --color-border: #384038; + + --color-primary: #5fae7e; + --color-primary-hover: #7cc496; + --color-accent: #ea7a48; + --color-accent-hover: #f0946c; + --color-tag: #e8b84b; + --color-tag-ink: #2a2005; + + --color-success: #5fae7e; + --color-warning: #e8b84b; + --color-error: #e5675a; + + --color-allergen: #e2547b; + --color-allergen-ink: #3a0416; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); +} diff --git a/apps/admin-web/src/styles/global.scss b/apps/admin-web/src/styles/global.scss new file mode 100644 index 0000000..dffae1e --- /dev/null +++ b/apps/admin-web/src/styles/global.scss @@ -0,0 +1,149 @@ +// ============================================================================= +// Global stylesheet — imported exactly once, in main.tsx. Contains only +// truly app-wide rules: the theme tokens and a minimal reset/base styling +// that every page inherits. Anything specific to one component or page +// belongs in a .scss file colocated next to that component/page instead. +// ============================================================================= + +@use "./theme"; + +// Include borders/padding in an element's declared width/height everywhere, +// rather than the browser default of adding them on top. +*, +*::before, +*::after { + box-sizing: border-box; +} + +// Minimal reset: remove the default body margin so pages can control their +// own layout without fighting the browser's default 8px margin. +body { + margin: 0; + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: 1.55; + color: var(--color-text); + background: var(--color-background); +} + +// Headings use the condensed "label" face app-wide — see _theme.scss for +// the rationale. `text-wrap: balance` avoids a lone short word wrapping +// onto its own line in multi-line titles. +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-family: var(--font-display); + font-weight: 700; + text-wrap: balance; +} + +// Default to the page-title size; a heading used as a smaller component +// title (e.g. the auth card's

) overrides this in its own stylesheet. +h1 { + font-size: var(--font-size-2xl); +} + +// A visible, consistent focus ring for keyboard navigation — the browser +// default varies a lot between elements and browsers. +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +// Checkbox/radio appearance, app-wide — "selectable card" style: the native +// control itself is visually hidden (still real, focusable and +// screen-reader-visible — see the `input[type=...]` rule below, not +// `display: none`) and the whole label row it lives in becomes the +// interactive surface instead: a flat bordered box that fills in with a +// tinted background + primary border once selected, with a checkmark +// fading in on the leading edge. +// +// The base (unselected) look below is detected structurally with `:has()` +// — safe, since "does this label contain a checkbox/radio" never changes +// after mount. The *selected* look is instead driven by the `is-selected` +// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle +// in JS from the same boolean their caller already passes to `checked` — +// chaining a second `:has(:checked)` to react to that live state turned +// out to be unreliable across browsers, so this only needs one +// always-true `:has()`. +// +// Every checkbox/radio in the app goes through this one place (the allergy +// grid, the theme picker, anywhere future) rather than each feature styling +// its own — see profile-forms.scss / settings-pages.scss, which only +// arrange these within their own layout (grid vs. stacked list) and +// intentionally don't re-style the control/label look itself. +label:has(> input[type="checkbox"]), +label:has(> input[type="radio"]) { + position: relative; + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + // Overrides the generic `label { font-weight: 600 }` base rule + // (profile-forms.scss) — without this, an *unselected* row reads just as + // bold as a selected one (only `.allergy-select__option` happened to set + // its own 400 already; `.theme-select__option` didn't, so its rows were + // all permanently bold until this was centralized here). + font-weight: 400; + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + &:hover { + border-color: var(--color-primary); + } + + &.is-selected { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + font-weight: 600; + } + + &:has(:focus-visible) { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } +} + +// The control itself is removed from the visual flow — hidden the +// "sr-only" way (not `display: none`) so it stays focusable/tabbable and +// announced correctly by screen readers; the label above carries the +// entire visible selected/unchecked look. +input[type="checkbox"], +input[type="radio"] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; +} + +// The checkmark — a real element (see components/ui/Checkbox.tsx / +// Radio.tsx) shown via the same `is-selected` class as the label's own +// look above, not a separate CSS-only trigger. Scaled in from nothing so +// toggling has a bit of motion. Same mark for both checkbox and radio: one +// consistent "selected" language app-wide rather than a checkmark here and +// a dot there. Sits first in the row (before the label text, per DOM +// order) — a classic "control on the left" layout rather than trailing. +.check-mark { + flex: none; + width: 0.9rem; + height: 0.9rem; + background: var(--color-primary); + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + transform: scale(0); + transition: transform 0.1s ease; +} + +.is-selected .check-mark { + transform: scale(1); +} diff --git a/apps/admin-web/src/vite-env.d.ts b/apps/admin-web/src/vite-env.d.ts new file mode 100644 index 0000000..a4754d7 --- /dev/null +++ b/apps/admin-web/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// + +// Injected by `define` in vite.config.ts, sourced from package.json's +// version field — rendered in AdminLayout.tsx. +declare const __APP_VERSION__: string; diff --git a/apps/admin-web/tsconfig.app.json b/apps/admin-web/tsconfig.app.json new file mode 100644 index 0000000..af5b6cb --- /dev/null +++ b/apps/admin-web/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json new file mode 100644 index 0000000..558996e --- /dev/null +++ b/apps/admin-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/admin-web/tsconfig.node.json b/apps/admin-web/tsconfig.node.json new file mode 100644 index 0000000..a0686f8 --- /dev/null +++ b/apps/admin-web/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "composite": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts new file mode 100644 index 0000000..9a52da9 --- /dev/null +++ b/apps/admin-web/vite.config.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Read once at config-eval time — same trick as apps/web's vite.config.ts. +const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8")); + +export default defineConfig({ + plugins: [react()], + server: { port: 5174 }, + css: { + preprocessorOptions: { + scss: { api: "modern-compiler" }, + }, + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml index e62058a..64a8a02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,24 @@ services: # Dockerfile doc comment on its VOLUME declaration. - tech_step_llm_worker_models:/worker/models + # The admin application's frontend (apps/admin-web) — a static nginx image, + # entirely independent of `app` (its own build, its own URL). Talks to + # `app`'s /admin/* surface. Optional: a stack that doesn't need the admin + # app just omits this service. `VITE_ADMIN_API_URL` is baked in at build + # time — set it as a build arg when the admin app and the API sit on + # different public origins (default "" = same origin, for a shared proxy). + admin-web: + build: + context: . + dockerfile: apps/admin-web/Dockerfile + args: + VITE_ADMIN_API_URL: ${VITE_ADMIN_API_URL:-} + restart: unless-stopped + depends_on: + - app + ports: + - "${ADMIN_WEB_PORT:-3001}:80" + volumes: postgres_data: tech_step_llm_worker_models: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c06548d..1f970c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,79 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/admin-web: + dependencies: + '@batch-cooking/date-tools': + specifier: workspace:* + version: link:../../packages/date-tools + '@batch-cooking/shared': + specifier: workspace:* + version: link:../../packages/shared + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@5.9.3) + lucide-react: + specifier: ^1.32.0 + version: 1.32.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-router-dom: + specifier: ^7.18.2 + version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.15.0 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@badeball/cypress-cucumber-preprocessor': + specifier: 22.2.0 + version: 22.2.0(@babel/core@7.29.7)(cypress@13.17.0)(typescript@5.9.3) + '@bahmutov/cypress-esbuild-preprocessor': + specifier: 2.2.8 + version: 2.2.8(esbuild@0.21.5) + '@cypress/vite-dev-server': + specifier: 5.2.1 + version: 5.2.1 + '@types/node': + specifier: ^22.9.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0)) + cypress: + specifier: 13.17.0 + version: 13.17.0 + esbuild: + specifier: 0.21.5 + version: 0.21.5 + sass: + specifier: ^1.102.0 + version: 1.102.0 + start-server-and-test: + specifier: ^2.0.8 + version: 2.1.5 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.1)(sass@1.102.0) + apps/api: dependencies: '@batch-cooking/date-tools': @@ -1147,6 +1220,33 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==, tarball: https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==, tarball: https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, tarball: https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, tarball: https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, tarball: https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, tarball: https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, tarball: https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==, tarball: https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, tarball: https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, tarball: https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz} @@ -1589,6 +1689,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, tarball: https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} engines: {node: '>=7.0.0'} @@ -1727,6 +1831,50 @@ packages: engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} hasBin: true + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, tarball: https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, tarball: https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, tarball: https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==, tarball: https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, tarball: https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, tarball: https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, tarball: https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, tarball: https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, tarball: https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, tarball: https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, tarball: https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz} + engines: {node: '>=12'} + dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==, tarball: https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz} engines: {node: '>=0.10'} @@ -1772,6 +1920,9 @@ packages: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==, tarball: https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz} engines: {node: '>=10'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==, tarball: https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, tarball: https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz} engines: {node: '>=6'} @@ -1873,6 +2024,9 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==, tarball: https://registry.npmjs.org/diff/-/diff-7.0.0.tgz} engines: {node: '>=0.3.1'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==, tarball: https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} @@ -2032,6 +2186,9 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==, tarball: https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz} + execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==, tarball: https://registry.npmjs.org/execa/-/execa-4.1.0.tgz} engines: {node: '>=10'} @@ -2060,6 +2217,10 @@ packages: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==, tarball: https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz} engines: {'0': node >=0.6.0} + fast-equals@5.4.1: + resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} engines: {node: '>=8.6.0'} @@ -2367,6 +2528,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==, tarball: https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, tarball: https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz} + engines: {node: '>=12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz} engines: {node: '>= 0.10'} @@ -3025,6 +3190,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==, tarball: https://registry.npmjs.org/progress/-/progress-2.0.3.tgz} engines: {node: '>=0.4.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} + property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==, tarball: https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz} @@ -3093,6 +3261,12 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, tarball: https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, tarball: https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz} engines: {node: '>=0.10.0'} @@ -3114,6 +3288,18 @@ packages: react-dom: optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==, tarball: https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==, tarball: https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz} engines: {node: '>=0.10.0'} @@ -3142,6 +3328,17 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz} engines: {node: '>= 20.19.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==, tarball: https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==, tarball: https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz} + engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==, tarball: https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz} @@ -3484,6 +3681,9 @@ packages: tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, tarball: https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz} engines: {node: '>=12.0.0'} @@ -3646,6 +3846,9 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==, tarball: https://registry.npmjs.org/verror/-/verror-1.10.0.tgz} engines: {'0': node >=0.6.0} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==, tarball: https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.21.tgz} engines: {node: ^18.0.0 || >=20.0.0} @@ -4622,6 +4825,30 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.9': {} '@types/express-serve-static-core@4.19.9': @@ -5106,6 +5333,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5256,6 +5485,44 @@ snapshots: untildify: 4.0.0 yauzl: 2.10.0 + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + dashdash@1.14.1: dependencies: assert-plus: 1.0.0 @@ -5284,6 +5551,8 @@ snapshots: decamelize@4.0.0: {} + decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} deep-equal@2.2.3: @@ -5411,6 +5680,11 @@ snapshots: diff@7.0.0: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -5614,6 +5888,8 @@ snapshots: eventemitter2@6.4.7: {} + eventemitter3@4.0.7: {} + execa@4.1.0: dependencies: cross-spawn: 7.0.6 @@ -5692,6 +5968,8 @@ snapshots: extsprintf@1.3.0: {} + fast-equals@5.4.1: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6037,6 +6315,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + ipaddr.js@1.9.1: {} is-arguments@1.2.0: @@ -6673,6 +6953,12 @@ snapshots: progress@2.0.3: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-expr@2.0.6: {} proxy-addr@2.0.7: @@ -6736,6 +7022,10 @@ snapshots: react-dom: 18.3.1(react@18.3.1) typescript: 5.9.3 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-refresh@0.17.0: {} react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -6752,6 +7042,23 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.4.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -6784,6 +7091,23 @@ snapshots: readdirp@5.1.1: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + reflect-metadata@0.2.2: {} regexp-match-indices@1.0.2: @@ -7212,6 +7536,8 @@ snapshots: tiny-case@1.0.3: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -7333,6 +7659,23 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@5.4.21(@types/node@22.20.1)(sass@1.102.0): dependencies: esbuild: 0.21.5