feat(web): page admin de CRUD des didacticiels (#44) #61

Merged
kyuno merged 1 commit from feat/admin-tour-page into main 2026-09-06 07:27:17 +02:00
7 changed files with 745 additions and 2 deletions

View file

@ -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");
});
});

View file

@ -10,6 +10,7 @@ import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/correctio
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage"; import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage"; import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage"; 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 { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage"; import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
@ -159,6 +160,7 @@ export function App() {
<Route path="monitoring" element={<AdminMonitoringPage />} /> <Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} /> <Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} /> <Route path="catalogue" element={<AdminCatalogPage />} />
<Route path="didacticiels" element={<AdminToursPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -1,18 +1,22 @@
import { import {
type AdminLoginInput, type AdminLoginInput,
type AdminTourScenarioView,
type AdminUserView, type AdminUserView,
type ApiErrorResponse, type ApiErrorResponse,
type CatalogPlaceholderGroupView, type CatalogPlaceholderGroupView,
type CorrectionAdminView, type CorrectionAdminView,
type CreateTourScenarioInput,
ErrorCode, ErrorCode,
type MarkPlaceholdersReviewedInput, type MarkPlaceholdersReviewedInput,
type MetricsView, type MetricsView,
type PruneOrphansResultView, type PruneOrphansResultView,
type RetrainRequestInput, type RetrainRequestInput,
type RetrainResultView, type RetrainResultView,
type TourTargetView,
type TrainingDataSnippetView, type TrainingDataSnippetView,
type TrainingSuggestionAdminView, type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView, type TrainingSuggestionGroupView,
type UpdateTourScenarioInput,
type UpdateTrainingSuggestionInput, type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -187,6 +191,34 @@ export class AdminApiClient {
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> { public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); 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<AdminTourScenarioView[]> {
return this._request("/tours");
}
/** The catalogue of valid highlight targets, for the scenario editor's per-step target picker. */
public getTourTargets(): Promise<TourTargetView[]> {
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<AdminTourScenarioView> {
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<AdminTourScenarioView> {
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<void> {
return this._request(`/tours/${id}`, { method: "DELETE" });
}
} }
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -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 { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; 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/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false }, { to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false }, { to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
{ to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false },
] as const; ] as const;
/** /**

View file

@ -1196,7 +1196,8 @@
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"monitoring": "Monitoring", "monitoring": "Monitoring",
"corrections": "Corrections", "corrections": "Corrections",
"catalog": "Catalogue" "catalog": "Catalogue",
"tours": "Didacticiels"
}, },
"layout": { "layout": {
"logout": "Se déconnecter" "logout": "Se déconnecter"

View file

@ -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<LoadState>({ status: "loading" });
const [selectedId, setSelectedId] = useState<number | null>(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 <p className="admin-page__status">Chargement…</p>;
}
if (state.status === "error") {
return (
<p className="admin-page__status admin-page__status--error">
Impossible de charger les scénarios.
</p>
);
}
const selected = state.scenarios.find((scenario) => scenario.id === selectedId) ?? null;
return (
<div className="admin-page tours-page">
<header className="admin-page__header">
<h1>Didacticiels</h1>
<button type="button" onClick={() => setIsCreating(true)}>
Nouveau scénario
</button>
</header>
<div className="tours-page__body">
<ul className="tours-page__list">
{state.scenarios.map((scenario) => (
<li key={scenario.id}>
<button
type="button"
className={scenario.id === selectedId ? "is-selected" : undefined}
onClick={() => {
setSelectedId(scenario.id);
setIsCreating(false);
}}
>
<span className="tours-page__list-name">{scenario.name}</span>
<span className="tours-page__list-meta">
{scenario.enabled ? "" : "désactivé · "}
{scenario.steps.length} étape{scenario.steps.length > 1 ? "s" : ""}
</span>
</button>
</li>
))}
</ul>
<div className="tours-page__editor">
{isCreating ? (
<CreateScenarioForm
onCancel={() => setIsCreating(false)}
onCreated={(scenario) => {
setIsCreating(false);
setSelectedId(scenario.id);
load();
}}
/>
) : selected ? (
<ScenarioEditor
key={selected.id}
scenario={selected}
targets={state.targets}
onSaved={load}
onDeleted={() => {
setSelectedId(null);
load();
}}
/>
) : (
<p className="admin-page__status">Sélectionnez un scénario, ou créez-en un.</p>
)}
</div>
</div>
</div>
);
}
function CreateScenarioForm({
onCancel,
onCreated,
}: {
onCancel: () => void;
onCreated: (scenario: AdminTourScenarioView) => void;
}) {
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className="tours-page__form">
<h2>Nouveau scénario</h2>
<label>
Clé
<input
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="onboardingHouse"
/>
</label>
<label>
Nom
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Tour du foyer partagé"
/>
</label>
{error && <p className="admin-page__status--error">{error}</p>}
<div className="tours-page__form-actions">
<button type="button" onClick={onCancel}>
Annuler
</button>
<button
type="button"
className="is-primary"
disabled={isSaving || key.trim() === "" || name.trim() === ""}
onClick={submit}
>
Créer
</button>
</div>
</div>
);
}
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<DraftStep[]>(
scenario.steps.map((step) => ({
route: step.route ?? "",
targetKey: step.targetKey,
title: step.title,
content: step.content,
})),
);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
function patchStep(index: number, patch: Partial<DraftStep>) {
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 (
<div className="tours-page__form" data-testid="scenario-editor">
<h2>
{scenario.name} <code>{scenario.key}</code>
</h2>
<label>
Nom
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label className="tours-page__checkbox">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
Activé
</label>
<h3>Étapes</h3>
<ol className="tours-page__steps">
{steps.map((step, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: steps are positional; reordering swaps them in place.
<li key={index} className="tours-page__step">
<div className="tours-page__step-row">
<label>
Route
<input
value={step.route}
placeholder="(même route)"
onChange={(event) => patchStep(index, { route: event.target.value })}
/>
</label>
<label>
Cible
<select
value={step.targetKey}
onChange={(event) => patchStep(index, { targetKey: event.target.value })}
>
{targets.map((target) => (
<option key={target.key} value={target.key}>
{target.description}
</option>
))}
</select>
</label>
</div>
<label>
Titre
<input
value={step.title}
onChange={(event) => patchStep(index, { title: event.target.value })}
/>
</label>
<label>
Contenu
<textarea
value={step.content}
rows={2}
onChange={(event) => patchStep(index, { content: event.target.value })}
/>
</label>
<div className="tours-page__step-actions">
<button type="button" onClick={() => moveStep(index, -1)} disabled={index === 0}>
↑
</button>
<button
type="button"
onClick={() => moveStep(index, 1)}
disabled={index === steps.length - 1}
>
↓
</button>
<button
type="button"
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}
>
Retirer
</button>
</div>
</li>
))}
</ol>
<button type="button" onClick={addStep}>
Ajouter une étape
</button>
{error && <p className="admin-page__status--error">{error}</p>}
{status === "saved" && <p className="admin-page__status">Enregistré.</p>}
<div className="tours-page__form-actions">
<button type="button" className="is-danger" onClick={remove}>
Supprimer le scénario
</button>
<button
type="button"
className="is-primary"
disabled={status === "saving" || name.trim() === ""}
onClick={save}
>
Enregistrer
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,207 @@
// Admin tour-scenario CRUD (ToursPage). Colocated; the generic
// `.admin-page__title` etc. come from ../admin-page.scss.
.tours-page {
.admin-page__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
h1 {
font-size: var(--font-size-2xl);
margin: 0;
}
}
.admin-page__status {
padding: var(--space-lg);
color: var(--color-text-muted);
&--error {
color: var(--color-error);
}
}
&__body {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
gap: var(--space-lg);
align-items: start;
@media (max-width: 860px) {
grid-template-columns: 1fr;
}
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
button {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
&.is-selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
}
}
}
&__list-name {
font-weight: 600;
font-size: var(--font-size-sm);
}
&__list-meta {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
&__editor {
min-width: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
h2 {
margin: 0;
font-size: var(--font-size-lg);
code {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
h3 {
margin: var(--space-sm) 0 0;
font-size: var(--font-size-md);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
input,
select,
textarea {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-weight: 400;
}
button {
align-self: flex-start;
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.is-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
&.is-danger {
background: var(--color-error);
border-color: var(--color-error);
color: #fff;
}
}
}
&__checkbox {
flex-direction: row !important;
align-items: center;
gap: var(--space-sm);
}
&__steps {
list-style: decimal inside;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
&__step {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__step-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
&__step-actions {
display: flex;
gap: var(--space-xs);
button {
padding: 2px var(--space-sm);
}
}
&__form-actions {
display: flex;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-md);
}
}