// GreenhousesList.jsx — Table view of greenhouses for a given site. // Fetches from Keeper.api.greenhouses(siteId) and falls back to // window.DATA.greenhouses[siteId] demo data when the backend is offline. function GreenhousesList({ siteId, selectedGreenhouseId, onNav, onSelectGreenhouse }) { const i18n = window.Keeper && window.Keeper.i18n ? window.Keeper.i18n : null; function tr(key, fallback, params) { if (i18n && typeof i18n.t === "function") return i18n.t(key, params, { fallback }); return fallback; } const interaction = window.Keeper && window.Keeper.interaction ? window.Keeper.interaction : null; const interactionLifecycle = interaction || { createState() { return { phase: "idle", loading: false, success: false, error: "", requestToken: 0, activeRequestToken: null, meta: {}, }; }, start(prev, options) { const safePrev = prev && typeof prev === "object" ? prev : this.createState(); const nextToken = (Number.isInteger(safePrev.requestToken) ? safePrev.requestToken : 0) + 1; return Object.assign({}, safePrev, { phase: "loading", loading: true, success: false, error: "", requestToken: nextToken, activeRequestToken: nextToken, meta: options && typeof options.meta === "object" ? options.meta : (safePrev.meta || {}), }); }, succeed(prev, token) { const safePrev = prev && typeof prev === "object" ? prev : this.createState(); if (safePrev.activeRequestToken !== token) return safePrev; return Object.assign({}, safePrev, { phase: "success", loading: false, success: true, error: "", activeRequestToken: null, }); }, fail(prev, token, error, options) { const safePrev = prev && typeof prev === "object" ? prev : this.createState(); if (safePrev.activeRequestToken !== token) return safePrev; const fallbackMessage = options && typeof options.fallbackMessage === "string" ? options.fallbackMessage : ((error && error.message) || ""); const patch = options && typeof options.patch === "object" ? options.patch : {}; return Object.assign({}, safePrev, { phase: "error", loading: false, success: false, error: fallbackMessage, activeRequestToken: null, }, patch); }, isStaleResponse(state, token) { return !state || state.activeRequestToken !== token; }, }; // Resolve siteId: prop > query-param hash > first site fallback const resolvedSiteId = siteId || (function() { try { const hash = window.location.hash; // e.g. "#greenhouses?siteId=2" const qi = hash.indexOf("?"); if (qi !== -1) { const params = new URLSearchParams(hash.slice(qi + 1)); const v = params.get("siteId"); if (v) return isNaN(Number(v)) ? v : Number(v); } } catch (_) {} return null; })() || (DATA.sites && DATA.sites[0] && DATA.sites[0].id) || 1; const [rows, setRows] = React.useState([]); const [listInteraction, setListInteraction] = React.useState(() => Object.assign({}, interactionLifecycle.createState(), { phase: "loading", loading: true }) ); const [search, setSearch] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); // "all" | "ok" | "warn" | "crit" const listInteractionRef = React.useRef(listInteraction); React.useEffect(() => { listInteractionRef.current = listInteraction; }, [listInteraction]); function commitListInteraction(nextState) { listInteractionRef.current = nextState; setListInteraction(nextState); } const listActionRunnerRef = React.useRef(null); if (!listActionRunnerRef.current && interaction && typeof interaction.createActionRunner === "function") { listActionRunnerRef.current = interaction.createActionRunner({ getState: () => listInteractionRef.current, setState: commitListInteraction, }); } function isStaleInteraction(state, token) { return interactionLifecycle.isStaleResponse(state, token); } // Find site metadata for subtitle const site = DATA.sites && DATA.sites.find(s => s.id === resolvedSiteId); React.useEffect(() => { let cancelled = false; const listRunner = listActionRunnerRef.current; (async () => { if (listRunner) { await listRunner.run(async ({ token }) => { const data = await Keeper.api.greenhouses(resolvedSiteId); if (cancelled || listRunner.isStale(token)) return null; setRows(Array.isArray(data) ? data : []); return null; }, { meta: { actionType: "greenhouses.load" }, onError: (error) => { if (cancelled) return {}; const demo = (DATA.greenhouses && DATA.greenhouses[resolvedSiteId]) || []; if (Array.isArray(demo) && demo.length > 0) { setRows(demo); return { patch: { phase: "success", loading: false, success: true, error: "", activeRequestToken: null } }; } setRows([]); const fallbackMessage = tr("greenhousesList.errors.loadFailed", "Failed to load greenhouse list"); const formatted = typeof Keeper.formatApiFailure === "function" ? Keeper.formatApiFailure(error, { fallbackMessage }) : fallbackMessage; return { fallbackMessage: formatted }; }, }); return; } const startedState = interactionLifecycle.start(listInteractionRef.current, { meta: { actionType: "greenhouses.load" } }); commitListInteraction(startedState); const requestToken = startedState.activeRequestToken; try { const data = await Keeper.api.greenhouses(resolvedSiteId); if (cancelled) return; if (isStaleInteraction(listInteractionRef.current, requestToken)) return; setRows(Array.isArray(data) ? data : []); const successState = interactionLifecycle.succeed(listInteractionRef.current, requestToken); commitListInteraction(successState); } catch (error) { if (cancelled) return; if (isStaleInteraction(listInteractionRef.current, requestToken)) return; const demo = (DATA.greenhouses && DATA.greenhouses[resolvedSiteId]) || []; if (Array.isArray(demo) && demo.length > 0) { setRows(demo); const successState = interactionLifecycle.succeed(listInteractionRef.current, requestToken); commitListInteraction(successState); return; } setRows([]); const fallbackMessage = tr("greenhousesList.errors.loadFailed", "Failed to load greenhouse list"); const formatted = typeof Keeper.formatApiFailure === "function" ? Keeper.formatApiFailure(error, { fallbackMessage }) : fallbackMessage; const errorState = interactionLifecycle.fail(listInteractionRef.current, requestToken, error, { fallbackMessage: formatted }); commitListInteraction(errorState); } })(); return () => { cancelled = true; }; }, [resolvedSiteId]); function reloadRows() { const listRunner = listActionRunnerRef.current; if (listRunner) { listRunner.run(async ({ token }) => { const data = await Keeper.api.greenhouses(resolvedSiteId); if (listRunner.isStale(token)) return null; setRows(Array.isArray(data) ? data : []); return null; }, { meta: { actionType: "greenhouses.retry" }, onError: (error) => { const fallbackMessage = tr("greenhousesList.errors.loadFailed", "Failed to load greenhouse list"); const formatted = typeof Keeper.formatApiFailure === "function" ? Keeper.formatApiFailure(error, { fallbackMessage }) : fallbackMessage; return { fallbackMessage: formatted }; }, }); return; } const startedState = interactionLifecycle.start(listInteractionRef.current, { meta: { actionType: "greenhouses.retry" } }); commitListInteraction(startedState); Keeper.api.greenhouses(resolvedSiteId) .then((data) => { if (isStaleInteraction(listInteractionRef.current, startedState.activeRequestToken)) return; setRows(Array.isArray(data) ? data : []); const successState = interactionLifecycle.succeed(listInteractionRef.current, startedState.activeRequestToken); commitListInteraction(successState); }) .catch((error) => { if (isStaleInteraction(listInteractionRef.current, startedState.activeRequestToken)) return; const fallbackMessage = tr("greenhousesList.errors.loadFailed", "Failed to load greenhouse list"); const formatted = typeof Keeper.formatApiFailure === "function" ? Keeper.formatApiFailure(error, { fallbackMessage }) : fallbackMessage; const errorState = interactionLifecycle.fail(listInteractionRef.current, startedState.activeRequestToken, error, { fallbackMessage: formatted }); commitListInteraction(errorState); }); } const loading = !!listInteraction.loading; const error = listInteraction.error || ""; // ── Derived filtered list ──────────────────────────────────────────────── const filtered = rows.filter(gh => { const q = search.trim().toLowerCase(); const matchSearch = !q || gh.id.toLowerCase().includes(q) || gh.name.toLowerCase().includes(q) || (gh.zone && gh.zone.toLowerCase().includes(q)) || (gh.crop && gh.crop.toLowerCase().includes(q)); const matchStatus = statusFilter === "all" || gh.status === statusFilter; return matchSearch && matchStatus; }); // ── Status colour helpers ──────────────────────────────────────────────── function statusClass(s) { if (s === "crit" || s === "critical") return "status--critical"; if (s === "warn") return "status--warn"; return "status--ok"; } // ── Summary counts ─────────────────────────────────────────────────────── const total = rows.length; const critCount = rows.filter(r => r.status === "crit" || r.status === "critical").length; const warnCount = rows.filter(r => r.status === "warn").length; const totalHives = rows.reduce((s, r) => s + (r.hives || 0), 0); const totalDevs = rows.reduce((s, r) => s + (r.devices || 0), 0); const mapGreenhouseId = React.useMemo(() => { if (!Array.isArray(rows) || rows.length === 0) return null; const selected = rows.find((item) => String(item.id) === String(selectedGreenhouseId)); return selected?.id ?? rows[0]?.id ?? null; }, [rows, selectedGreenhouseId]); // ── Render ─────────────────────────────────────────────────────────────── return (