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("ingredients"); const [tab, setTab] = useState("pending"); return (

{t("admin.catalog.title")}

{t("admin.catalog.lead")}

{(["ingredients", "techSteps"] as const).map((value) => ( ))}
{(["pending", "reviewed"] as const).map((value) => ( ))}
{kind === "ingredients" ? ( ) : ( )}
); } /** 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({ status: "loading" }); const [pendingIds, setPendingIds] = useState(null); const [pruneMessage, setPruneMessage] = useState(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 ( <>
{pruneMessage &&

{pruneMessage}

} {state.status === "loading" && (

{t("admin.common.loading")}

)} {state.status === "error" && (

{t("admin.common.loadError")}

)} {state.status === "loaded" && (state.groups.length === 0 ? (

{t("admin.catalog.empty")}

) : (
    {state.groups.map((group) => ( markReviewed(group)} /> ))}
))} ); } /** 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({ status: "loading" }); const [busyId, setBusyId] = useState(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

{t("admin.common.loading")}

; } if (state.status === "error") { return

{t("admin.common.loadError")}

; } if (state.rows.length === 0) { return

{t("admin.catalog.techStepEmpty")}

; } return (
    {state.rows.map((row) => { const firstSeen = formatDate(row.createdAt); return (
  • {row.displayName}

    {t("admin.catalog.recipeCount", { count: row.recipeCount })}
    {row.sampleRecipes.length > 0 && (

    {t("admin.catalog.seenIn")}{" "} {row.sampleRecipes.map((recipe) => recipe.name).join(", ")}

    )}
    {firstSeen && ( {t("admin.catalog.firstSeen", { date: firstSeen })} )} {row.reviewedAt === null && ( )}
  • ); })}
); } 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 (
  • {headline}

    {t("admin.catalog.recipeCount", { count: group.recipeCount })}
    {variants.length > 0 && (

    {t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}

    )} {group.sampleRecipes.length > 0 && (

    {t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}

    )}
    {firstSeen && ( {t("admin.catalog.firstSeen", { date: firstSeen })} )} {!group.allReviewed && ( )}
  • ); }