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("pending"); 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 (

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

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

{(["pending", "reviewed"] as const).map((value) => ( ))}
{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)} /> ))}
))}
); } 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 && ( )}
  • ); }