Merge pull request 'feat(web): page admin de CRUD des didacticiels (#44)' (#61) from feat/admin-tour-page into main
This commit is contained in:
commit
713d6c520d
7 changed files with 745 additions and 2 deletions
129
apps/web/cypress/e2e/admin-tours.cy.ts
Normal file
129
apps/web/cypress/e2e/admin-tours.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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`. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
371
apps/web/src/pages/admin/tours/ToursPage.tsx
Normal file
371
apps/web/src/pages/admin/tours/ToursPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
apps/web/src/pages/admin/tours/tours-page.scss
Normal file
207
apps/web/src/pages/admin/tours/tours-page.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue