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 <noreply@anthropic.com>
This commit is contained in:
parent
cf8ef26f63
commit
8a6143d54d
38 changed files with 1828 additions and 0 deletions
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -177,3 +177,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
|
||||
|
|
|
|||
6
apps/admin-web/.env.example
Normal file
6
apps/admin-web/.env.example
Normal file
|
|
@ -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
|
||||
25
apps/admin-web/Dockerfile
Normal file
25
apps/admin-web/Dockerfile
Normal file
|
|
@ -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
|
||||
28
apps/admin-web/cypress.config.ts
Normal file
28
apps/admin-web/cypress.config.ts
Normal file
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
57
apps/admin-web/cypress/e2e/admin-layout.cy.ts
Normal file
57
apps/admin-web/cypress/e2e/admin-layout.cy.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
24
apps/admin-web/cypress/e2e/login.feature
Normal file
24
apps/admin-web/cypress/e2e/login.feature
Normal file
|
|
@ -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"
|
||||
24
apps/admin-web/cypress/e2e/login.ts
Normal file
24
apps/admin-web/cypress/e2e/login.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
3
apps/admin-web/cypress/support/e2e.ts
Normal file
3
apps/admin-web/cypress/support/e2e.ts
Normal file
|
|
@ -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 {};
|
||||
|
|
@ -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 `<name>.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");
|
||||
});
|
||||
12
apps/admin-web/index.html
Normal file
12
apps/admin-web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>batchCooking — Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
apps/admin-web/nginx.conf
Normal file
20
apps/admin-web/nginx.conf
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
42
apps/admin-web/package.json
Normal file
42
apps/admin-web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
34
apps/admin-web/src/App.tsx
Normal file
34
apps/admin-web/src/App.tsx
Normal file
|
|
@ -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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminLayout />
|
||||
</RequireAdmin>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route path="/corrections" element={<CorrectionsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
96
apps/admin-web/src/api/client.ts
Normal file
96
apps/admin-web/src/api/client.ts
Normal file
|
|
@ -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<string, string[] | undefined>;
|
||||
|
||||
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<TResponseBody>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<TResponseBody> {
|
||||
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<AdminUserView> {
|
||||
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
/** Ends the current admin session. */
|
||||
public logout(): Promise<void> {
|
||||
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<AdminUserView> {
|
||||
return this._request("/admin/auth/me");
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||
export const adminApiClient = new AdminApiClient();
|
||||
71
apps/admin-web/src/features/auth/AdminAuthContext.tsx
Normal file
71
apps/admin-web/src/features/auth/AdminAuthContext.tsx
Normal file
|
|
@ -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<void>;
|
||||
/** Ends the session and clears `admin`. */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AdminAuthContext = createContext<AdminAuthContextValue | null>(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<AdminUserView | null>(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 (
|
||||
<AdminAuthContext.Provider value={{ admin, isLoading, login, logout }}>
|
||||
{children}
|
||||
</AdminAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
21
apps/admin-web/src/features/auth/RequireAdmin.tsx
Normal file
21
apps/admin-web/src/features/auth/RequireAdmin.tsx
Normal file
|
|
@ -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 <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
84
apps/admin-web/src/features/auth/admin-auth.scss
Normal file
84
apps/admin-web/src/features/auth/admin-auth.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
22
apps/admin-web/src/i18n/i18n.ts
Normal file
22
apps/admin-web/src/i18n/i18n.ts
Normal file
|
|
@ -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;
|
||||
108
apps/admin-web/src/layouts/AdminLayout.scss
Normal file
108
apps/admin-web/src/layouts/AdminLayout.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
83
apps/admin-web/src/layouts/AdminLayout.tsx
Normal file
83
apps/admin-web/src/layouts/AdminLayout.tsx
Normal file
|
|
@ -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.<key>` 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 `<Outlet />`. 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 (
|
||||
<div className="admin-layout">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar__brand">
|
||||
batchCooking <span>Admin</span>
|
||||
</div>
|
||||
|
||||
<nav className="admin-sidebar__nav">
|
||||
{NAV_ITEMS.map(({ to, key, Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{t(`admin.nav.${key}`)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="admin-sidebar__footer">
|
||||
<span className="admin-sidebar__who" title={admin?.email}>
|
||||
{admin?.name}
|
||||
</span>
|
||||
<button type="button" onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{t("admin.layout.logout")}
|
||||
</button>
|
||||
<p className="admin-sidebar__version" aria-hidden="true">
|
||||
v{__APP_VERSION__}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="admin-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/admin-web/src/lib/zod-errors.ts
Normal file
18
apps/admin-web/src/lib/zod-errors.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const fieldErrors = error.flatten().fieldErrors;
|
||||
const firstMessagePerField: Record<string, string> = {};
|
||||
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||
if (messages?.[0]) {
|
||||
firstMessagePerField[field] = messages[0];
|
||||
}
|
||||
}
|
||||
return firstMessagePerField;
|
||||
}
|
||||
42
apps/admin-web/src/locales/fr/translation.json
Normal file
42
apps/admin-web/src/locales/fr/translation.json
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/admin-web/src/main.tsx
Normal file
24
apps/admin-web/src/main.tsx
Normal file
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AdminAuthProvider>
|
||||
<App />
|
||||
</AdminAuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
26
apps/admin-web/src/pages/admin-page.scss
Normal file
26
apps/admin-web/src/pages/admin-page.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
19
apps/admin-web/src/pages/corrections/CorrectionsPage.tsx
Normal file
19
apps/admin-web/src/pages/corrections/CorrectionsPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
|
||||
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
|
||||
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/admin-web/src/pages/dashboard/DashboardPage.tsx
Normal file
17
apps/admin-web/src/pages/dashboard/DashboardPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page__title">{t("admin.dashboard.title")}</h1>
|
||||
<p className="admin-page__lead">{t("admin.dashboard.lead")}</p>
|
||||
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
apps/admin-web/src/pages/login/LoginPage.tsx
Normal file
85
apps/admin-web/src/pages/login/LoginPage.tsx
Normal file
|
|
@ -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<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(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 (
|
||||
<main className="admin-auth-page">
|
||||
<form className="admin-auth-card" onSubmit={handleSubmit} noValidate>
|
||||
<h1>{t("admin.login.title")}</h1>
|
||||
|
||||
<label htmlFor="email">{t("admin.login.emailLabel")}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||
|
||||
<label htmlFor="password">{t("admin.login.passwordLabel")}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
||||
|
||||
{formError && <p className="form-error">{formError}</p>}
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t("admin.login.submitting") : t("admin.login.submit")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
apps/admin-web/src/pages/monitoring/MonitoringPage.tsx
Normal file
18
apps/admin-web/src/pages/monitoring/MonitoringPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
||||
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
|
||||
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/admin-web/src/services/error-message.service.ts
Normal file
19
apps/admin-web/src/services/error-message.service.ts
Normal file
|
|
@ -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();
|
||||
171
apps/admin-web/src/styles/_theme.scss
Normal file
171
apps/admin-web/src/styles/_theme.scss
Normal file
|
|
@ -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 `<html>`), 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);
|
||||
}
|
||||
149
apps/admin-web/src/styles/global.scss
Normal file
149
apps/admin-web/src/styles/global.scss
Normal file
|
|
@ -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 <h1>) 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);
|
||||
}
|
||||
5
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
5
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// Injected by `define` in vite.config.ts, sourced from package.json's
|
||||
// version field — rendered in AdminLayout.tsx.
|
||||
declare const __APP_VERSION__: string;
|
||||
14
apps/admin-web/tsconfig.app.json
Normal file
14
apps/admin-web/tsconfig.app.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
8
apps/admin-web/tsconfig.json
Normal file
8
apps/admin-web/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
12
apps/admin-web/tsconfig.node.json
Normal file
12
apps/admin-web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
apps/admin-web/vite.config.ts
Normal file
19
apps/admin-web/vite.config.ts
Normal file
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
343
pnpm-lock.yaml
343
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue