batchCooking/apps/web/src/pages/admin/catalog/CatalogPage.tsx
Nicolas bbd9afe8aa
Some checks failed
CI / lint (push) Failing after 23s
CI / build (push) Successful in 3m11s
CI / e2e (push) Failing after 8m16s
CI / intent-service-test (push) Successful in 12m49s
CI / test (push) Successful in 21m34s
refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*
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>
2026-08-29 17:02:29 +02:00

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