// screens-overview.jsx — Org overview, Sites list, Site detail // [TASK_START:T005] function Sparkline({ kind }) { if (kind === "progress") return (
); const colors = { honey:"#FFB300", ok:"#26972B", crit:"#E5342B" }; const points = kind === "crit" ? "0,8 10,10 20,6 30,12 40,8 50,14 60,10 70,16 80,18 90,22 100,24" : kind === "ok" ? "0,18 10,20 20,16 30,14 40,18 50,12 60,14 70,10 80,12 90,8 100,6" : "0,22 10,18 20,20 30,14 40,16 50,10 60,12 70,8 80,10 90,6 100,4"; return (
); } // [TASK_COMPLETE:T005] function _toNumber(value, fallback = 0) { const n = Number(value); return Number.isFinite(n) ? n : fallback; } function _normalizeKind(value) { if (value === "critical") return "crit"; if (value === "warning") return "warn"; if (value === "info") return "info"; if (value === "crit" || value === "warn" || value === "info") return value; return "warn"; } function _relativeTimeLabel(value) { if (!value) return "—"; if (typeof value === "string" && (value.includes("Сегодня") || value.includes("вчера"))) { return value; } const ts = new Date(value); if (Number.isNaN(ts.getTime())) return String(value); const deltaMs = Date.now() - ts.getTime(); const mins = Math.floor(deltaMs / 60000); if (mins < 1) return "только что"; if (mins < 60) return `${mins} мин назад`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours} ч назад`; const days = Math.floor(hours / 24); return `${days} дн назад`; } function _buildOverviewFallback(tenant) { const devices = Array.isArray(DATA?.devices) ? DATA.devices : []; const alerts = Array.isArray(DATA?.alerts) ? DATA.alerts : []; const tasks = Array.isArray(DATA?.tasks) ? DATA.tasks : []; const totalTasks = tasks.length; const doneTasks = tasks.filter((t) => t.col === "done").length; const openTasks = totalTasks - doneTasks; const taskCompletionPct = totalTasks ? Number(((doneTasks / totalTasks) * 100).toFixed(1)) : 0; const devicesOnline = devices.filter((d) => d.status !== "critical").length; return { kpis: { uptime_pct: devices.length ? Number(((devicesOnline / devices.length) * 100).toFixed(1)) : 0, devices_online: devicesOnline, devices_total: devices.length, hive_activity_pct: 76, open_alerts: alerts.filter((a) => a.kind === "crit" || a.kind === "warn").length, total_alerts: alerts.length, open_tasks: openTasks, total_tasks: totalTasks, task_completion_pct: taskCompletionPct, total_sites: Array.isArray(DATA?.sites) ? DATA.sites.length : tenant?.greenhouses || 0, total_greenhouses: tenant?.greenhouses || 0, total_hives: 0, }, alerts, sites: Array.isArray(DATA?.sites) ? DATA.sites : [], }; } function _normalizeOverviewPayload(payload, fallback) { const kpis = (payload && payload.kpis && typeof payload.kpis === "object") ? payload.kpis : {}; const base = fallback.kpis; return { kpis: { uptime_pct: _toNumber(kpis.uptime_pct, base.uptime_pct), devices_online: _toNumber(kpis.devices_online, base.devices_online), devices_total: _toNumber(kpis.devices_total, base.devices_total), hive_activity_pct: _toNumber(kpis.hive_activity_pct, base.hive_activity_pct), open_alerts: _toNumber(kpis.open_alerts, base.open_alerts), total_alerts: _toNumber(kpis.total_alerts, base.total_alerts), open_tasks: _toNumber(kpis.open_tasks, base.open_tasks), total_tasks: _toNumber(kpis.total_tasks, base.total_tasks), task_completion_pct: _toNumber(kpis.task_completion_pct, base.task_completion_pct), total_sites: _toNumber(kpis.total_sites, base.total_sites), total_greenhouses: _toNumber(kpis.total_greenhouses, base.total_greenhouses), total_hives: _toNumber(kpis.total_hives, base.total_hives), }, alerts: Array.isArray(payload?.alerts) ? payload.alerts : fallback.alerts, sites: Array.isArray(payload?.sites) ? payload.sites : fallback.sites, }; } function Overview({ tenant, onNav, onOpenHive }) { const [overview, setOverview] = React.useState(() => _buildOverviewFallback(tenant)); // [TASK_START:T010] function go(route) { if (!route) return; if (window.Keeper?.router?.go) { window.Keeper.router.go(route); return; } if (typeof onNav === "function") onNav(route); } // [TASK_COMPLETE:T010] // [TASK_START:T012] React.useEffect(() => { let cancelled = false; async function load() { const fallback = _buildOverviewFallback(tenant); if (!cancelled) setOverview(fallback); try { const live = await Keeper.api.overview(); if (cancelled) return; setOverview(_normalizeOverviewPayload(live, fallback)); } catch (_) { if (!cancelled) setOverview(fallback); } } load(); return () => { cancelled = true; }; }, [tenant?.id]); // [TASK_COMPLETE:T012] const kpis = overview.kpis; // [TASK_START:T004] const cards = [ { lbl: "Устройств онлайн", val: String(kpis.devices_online ?? 0), unit: `/${kpis.devices_total ?? 0}`, delta: `${kpis.uptime_pct ?? 0}% аптайм`, deltaCls: "up", icon: "ri-router-line", spark: "honey", nav: "devices", }, { lbl: "Активность ульев", val: String(kpis.hive_activity_pct ?? 0), unit: "%", delta: `${kpis.total_hives ?? 0} ульев`, deltaCls: "up", icon: "ri-pulse-line", spark: "ok", nav: null, }, { lbl: "Инциденты", val: String(kpis.open_alerts ?? 0), unit: "откр.", delta: `всего ${kpis.total_alerts ?? 0}`, deltaCls: "dn", icon: "ri-alarm-warning-line", spark: "crit", nav: "alerts", }, { lbl: "Открытых задач", val: String(kpis.open_tasks ?? 0), unit: `из ${kpis.total_tasks ?? 0}`, delta: `${kpis.task_completion_pct ?? 0}% выполнено`, deltaCls: "", icon: "ri-task-line", spark: "progress", nav: "tasks", }, ]; // [TASK_COMPLETE:T004] const alertsPreview = (Array.isArray(overview.alerts) ? overview.alerts : []) .filter((a) => !a.status || a.status === "open") .slice(0, 3); const allHives = Object.values(DATA?.hives || {}).flat(); const hivesNeedAttention = allHives .filter((h) => { const state = String(h.state || h.status || "ok").toLowerCase(); return state !== "ok" && state !== "green"; }) .slice(0, 3) .map((h) => { const state = String(h.state || h.status || "warn").toLowerCase(); return { id: h.id, zone: h.zone_id || h.zone || "—", activity: h.departures_per_min ?? h.departures ?? "—", temp: h.temperature_c == null ? "—" : h.temperature_c, state: state === "crit" || state === "critical" ? "cri" : "att", note: state === "crit" || state === "critical" ? "Требует срочного вмешательства" : "Нестабильная активность", }; }); const dayLabels = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const timeWindows = ["06:00", "11:00", "18:00", "21:00"]; const scheduleMatrix = [ ["open", "open", "open", "empty", "open", "open", "open"], ["close", "close", "close", "empty", "close", "close", "close"], ["open", "open", "open", "empty", "open", "open", "open"], ["close", "close", "close", "empty", "close", "close", "close"], ]; const efficiencySeries = [62, 68, 71, 74, 72, 76, 78]; const today = new Date(); const dateCaption = today.toLocaleDateString("ru-RU", { day: "numeric", month: "long" }); return (
{/* [TASK_START:T011] */}
Доброе утро, {DATA?.user?.name || "оператор"}
{dateCaption} · {(kpis.total_greenhouses ?? tenant?.greenhouses ?? 0)} теплиц · {(kpis.open_alerts ?? 0)} инцидента требуют внимания
{/* [TASK_COMPLETE:T011] */}
{cards.map((k, i) => (
go(k.nav)}>
{k.lbl}
{k.val}{k.unit}
{k.delta}
))}
{/* [TASK_START:T006] */}

Активные уведомления {e.preventDefault();go("alerts")}}>View all · {kpis.open_alerts ?? 0}

{alertsPreview.length === 0 && (
Открытых инцидентов нет.
)} {alertsPreview.map((a, i) => { const kind = _normalizeKind(a?.severity || a?.kind); return (
go("alerts")}>
{a?.title || "Инцидент"}
{a?.zone ? `Зона ${a.zone}` : "Зона —"}{a?.sub ? ` · ${a.sub}` : ""}
{_relativeTimeLabel(a?.time)}
); })}
{/* [TASK_COMPLETE:T006] */} {/* [TASK_START:T007] */}

Ульи, требующие внимания {e.preventDefault();go("hives")}}>все

{hivesNeedAttention.length === 0 && (
Все ульи в норме.
)} {hivesNeedAttention.map((h, i, arr) => (
Улей {h.id} · {h.zone}
{h.note}
{h.activity}/мин
{h.temp} °C
))}
{/* [TASK_COMPLETE:T007] */}
{/* [TASK_START:T008] */}

Расписание опыления · Блок AB1 изменить

{dayLabels.map((d) =>
{d}
)} {timeWindows.map((time, ri) => (
{time}
{dayLabels.map((_, di) => { const state = scheduleMatrix[ri][di]; if (state === "empty") return
; return
{state === "open" ? "откр" : "закр"}
; })}
))}
{/* [TASK_COMPLETE:T008] */} {/* [TASK_START:T009] */}

Эффективность опыления · 7 дней

{efficiencySeries.map((value, index) => { const max = 100; const chartBase = 120; const barWidth = 30; const step = 46; const x = 12 + (index * step); const h = Math.max(6, Math.round((value / max) * 94)); const y = chartBase - h; const isLast = index === efficiencySeries.length - 1; return ( {dayLabels[index]} {isLast && ( {value}% )} ); })}
{/* [TASK_COMPLETE:T009] */}
); } /* ── Sites list ───────────────────────────────────────────── */ function Sites({ onNav, onSelectSite }) { return (
Теплицы
{DATA.sites.length} объектов · 6,2 га суммарно · 109 ульев · 318 устройств
{DATA.sites.map(s=>(
{ // [TASK_START:T008] if (onSelectSite) onSelectSite(s.id); onNav("greenhouses"); // [TASK_COMPLETE:T008] }} >
{s.crit?"Авария":s.warn?"Внимание":"Норма"}

{s.name}

{s.area} · {s.crop}
Ульи
{s.hives}
Устройства
{s.devices}
Внимание
{s.warn}
Авария
{s.crit}
))}
); } /* ── Site detail (summary header above the greenhouse map nav) ─ */ function Site({ siteId, onNav, onOpenGreenhouses }) { const site = DATA.sites.find((item) => item.id === siteId) || DATA.sites[0]; return (
{site.name}
{site.area} · {site.crop} · {site.hives} ульев · {site.devices} устройств · обновлено 14:38
{/* [TASK_START:T009] */} {/* [TASK_COMPLETE:T009] */}
{[ {lbl:"Активность ульев",val:"76",unit:"%",icon:"ri-pulse-line",spark:"ok",delta:"▲ 3,2 пп",cls:"up"}, {lbl:"Темп. воздуха",val:"24,3",unit:"°C",icon:"ri-temp-hot-line",spark:"honey",delta:"норма 22–26",cls:""}, {lbl:"Влажность",val:"68",unit:"%",icon:"ri-drop-line",spark:"honey",delta:"норма 60–75",cls:""}, {lbl:"EC субстрата",val:"2,4",unit:"mS/cm",icon:"ri-flask-line",spark:"crit",delta:"1 датчик ↑",cls:"dn"}, {lbl:"CO₂",val:"812",unit:"ppm",icon:"ri-bubble-chart-line",spark:"honey",delta:"норма 450–1100",cls:""}, {lbl:"Освещённость",val:"18 400",unit:"лк",icon:"ri-sun-line",spark:"ok",delta:"ест. свет",cls:"up"}, ].map((k,i)=>(
{k.lbl}
{k.val}{k.unit}
{k.delta}
))}

Сегодня журнал

Закрыта задача T-1035 «Чистка фильтров полива L1»
Авария · аномалия EC в зоне AB2
Авария · TDS-Sensor-12 — нет связи
Замена улья B-09 — задача создана
Сработало правило «Закрыть ульи при перегреве»
Открытие летков по расписанию · 28 из 29 успешно

Парк устройств {e.preventDefault();onNav("devices")}}>все · 84

{[ {n:"Gateway",v:"1",icon:"ri-router-line",s:"ok"}, {n:"Задвижки",v:"29",icon:"ri-door-open-line",s:"ok"}, {n:"TDS",v:"12",icon:"ri-flask-line",s:"warn"}, {n:"Активность",v:"29",icon:"ri-pulse-line",s:"ok"}, {n:"Влажность",v:"8",icon:"ri-drop-line",s:"warn"}, {n:"Камеры",v:"5",icon:"ri-camera-line",s:"ok"}, ].map((c,i)=>(
{c.n}
{c.v}
))}
); } function statusLabelClass(status) { if (status === "crit" || status === "critical") return "status--critical"; if (status === "warn" || status === "warning") return "status--warn"; return "status--ok"; } function GreenhousesList({ siteId, selectedGreenhouseId, onNav, onSelectGreenhouse }) { const activeSite = DATA.sites.find((item) => item.id === siteId) || DATA.sites[0]; const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); 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]); React.useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(""); try { const live = await Keeper.api.greenhouses(activeSite.id); if (cancelled) return; setRows(Array.isArray(live) ? live : []); } catch (e) { if (cancelled) return; const fallback = DATA.greenhouses?.[activeSite.id] || []; setRows(fallback); if (e && e.status === 404) setError("Список теплиц не найден (404)"); } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, [activeSite.id]); return (
Теплицы · {activeSite.name}
{rows.length} теплиц · переход к зонам по клику на строку
{loading && ( )} {!loading && error && ( )} {!loading && rows.length === 0 && ( )} {!loading && rows.map((item) => { const zoneCount = item.zone_count ?? item.zones_count ?? item.zones ?? 0; return ( { if (onSelectGreenhouse) onSelectGreenhouse(item.id); onNav("zones", { greenhouseId: item.id, zoneId: null, deviceId: null }); }} > ); })}
Теплица Зон Ульев Устройств Статус
Загрузка…
{error}
Теплицы не найдены
{item.name || item.id} {zoneCount} {item.hive_count ?? item.hives_count ?? item.hives ?? 0} {item.device_count ?? item.devices_count ?? item.devices ?? 0} {item.status_label || item.statusLbl || "Норма"}
); } // [TASK_START:T005] function ZonesList({ greenhouseId, onNav, onSelectZone }) { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); React.useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(""); try { const live = await Keeper.api.zones(greenhouseId); if (cancelled) return; setRows(Array.isArray(live) ? live : []); } catch (e) { if (cancelled) return; const fallback = DATA.zones?.[greenhouseId] || []; setRows(fallback); if (e && e.status === 404) setError("Зоны не найдены (404)"); } finally { if (!cancelled) setLoading(false); } } if (greenhouseId) load(); else { setRows([]); setLoading(false); } return () => { cancelled = true; }; }, [greenhouseId]); return (
Зоны · {greenhouseId || "—"}
{rows.length} зон · переход к списку ульев по клику
{loading && ( )} {!loading && error && ( )} {!loading && rows.length === 0 && ( )} {!loading && rows.map((item) => ( { if (onSelectZone) onSelectZone(item.id); onNav("hiveslist", { greenhouseId: greenhouseId || null, zoneId: item.id, }); }} > ))}
Zone ID Ряд Ульев Устройств Статус
Загрузка…
{error}
В этой теплице пока нет зон
{item.id} {item.row_label || item.row || item.name || "—"} {item.hive_count ?? item.hives_count ?? 0} {item.device_count ?? item.devices_count ?? 0} {item.status_label || item.statusLbl || "Норма"}
); } // [TASK_COMPLETE:T005] // [TASK_START:T006] function HivesList({ greenhouseId, zoneId, onNav, onSelectHive }) { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); React.useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(""); try { const live = await Keeper.api.hives(greenhouseId); if (cancelled) return; setRows(Array.isArray(live) ? live : []); } catch (e) { if (cancelled) return; const fallback = DATA.hives?.[greenhouseId] || []; setRows(fallback); if (e && e.status === 404) setError("Ульи не найдены (404)"); } finally { if (!cancelled) setLoading(false); } } if (greenhouseId) load(); else { setRows([]); setLoading(false); } return () => { cancelled = true; }; }, [greenhouseId]); const filtered = zoneId ? rows.filter((item) => item.zone_id === zoneId || item.zoneId === zoneId) : rows; return (
Ульи · {zoneId || greenhouseId || "—"}
{filtered.length} ульев · откройте строку для карточки улья
{loading && ( )} {!loading && error && ( )} {!loading && filtered.length === 0 && ( )} {!loading && filtered.map((item) => ( { if (onSelectHive) onSelectHive(item.id); if (onNav) onNav("hivepage", { greenhouseId: greenhouseId || null, zoneId: item.zone_id || item.zoneId || zoneId || null, hiveId: item.id, }); }} > ))}
Hive ID State Departures/min Temperature Gate Last seen
Загрузка…
{error}
Ульи в выбранной зоне не найдены
{item.id} {item.state_label || item.stateLabel || item.state || "ok"} {item.departures_per_min ?? item.departures ?? "—"} {item.temperature_c != null ? `${item.temperature_c} °C` : "—"} {item.gate_status || item.gate || "—"} {item.last_seen || item.updated_at || "—"}
); } // [TASK_COMPLETE:T006] window.Overview = Overview; window.Sites = Sites; window.Site = Site; window.GreenhousesList = GreenhousesList; window.ZonesList = ZonesList; window.HivesList = HivesList;