diff --git a/apps/web/cypress/e2e/admin-tours.cy.ts b/apps/web/cypress/e2e/admin-tours.cy.ts new file mode 100644 index 0000000..60ba9de --- /dev/null +++ b/apps/web/cypress/e2e/admin-tours.cy.ts @@ -0,0 +1,129 @@ +// Mocks the admin API via cy.intercept — no live backend. Covers the +// `/admin/didacticiels` CRUD page (issue #44): list, edit-and-save a +// scenario's steps in one block PATCH, create a scenario, delete one. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +const TARGETS = [ + { key: "nav-planning", description: "Lien « Planning » de la barre latérale" }, + { key: "planning-grid", description: "La grille du planning de la semaine" }, + { key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" }, +]; + +function scenarios() { + return [ + { + id: 1, + key: "general", + name: "Tour d'orientation", + enabled: true, + steps: [ + { + order: 0, + route: "/", + targetKey: "nav-planning", + title: "Bienvenue", + content: "Le planning.", + }, + { + order: 1, + route: null, + targetKey: "planning-grid", + title: "La grille", + content: "Sept jours.", + }, + ], + }, + { id: 2, key: "techStepMetadata", name: "Corriger les techniques", enabled: false, steps: [] }, + ]; +} + +describe("Admin — didacticiels CRUD", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/tours", { statusCode: 200, body: scenarios() }).as("list"); + cy.intercept("GET", "**/admin/api/tours/targets", { statusCode: 200, body: TARGETS }); + }); + + it("lists every scenario, enabled and disabled, with its step count", () => { + cy.visit("/admin/didacticiels"); + cy.wait("@list"); + + cy.contains(".tours-page__list button", "Tour d'orientation").should( + "contain.text", + "2 étapes", + ); + cy.contains(".tours-page__list button", "Corriger les techniques").should( + "contain.text", + "désactivé", + ); + }); + + it("edits a scenario's name and steps and saves the whole list in one PATCH", () => { + cy.intercept("PATCH", "**/admin/api/tours/1", (req) => { + req.reply({ statusCode: 200, body: { ...scenarios()[0], name: req.body.name } }); + }).as("save"); + + cy.visit("/admin/didacticiels"); + cy.wait("@list"); + cy.contains(".tours-page__list button", "Tour d'orientation").click(); + + cy.get('[data-testid="scenario-editor"]').within(() => { + cy.contains("label", "Nom").find("input").clear().type("Tour d'accueil"); + // Reorder: push step 1 down so "La grille" comes first. + cy.get(".tours-page__step").first().contains("button", "↓").click(); + cy.contains("button", "Enregistrer").click(); + }); + + cy.wait("@save") + .its("request.body") + .should((body) => { + expect(body.name).to.equal("Tour d'accueil"); + expect(body.enabled).to.equal(true); + expect(body.steps).to.have.length(2); + // Reordered: the grid step is now first, nav-planning second. + expect(body.steps[0].targetKey).to.equal("planning-grid"); + expect(body.steps[1].targetKey).to.equal("nav-planning"); + }); + }); + + it("creates a new scenario", () => { + cy.intercept("POST", "**/admin/api/tours", (req) => { + req.reply({ + statusCode: 201, + body: { id: 3, key: req.body.key, name: req.body.name, enabled: true, steps: [] }, + }); + }).as("create"); + + cy.visit("/admin/didacticiels"); + cy.wait("@list"); + cy.contains("button", "Nouveau scénario").click(); + + cy.contains("label", "Clé").find("input").type("shoppingList"); + cy.contains("label", "Nom").find("input").type("Tour de la liste de courses"); + cy.contains("button", "Créer").click(); + + cy.wait("@create") + .its("request.body") + .should("deep.equal", { key: "shoppingList", name: "Tour de la liste de courses" }); + }); + + it("deletes a scenario after confirmation", () => { + cy.intercept("DELETE", "**/admin/api/tours/1", { statusCode: 204 }).as("del"); + cy.on("window:confirm", () => true); + + cy.visit("/admin/didacticiels"); + cy.wait("@list"); + cy.contains(".tours-page__list button", "Tour d'orientation").click(); + cy.get('[data-testid="scenario-editor"]').contains("button", "Supprimer le scénario").click(); + + cy.wait("@del"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 85f4b79..1d02b42 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/correctio import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage"; import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; +import { ToursPage as AdminToursPage } from "./pages/admin/tours/ToursPage"; import { LoginPage } from "./pages/auth/LoginPage"; import { SignupPage } from "./pages/auth/SignupPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; @@ -159,6 +160,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index f6dcf30..1bd24ff 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -1,18 +1,22 @@ import { type AdminLoginInput, + type AdminTourScenarioView, type AdminUserView, type ApiErrorResponse, type CatalogPlaceholderGroupView, type CorrectionAdminView, + type CreateTourScenarioInput, ErrorCode, type MarkPlaceholdersReviewedInput, type MetricsView, type PruneOrphansResultView, type RetrainRequestInput, type RetrainResultView, + type TourTargetView, type TrainingDataSnippetView, type TrainingSuggestionAdminView, type TrainingSuggestionGroupView, + type UpdateTourScenarioInput, type UpdateTrainingSuggestionInput, } from "@batch-cooking/shared"; @@ -187,6 +191,34 @@ export class AdminApiClient { public pruneOrphanPlaceholders(): Promise { return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); } + + /** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */ + public getTourScenarios(): Promise { + return this._request("/tours"); + } + + /** The catalogue of valid highlight targets, for the scenario editor's per-step target picker. */ + public getTourTargets(): Promise { + return this._request("/tours/targets"); + } + + /** Creates an empty tour scenario — steps are added afterwards via {@link updateTourScenario}. Rejects with `TOUR_KEY_ALREADY_IN_USE`. */ + public createTourScenario(input: CreateTourScenarioInput): Promise { + return this._request("/tours", { method: "POST", body: JSON.stringify(input) }); + } + + /** Replaces a scenario's `name`/`enabled` and its whole step list. Rejects with `TOUR_NOT_FOUND` / `TOUR_TARGET_NOT_FOUND`. */ + public updateTourScenario( + id: number, + input: UpdateTourScenarioInput, + ): Promise { + return this._request(`/tours/${id}`, { method: "PATCH", body: JSON.stringify(input) }); + } + + /** Deletes a scenario — its steps and every user's progress row for it cascade away. */ + public deleteTourScenario(id: number): Promise { + return this._request(`/tours/${id}`, { method: "DELETE" }); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/web/src/layouts/AdminLayout.tsx b/apps/web/src/layouts/AdminLayout.tsx index 43213ed..4602e90 100644 --- a/apps/web/src/layouts/AdminLayout.tsx +++ b/apps/web/src/layouts/AdminLayout.tsx @@ -1,4 +1,4 @@ -import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; +import { Activity, GraduationCap, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -16,6 +16,7 @@ const NAV_ITEMS = [ { to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, + { to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false }, ] as const; /** diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index e9b9826..70ee33b 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1196,7 +1196,8 @@ "dashboard": "Tableau de bord", "monitoring": "Monitoring", "corrections": "Corrections", - "catalog": "Catalogue" + "catalog": "Catalogue", + "tours": "Didacticiels" }, "layout": { "logout": "Se déconnecter" diff --git a/apps/web/src/pages/admin/tours/ToursPage.tsx b/apps/web/src/pages/admin/tours/ToursPage.tsx new file mode 100644 index 0000000..dda3c64 --- /dev/null +++ b/apps/web/src/pages/admin/tours/ToursPage.tsx @@ -0,0 +1,371 @@ +import type { AdminTourScenarioView, TourTargetView } from "@batch-cooking/shared"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AdminApiError, adminApiClient } from "../../../api/admin-client"; +import "../admin-page.scss"; +import "./tours-page.scss"; + +type LoadState = + | { status: "loading" } + | { status: "loaded"; scenarios: AdminTourScenarioView[]; targets: TourTargetView[] } + | { status: "error" }; + +/** A step being edited — same fields the API's `adminTourStepSchema` accepts (no `order`, that's the array index). */ +interface DraftStep { + route: string; + targetKey: string; + title: string; + content: string; +} + +/** + * Admin CRUD for the guided-tour scenarios the web tour engine consumes + * (`GET /reference/tours`). List on the left, an editor for the selected + * scenario on the right: rename, enable/disable, and edit the whole step + * list (route / highlight target / title / content, reorder, add, remove) + * which is saved back in one block PATCH — see the API's + * `admin-tours.service.ts`. Issue #44. + */ +export function ToursPage() { + const [state, setState] = useState({ status: "loading" }); + const [selectedId, setSelectedId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const requestSeq = useRef(0); + + const load = useCallback(() => { + const seq = ++requestSeq.current; + setState({ status: "loading" }); + Promise.all([adminApiClient.getTourScenarios(), adminApiClient.getTourTargets()]) + .then(([scenarios, targets]) => { + if (seq === requestSeq.current) setState({ status: "loaded", scenarios, targets }); + }) + .catch(() => { + if (seq === requestSeq.current) setState({ status: "error" }); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (state.status === "loading") { + return

Chargement…

; + } + if (state.status === "error") { + return ( +

+ Impossible de charger les scénarios. +

+ ); + } + + const selected = state.scenarios.find((scenario) => scenario.id === selectedId) ?? null; + + return ( +
+
+

Didacticiels

+ +
+ +
+
    + {state.scenarios.map((scenario) => ( +
  • + +
  • + ))} +
+ +
+ {isCreating ? ( + setIsCreating(false)} + onCreated={(scenario) => { + setIsCreating(false); + setSelectedId(scenario.id); + load(); + }} + /> + ) : selected ? ( + { + setSelectedId(null); + load(); + }} + /> + ) : ( +

Sélectionnez un scénario, ou créez-en un.

+ )} +
+
+
+ ); +} + +function CreateScenarioForm({ + onCancel, + onCreated, +}: { + onCancel: () => void; + onCreated: (scenario: AdminTourScenarioView) => void; +}) { + const [key, setKey] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + function submit() { + setError(null); + setIsSaving(true); + adminApiClient + .createTourScenario({ key: key.trim(), name: name.trim() }) + .then(onCreated) + .catch((err: unknown) => { + setError(err instanceof AdminApiError ? err.message : "Création impossible"); + }) + .finally(() => setIsSaving(false)); + } + + return ( +
+

Nouveau scénario

+ + + {error &&

{error}

} +
+ + +
+
+ ); +} + +function ScenarioEditor({ + scenario, + targets, + onSaved, + onDeleted, +}: { + scenario: AdminTourScenarioView; + targets: TourTargetView[]; + onSaved: () => void; + onDeleted: () => void; +}) { + const [name, setName] = useState(scenario.name); + const [enabled, setEnabled] = useState(scenario.enabled); + const [steps, setSteps] = useState( + scenario.steps.map((step) => ({ + route: step.route ?? "", + targetKey: step.targetKey, + title: step.title, + content: step.content, + })), + ); + const [error, setError] = useState(null); + const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle"); + + function patchStep(index: number, patch: Partial) { + setSteps((prev) => prev.map((step, i) => (i === index ? { ...step, ...patch } : step))); + } + + function moveStep(index: number, delta: number) { + setSteps((prev) => { + const target = index + delta; + if (target < 0 || target >= prev.length) return prev; + const next = [...prev]; + const moved = next[index]; + const displaced = next[target]; + if (moved === undefined || displaced === undefined) return prev; + next[index] = displaced; + next[target] = moved; + return next; + }); + } + + function addStep() { + setSteps((prev) => [ + ...prev, + { route: "", targetKey: targets[0]?.key ?? "", title: "", content: "" }, + ]); + } + + function save() { + setError(null); + setStatus("saving"); + adminApiClient + .updateTourScenario(scenario.id, { + name: name.trim(), + enabled, + steps: steps.map((step) => ({ + route: step.route.trim() === "" ? null : step.route.trim(), + targetKey: step.targetKey, + title: step.title.trim(), + content: step.content.trim(), + })), + }) + .then(() => { + setStatus("saved"); + onSaved(); + }) + .catch((err: unknown) => { + setStatus("idle"); + setError(err instanceof AdminApiError ? err.message : "Enregistrement impossible"); + }); + } + + function remove() { + if (!window.confirm(`Supprimer le scénario « ${scenario.name} » ?`)) return; + adminApiClient + .deleteTourScenario(scenario.id) + .then(onDeleted) + .catch(() => onDeleted()); + } + + return ( +
+

+ {scenario.name} {scenario.key} +

+ + + + +

Étapes

+
    + {steps.map((step, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: steps are positional; reordering swaps them in place. +
  1. +
    + + +
    + +