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 { 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() {
|
|||
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
||||
<Route path="didacticiels" element={<AdminToursPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -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<PruneOrphansResultView> {
|
||||
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`. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1196,7 +1196,8 @@
|
|||
"dashboard": "Tableau de bord",
|
||||
"monitoring": "Monitoring",
|
||||
"corrections": "Corrections",
|
||||
"catalog": "Catalogue"
|
||||
"catalog": "Catalogue",
|
||||
"tours": "Didacticiels"
|
||||
},
|
||||
"layout": {
|
||||
"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