L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { adminApiClient } from "../../../api/admin-client";
|
|
import "../admin-page.scss";
|
|
import "./catalog-page.scss";
|
|
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
|
|
|
type CatalogState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
|
|
* free text users typed when the seeded catalog fell short — see
|
|
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
|
|
* name, so a maintainer sees what the catalog is missing and how many
|
|
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
|
|
* as handled, or purge rows no recipe references any more. Actually adding
|
|
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
|
|
* locale files.
|
|
*/
|
|
export function CatalogPage() {
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = useState<CatalogTab>("pending");
|
|
const [state, setState] = useState<CatalogState>({ status: "loading" });
|
|
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
|
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
|
const [isPruning, setIsPruning] = useState(false);
|
|
|
|
// Monotonic id of the most recent `load()` call — a response from an
|
|
// earlier one (switching tabs fast, or React 18 StrictMode's double-mount
|
|
// firing the effect twice) must not clobber the current tab's data.
|
|
const requestSeq = useRef(0);
|
|
|
|
const load = useCallback((forTab: CatalogTab) => {
|
|
const seq = ++requestSeq.current;
|
|
setState({ status: "loading" });
|
|
adminApiClient
|
|
.getPlaceholders(reviewedParam(forTab))
|
|
.then((groups) => {
|
|
if (seq === requestSeq.current) setState({ status: "loaded", groups });
|
|
})
|
|
.catch(() => {
|
|
if (seq === requestSeq.current) setState({ status: "error" });
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load(tab);
|
|
}, [tab, load]);
|
|
|
|
function markReviewed(group: CatalogPlaceholderGroupView) {
|
|
setPendingIds(group.ingredientIds);
|
|
adminApiClient
|
|
.markPlaceholdersReviewed({ ingredientIds: group.ingredientIds })
|
|
.then(() => load(tab))
|
|
.catch(() => load(tab))
|
|
.finally(() => setPendingIds(null));
|
|
}
|
|
|
|
function pruneOrphans() {
|
|
setIsPruning(true);
|
|
setPruneMessage(null);
|
|
adminApiClient
|
|
.pruneOrphanPlaceholders()
|
|
.then(({ deleted }) => {
|
|
setPruneMessage(
|
|
deleted === 0
|
|
? t("admin.catalog.prunedNone")
|
|
: t("admin.catalog.pruned", { count: deleted }),
|
|
);
|
|
load(tab);
|
|
})
|
|
.catch(() => setPruneMessage(t("admin.common.loadError")))
|
|
.finally(() => setIsPruning(false));
|
|
}
|
|
|
|
return (
|
|
<div className="admin-page">
|
|
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
|
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
|
|
|
<div className="catalog-toolbar">
|
|
<div className="catalog-tabs" role="tablist">
|
|
{(["pending", "reviewed"] as const).map((value) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === value}
|
|
className={tab === value ? "active" : undefined}
|
|
onClick={() => setTab(value)}
|
|
>
|
|
{t(`admin.catalog.tab.${value}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
|
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
|
</button>
|
|
</div>
|
|
{pruneMessage && <p className="catalog-prune-message">{pruneMessage}</p>}
|
|
|
|
{state.status === "loading" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
)}
|
|
{state.status === "loaded" &&
|
|
(state.groups.length === 0 ? (
|
|
<p className="admin-page__placeholder">{t("admin.catalog.empty")}</p>
|
|
) : (
|
|
<ul className="catalog-list">
|
|
{state.groups.map((group) => (
|
|
<PlaceholderGroupCard
|
|
key={group.normalizedName}
|
|
group={group}
|
|
busy={pendingIds === group.ingredientIds}
|
|
onMarkReviewed={() => markReviewed(group)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlaceholderGroupCard({
|
|
group,
|
|
busy,
|
|
onMarkReviewed,
|
|
}: {
|
|
group: CatalogPlaceholderGroupView;
|
|
busy: boolean;
|
|
onMarkReviewed: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { headline, variants } = splitSpellings(group);
|
|
const firstSeen = formatDate(group.firstSeenAt);
|
|
|
|
return (
|
|
<li className="catalog-card">
|
|
<div className="catalog-card__head">
|
|
<h2 className="catalog-card__name">{headline}</h2>
|
|
<span className="catalog-card__count">
|
|
{t("admin.catalog.recipeCount", { count: group.recipeCount })}
|
|
</span>
|
|
</div>
|
|
|
|
{variants.length > 0 && (
|
|
<p className="catalog-card__variants">
|
|
{t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}
|
|
</p>
|
|
)}
|
|
|
|
{group.sampleRecipes.length > 0 && (
|
|
<p className="catalog-card__recipes">
|
|
{t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}
|
|
</p>
|
|
)}
|
|
|
|
<div className="catalog-card__foot">
|
|
{firstSeen && (
|
|
<span className="catalog-card__seen">
|
|
{t("admin.catalog.firstSeen", { date: firstSeen })}
|
|
</span>
|
|
)}
|
|
{!group.allReviewed && (
|
|
<button type="button" onClick={onMarkReviewed} disabled={busy}>
|
|
{busy ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|