Some checks failed
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/ `reviewedAt`), migration purement additive, FK `createdById → user_profiles` en `SET NULL`. Correction d'étape : `submitTechStepCorrectionSchema` accepte `correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ; `submitTechStepCorrection` crée une `TechStep` placeholder dans la transaction et poursuit comme si son id avait été fourni. La correction s'applique immédiatement (`source: manual`) ; nouvel event analytics `tech_step.placeholder_proposed`. `TechStepView` gagne `isPlaceholder`/`displayName` ; `GET /reference/tech-steps` exclut les placeholders. Nouveau helper web `techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par `StepDescription` et le popover. Le popover a une échappatoire « Ma technique n'est pas dans la liste » (champ libre). Admin : `admin-tech-step-catalog.service.ts` + routes `GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur Ingrédients / Techniques. Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue, liste + mark-reviewed admin) + Cypress (composant popover, e2e admin catalogue). Assertions `.techStep` existantes mises à jour pour les 2 nouveaux champs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import type { CatalogPlaceholderGroupView, TechStepPlaceholderView } 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 IngredientState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
|
| { status: "error" };
|
|
|
|
type TechStepState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; rows: TechStepPlaceholderView[] }
|
|
| { status: "error" };
|
|
|
|
/** Which catalogue gap the page is reviewing — off-catalog ingredients, or techniques users proposed while correcting a step (issue #130). */
|
|
type CatalogKind = "ingredients" | "techSteps";
|
|
|
|
/**
|
|
* Off-catalog review. Two lists a maintainer works through to see what the
|
|
* seeded catalog is missing: the free-text **ingredients** users typed on a
|
|
* recipe line (grouped by normalized name), and the free-text **techniques**
|
|
* users proposed while correcting a step (issue #130, a flat list — that's
|
|
* rarer). Actions are deliberately minimal: mark a gap as handled (and, for
|
|
* ingredients, purge rows no recipe references any more). Adding the real
|
|
* catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
|
|
* files.
|
|
*/
|
|
export function CatalogPage() {
|
|
const { t } = useTranslation();
|
|
const [kind, setKind] = useState<CatalogKind>("ingredients");
|
|
const [tab, setTab] = useState<CatalogTab>("pending");
|
|
|
|
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-kinds" role="tablist">
|
|
{(["ingredients", "techSteps"] as const).map((value) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={kind === value}
|
|
className={kind === value ? "active" : undefined}
|
|
onClick={() => setKind(value)}
|
|
>
|
|
{t(`admin.catalog.kind.${value}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<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>
|
|
|
|
{kind === "ingredients" ? (
|
|
<IngredientPlaceholders tab={tab} />
|
|
) : (
|
|
<TechStepPlaceholders tab={tab} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The off-catalog ingredient list — grouped by normalized name, with a prune-orphans action. */
|
|
function IngredientPlaceholders({ tab }: { tab: CatalogTab }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<IngredientState>({ 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="catalog-toolbar">
|
|
<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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The proposed-technique list (issue #130) — a flat list, each row markable as reviewed. */
|
|
function TechStepPlaceholders({ tab }: { tab: CatalogTab }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<TechStepState>({ status: "loading" });
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
const requestSeq = useRef(0);
|
|
|
|
const load = useCallback((forTab: CatalogTab) => {
|
|
const seq = ++requestSeq.current;
|
|
setState({ status: "loading" });
|
|
adminApiClient
|
|
.getTechStepPlaceholders(reviewedParam(forTab))
|
|
.then((rows) => {
|
|
if (seq === requestSeq.current) setState({ status: "loaded", rows });
|
|
})
|
|
.catch(() => {
|
|
if (seq === requestSeq.current) setState({ status: "error" });
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load(tab);
|
|
}, [tab, load]);
|
|
|
|
function markReviewed(id: number) {
|
|
setBusyId(id);
|
|
adminApiClient
|
|
.markTechStepPlaceholdersReviewed({ techStepIds: [id] })
|
|
.then(() => load(tab))
|
|
.catch(() => load(tab))
|
|
.finally(() => setBusyId(null));
|
|
}
|
|
|
|
if (state.status === "loading") {
|
|
return <p className="admin-page__placeholder">{t("admin.common.loading")}</p>;
|
|
}
|
|
if (state.status === "error") {
|
|
return <p className="admin-page__placeholder">{t("admin.common.loadError")}</p>;
|
|
}
|
|
if (state.rows.length === 0) {
|
|
return <p className="admin-page__placeholder">{t("admin.catalog.techStepEmpty")}</p>;
|
|
}
|
|
return (
|
|
<ul className="catalog-list">
|
|
{state.rows.map((row) => {
|
|
const firstSeen = formatDate(row.createdAt);
|
|
return (
|
|
<li key={row.id} className="catalog-card">
|
|
<div className="catalog-card__head">
|
|
<h2 className="catalog-card__name">{row.displayName}</h2>
|
|
<span className="catalog-card__count">
|
|
{t("admin.catalog.recipeCount", { count: row.recipeCount })}
|
|
</span>
|
|
</div>
|
|
{row.sampleRecipes.length > 0 && (
|
|
<p className="catalog-card__recipes">
|
|
{t("admin.catalog.seenIn")}{" "}
|
|
{row.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>
|
|
)}
|
|
{row.reviewedAt === null && (
|
|
<button
|
|
type="button"
|
|
onClick={() => markReviewed(row.id)}
|
|
disabled={busyId === row.id}
|
|
>
|
|
{busyId === row.id ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|