// screens-work.jsx — Telemetry, Microclimate, Recommendations, Tasks (Kanban + List), Commands, OTA, Reports, Settings
/* ─── CP-8 Live telemetry ─────────────────────────────────── */
function Telemetry() {
const TelemetryChart = window.TelemetryChart;
const devices = Array.isArray(DATA?.devices) ? DATA.devices : [];
const [selected, setSelected] = React.useState(devices[0]?.id || "");
React.useEffect(() => {
if (!devices.length) return;
if (!selected || !devices.some((d) => d.id === selected)) {
setSelected(devices[0].id);
}
}, [selected, devices]);
const dev = devices.find((d) => d.id === selected) || devices[0] || null;
const events = [
{ t:"14:38:12", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,0 flow=1,2 CO2=812 lux=18400 t=21,4°C" },
{ t:"14:38:08", lvl:"info", msg:"sample EC_sub=2,3 EC_drain=1,9 flow=1,1 CO2=808 lux=18350 t=21,5°C" },
{ t:"14:38:04", lvl:"warn", msg:"battery dropped to 12% — schedule replacement" },
{ t:"14:38:00", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,1 flow=1,2 CO2=815 lux=18420 t=21,4°C" },
{ t:"14:37:56", lvl:"info", msg:"heartbeat ok rssi=−62 fw=v1.8.0" },
{ t:"14:37:48", lvl:"crit", msg:"packet dropped seq=4421 (3rd in 30s)" },
{ t:"14:37:40", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,0 flow=1,3 CO2=810 lux=18380 t=21,3°C" },
{ t:"14:37:32", lvl:"ok", msg:"calibration check passed" },
{ t:"14:37:24", lvl:"info", msg:"sample EC_sub=2,3 EC_drain=1,8 flow=1,1 CO2=805 lux=18310 t=21,4°C" },
];
return (
Живая телеметрия
История датчиков и микроклимата по выбранному устройству
Автообновление 60с (1h)
EC субстрата
EC дренажа
Расход полива
Температура
Влажность
CO₂
Освещённость
Активность
Батарея
Все зоны
Устройства{devices.length}
{devices.map(d=>(
setSelected(d.id)}>
{d.name}
{d.zone}
))}
{dev && (
{dev.name}
{dev.id} · {dev.zone}
{dev.statusLbl}
)}
{!dev && (
Устройства не найдены
)}
{/* [TASK_START:T006] */}
{dev && TelemetryChart && [
{ title: "Температура", metric_key: "temperature_c", unit: "°C", accent: "#E5342B" },
{ title: "Влажность", metric_key: "humidity_pct", unit: "%", accent: "#007BFB" },
{ title: "CO₂", metric_key: "co2_ppm", unit: "ppm", accent: "#7FB539" },
{ title: "Освещённость", metric_key: "insolation_lux", unit: "lux", accent: "#E8B84A" },
{ title: "Влажность субстрата", metric_key: "substrate_moisture_pct", unit: "%", accent: "#00A3A3" },
{ title: "EC дренажа", metric_key: "drain_ec", unit: "mS/cm", accent: "#FF7A00" },
].map((chart) => (
))}
{/* [TASK_COMPLETE:T006] */}
{events.map((e,i)=>(
{e.t}
{e.lvl}
{e.msg}
))}
);
}
/* ─── Microclimate dashboard ──────────────────────────────── */
function metricDisplayName(metric) {
if (metric === "temperature_c") return "Температура";
if (metric === "humidity_pct") return "Влажность";
if (metric === "co2_ppm") return "CO₂";
if (metric === "insolation_lux") return "Освещённость";
return metric || "—";
}
function toMicroclimateFallback(zoneId, range) {
const micro = (DATA && DATA.telemetry && DATA.telemetry.microclimate) || {};
return {
composite: {
zone_id: zoneId,
range,
generated_at: new Date().toISOString(),
data_available: true,
composite_score: null,
metrics: [
{ metric: "temperature_c", unit: "°C", current_value: micro.temperature_c ?? null, status: "unknown" },
{ metric: "humidity_pct", unit: "%", current_value: micro.humidity_pct ?? null, status: "unknown" },
{ metric: "co2_ppm", unit: "ppm", current_value: micro.co2_ppm ?? null, status: "unknown" },
{ metric: "insolation_lux", unit: "lux", current_value: micro.insolation_lux ?? null, status: "unknown" },
],
},
history_comparison: { metrics: [] },
zone_comparison: { metrics: [] },
};
}
function Microclimate() {
const zoneOptions = React.useMemo(() => {
const byGreenhouse = (DATA && DATA.zones) || {};
return Object.keys(byGreenhouse).flatMap((greenhouseId) => {
const rows = Array.isArray(byGreenhouse[greenhouseId]) ? byGreenhouse[greenhouseId] : [];
return rows.map((row) => ({
greenhouseId,
zoneId: row.id,
label: row.row_label || row.id,
}));
});
}, []);
const [selectedZoneId, setSelectedZoneId] = React.useState(() => zoneOptions[0]?.zoneId || "");
const [range, setRange] = React.useState("24h");
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [payload, setPayload] = React.useState(null);
React.useEffect(() => {
if (!selectedZoneId) return;
let cancelled = false;
async function loadMicroclimate() {
setLoading(true);
setError("");
try {
const response = await Keeper.api.zoneMicroclimateDashboard(selectedZoneId, { range });
if (!cancelled) setPayload(response);
} catch (_) {
if (!cancelled) {
setError("Модуль микроклимата временно недоступен");
setPayload(toMicroclimateFallback(selectedZoneId, range));
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadMicroclimate();
return () => {
cancelled = true;
};
}, [selectedZoneId, range]);
const composite = payload && payload.composite ? payload.composite : null;
const history = payload && payload.history_comparison ? payload.history_comparison : null;
const zoneComparison = payload && payload.zone_comparison ? payload.zone_comparison : null;
const metrics = composite && Array.isArray(composite.metrics) ? composite.metrics : [];
return (
Микроклимат
Сводные метрики зоны, динамика и сравнение с соседними зонами
setSelectedZoneId(e.target.value)}>
{zoneOptions.map((zone) => (
{zone.label}
))}
{["24h", "48h", "7d"].map((option) => (
setRange(option)}>
{option}
))}
{error &&
{error}
}
Индекс микроклимата
{composite && composite.composite_score != null
? `${Math.round(Number(composite.composite_score) * 100)}%`
: "—"}
Диапазон: {composite?.range || range}
Точек данных
{composite?.data_points ?? 0}
Зона: {composite?.zone_id || selectedZoneId || "—"}
Сгенерировано
{composite?.generated_at ? new Date(composite.generated_at).toLocaleString("ru-RU") : "—"}
{loading ? "Обновление..." : "Актуально"}
Текущие метрики
{!loading && metrics.length === 0 &&
Нет данных для выбранной зоны
}
Метрика
Значение
База
Оптимум
Статус
{metrics.map((row) => (
{metricDisplayName(row.metric)}
{row.current_value != null ? `${row.current_value} ${row.unit || ""}` : "—"}
{row.baseline_value != null ? `${row.baseline_value} ${row.unit || ""}` : "—"}
{row.optimal_min != null || row.optimal_max != null
? `${row.optimal_min ?? "—"}…${row.optimal_max ?? "—"} ${row.unit || ""}`
: "—"}
{row.status || "unknown"}
))}
Историческое сравнение
{history?.metrics?.length ? (
{history.metrics.map((row) => (
{metricDisplayName(row.metric)}: {row.absolute_delta != null ? row.absolute_delta : "—"} {row.unit || ""} ({row.trend || "unknown"})
))}
) : (
Сравнение периодов пока недоступно
)}
Сравнение зон
{zoneComparison?.metrics?.length ? (
{zoneComparison.metrics.map((row) => (
{metricDisplayName(row.metric)}: ранг {row.rank ?? "—"} из {row.peer_count ?? 0}
))}
) : (
Сравнение с соседними зонами пока недоступно
)}
);
}
/* ─── CP-10 Recommendations ───────────────────────────────── */
function normalizeRecommendationSeverity(raw) {
const value = String(raw || "").toLowerCase();
if (value === "critical" || value === "cri") return "critical";
if (value === "warning" || value === "warn") return "warning";
return "info";
}
function normalizeRecommendationStatus(raw, applied, dismissed) {
const value = String(raw || "").toLowerCase();
if (value === "applied" || applied) return "applied";
if (value === "dismissed" || dismissed) return "dismissed";
return "active";
}
function recommendationSeverityLabel(severity) {
if (severity === "critical") return "Critical";
if (severity === "warning") return "Warning";
return "Info";
}
// [TASK_START:T008]
function RecommendationSeverityBadge({ severity }) {
return (
{recommendationSeverityLabel(severity)}
);
}
// [TASK_COMPLETE:T008]
function recommendationSeverityClass(severity) {
if (severity === "critical") return "cri";
if (severity === "warning") return "warn";
return "info";
}
function recommendationSourceLabel(source) {
const value = String(source || "").toLowerCase();
if (value === "sensor_analysis") return "Sensor analysis";
if (value === "alert_correlation") return "Alert correlation";
if (value === "scheduled_inspection") return "Scheduled inspection";
if (value === "agronomist") return "Agronomist";
return "System";
}
function recommendationStatusLabel(status) {
if (status === "applied") return "Applied";
if (status === "dismissed") return "Dismissed";
return "Active";
}
function recommendationActionIcon(severity) {
if (severity === "critical") return "ri-alarm-warning-line";
if (severity === "warning") return "ri-error-warning-line";
return "ri-lightbulb-line";
}
function toRecoViewModel(raw) {
const severity = normalizeRecommendationSeverity(raw?.severity || raw?.kind);
const status = normalizeRecommendationStatus(raw?.status, raw?.applied, raw?.dismissed);
// [TASK_START:T012]
const expiresAt = raw?.expires_at || raw?.expiresAt || null;
const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
const isExpired = Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now();
// [TASK_COMPLETE:T012]
return {
id: raw?.id || `rec-${Math.random().toString(16).slice(2, 8)}`,
tenant_id: raw?.tenant_id || null,
greenhouse_id: raw?.greenhouse_id || null,
zone_id: raw?.zone_id || null,
severity,
source: raw?.source || "sensor_analysis",
status,
title: raw?.title || "Recommendation",
detail: raw?.detail || raw?.desc || "",
created_at: raw?.created_at || raw?.createdAt || null,
expires_at: expiresAt,
is_expired: isExpired,
icon: raw?.icon || recommendationActionIcon(severity),
applied: Boolean(raw?.applied || status === "applied"),
dismissed: Boolean(raw?.dismissed || status === "dismissed"),
};
}
function formatRecommendationTime(ts) {
if (!ts) return "—";
const value = Date.parse(ts);
if (!Number.isFinite(value)) return String(ts);
return new Date(value).toLocaleString();
}
function Recommendations({ onNav }) {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [recommendations, setRecommendations] = React.useState([]);
const [statusFilter, setStatusFilter] = React.useState("active");
const [severityFilter, setSeverityFilter] = React.useState("all");
const [pendingAction, setPendingAction] = React.useState(null); // { id, type, text, submitting, error }
React.useEffect(() => {
let cancelled = false;
async function loadRecommendations() {
setLoading(true);
setError("");
try {
// [TASK_START:T006]
const rows = await Keeper.api.recommendations();
const list = Array.isArray(rows) ? rows.map(toRecoViewModel) : [];
// [TASK_COMPLETE:T006]
if (!cancelled) setRecommendations(list);
} catch (_) {
if (!cancelled) {
// [TASK_START:T013]
setError("Рекомендации временно недоступны");
const fallback = Array.isArray(DATA?.recommendations) ? DATA.recommendations.map(toRecoViewModel) : [];
setRecommendations(fallback);
// [TASK_COMPLETE:T013]
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadRecommendations();
return () => {
cancelled = true;
};
}, []);
const statusCounts = React.useMemo(() => {
const counts = { active: 0, applied: 0, dismissed: 0 };
recommendations.forEach((rec) => {
const value = rec.status;
if (value === "applied") counts.applied += 1;
else if (value === "dismissed") counts.dismissed += 1;
else counts.active += 1;
});
return counts;
}, [recommendations]);
// [TASK_START:T009]
const visibleRecommendations = React.useMemo(() => {
return recommendations.filter((rec) => {
if (statusFilter !== "all" && rec.status !== statusFilter) return false;
if (severityFilter !== "all" && rec.severity !== severityFilter) return false;
return true;
});
}, [recommendations, statusFilter, severityFilter]);
// [TASK_COMPLETE:T009]
function beginAction(recId, type) {
setPendingAction({ id: recId, type, text: "", submitting: false, error: "" });
}
function cancelAction() {
setPendingAction(null);
}
async function confirmAction() {
if (!pendingAction) return;
const prev = recommendations;
const { id, type, text } = pendingAction;
// [TASK_START:T007]
if (type === "apply") {
setRecommendations((rows) =>
rows.map((row) =>
row.id === id
? { ...row, status: "applied", applied: true, dismissed: false }
: row
)
);
} else {
setRecommendations((rows) =>
rows.map((row) =>
row.id === id
? { ...row, status: "dismissed", dismissed: true, applied: false }
: row
)
);
}
// [TASK_COMPLETE:T007]
setPendingAction((state) => ({ ...state, submitting: true, error: "" }));
try {
if (type === "apply") {
await Keeper.api.applyRecommendation(id, text ? { note: text } : undefined);
} else {
await Keeper.api.dismissRecommendation(id, text ? { reason: text } : undefined);
}
setPendingAction(null);
} catch (_) {
setRecommendations(prev);
setPendingAction((state) => ({
...state,
submitting: false,
error: type === "apply" ? "Не удалось применить рекомендацию" : "Не удалось отклонить рекомендацию",
}));
}
}
return (
Рекомендации
Приоритизированные действия по зонам и теплицам
setStatusFilter("active")}>
Active · {statusCounts.active}
setStatusFilter("applied")}>
Applied · {statusCounts.applied}
setStatusFilter("dismissed")}>
Dismissed · {statusCounts.dismissed}
setSeverityFilter("all")}>All
setSeverityFilter("warning")}>Warning
setSeverityFilter("critical")}>Critical
setSeverityFilter("info")}>Info
{/* [TASK_START:T013] */}
{error &&
{error}
}
{/* [TASK_COMPLETE:T013] */}
{loading &&
Загрузка рекомендаций…
}
{!loading && recommendations.length === 0 && (
Рекомендаций пока нет
Когда аналитика сформирует новые рекомендации, они появятся здесь.
)}
{!loading && recommendations.length > 0 && visibleRecommendations.length === 0 && (
Нет рекомендаций для выбранных фильтров
)}
{visibleRecommendations.map((r)=>(
{recommendationStatusLabel(r.status)}
{/* [TASK_START:T012] */}
{r.is_expired && Expired }
{/* [TASK_COMPLETE:T012] */}
{r.title}
{r.detail}
{recommendationSourceLabel(r.source)}
{r.zone_id || "—"}
Expires: {formatRecommendationTime(r.expires_at)}
{r.id}
Created: {formatRecommendationTime(r.created_at)}
{pendingAction && pendingAction.id === r.id && (
<>
{/* [TASK_START:T010] */}
{pendingAction.type === "apply" ? "Комментарий (опционально)" : "Причина (опционально)"}
{/* [TASK_COMPLETE:T010] */}
>
)}
beginAction(r.id, "apply")}
disabled={r.status !== "active" || r.is_expired}
>
{/* [TASK_START:T010] */}
Apply
{/* [TASK_COMPLETE:T010] */}
onNav("tasks")}> Create task
beginAction(r.id, "dismiss")}
disabled={r.status !== "active" || r.is_expired}
>
{/* [TASK_START:T011] */}
Dismiss
{/* [TASK_COMPLETE:T011] */}
))}
);
}
// [TASK_START:T014]
function RecommendationsPanel({ greenhouseId, zoneId, maxItems = 4 }) {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [recommendations, setRecommendations] = React.useState([]);
React.useEffect(() => {
let cancelled = false;
async function loadRecommendations() {
setLoading(true);
setError("");
try {
const rows = await Keeper.api.recommendations();
const list = Array.isArray(rows) ? rows.map(toRecoViewModel) : [];
if (!cancelled) setRecommendations(list);
} catch (_) {
if (!cancelled) {
setError("Рекомендации временно недоступны");
const fallback = Array.isArray(DATA?.recommendations) ? DATA.recommendations.map(toRecoViewModel) : [];
setRecommendations(fallback);
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadRecommendations();
return () => {
cancelled = true;
};
}, [greenhouseId, zoneId]);
const visible = React.useMemo(() => {
return recommendations
.filter((item) => {
if (item.status !== "active") return false;
if (zoneId) return item.zone_id === zoneId;
if (greenhouseId) return item.greenhouse_id === greenhouseId;
return true;
})
.slice(0, maxItems);
}, [greenhouseId, maxItems, recommendations, zoneId]);
async function quickApply(id) {
const prev = recommendations;
setRecommendations((rows) =>
rows.map((row) => (row.id === id ? { ...row, status: "applied", applied: true } : row))
);
try {
await Keeper.api.applyRecommendation(id);
} catch (_) {
setRecommendations(prev);
}
}
return (
Recommendations
{error && {error}
}
{loading && Загрузка…
}
{!loading && !error && visible.length === 0 && (
Нет активных рекомендаций для текущего контекста
)}
{!loading && !error && visible.map((item) => (
{item.is_expired && Expired }
{item.title}
{item.detail}
{item.zone_id || "—"}
quickApply(item.id)}
disabled={item.is_expired}
>
Quick apply
))}
);
}
// [TASK_COMPLETE:T014]
/* ─── CP-11 Tasks Kanban ──────────────────────────────────── */
function TasksKanban() {
const [view, setView] = React.useState("kanban");
const [tasks, setTasks] = React.useState(DATA.tasks);
const [drag, setDrag] = React.useState(null);
const cols = [
{ id:"todo", label:"К выполнению", count: tasks.filter(t=>t.col==="todo").length },
{ id:"doing", label:"В работе", count: tasks.filter(t=>t.col==="doing").length },
{ id:"review", label:"На проверке", count: tasks.filter(t=>t.col==="review").length },
{ id:"done", label:"Готово", count: tasks.filter(t=>t.col==="done").length },
];
function onDragStart(id) { setDrag(id); }
function onDrop(colId) {
if (!drag) return;
setTasks(ts => ts.map(t => t.id===drag ? {...t, col: colId} : t));
setDrag(null);
}
return (
Задачи
{tasks.filter(t=>t.col!=="done").length} открытых · {tasks.filter(t=>t.col==="done").length} закрыто за неделю
setView("kanban")}>Канбан
setView("list")}>Список
Фильтр
Новая задача
{view === "kanban" && (
{cols.map(col => (
e.preventDefault()}
onDrop={()=>onDrop(col.id)}
>
{col.label}
{col.count}
{tasks.filter(t=>t.col===col.id).map(t=>(
onDragStart(t.id)}
onDragEnd={()=>setDrag(null)}
>
{t.id}
{t.site}
{t.title}
{t.linked &&
из рекомендации {t.linked}
}
{t.assignee}
{t.due}
))}
))}
)}
{view === "list" && (
ID Задача Приоритет Объект Исполнитель Срок Статус
{tasks.map(t=>(
{t.id}
{t.title} {t.linked && · из {t.linked} }
{t.prio==="high"?"Высокий":t.prio==="med"?"Средний":"Низкий"}
{t.site}
{t.assignee}
{t.due}
{t.col==="todo"?"К выполнению":t.col==="doing"?"В работе":t.col==="review"?"На проверке":"Готово"}
))}
)}
);
}
/* ─── CP-12 Command center ────────────────────────────────── */
function Commands() {
const [showConfirm, setShowConfirm] = React.useState(false);
const [pending, setPending] = React.useState([
{ id:"CMD-882", at:"14:32", who:"ИП", action:"Закрыть задвижку", target:"AB1 · B-09", state:"running" },
{ id:"CMD-881", at:"14:18", who:"авто", action:"Открыть задвижки", target:"AB1, AB2 · 14 шт", state:"ok" },
]);
function execute() {
setShowConfirm(false);
const cmd = { id:"CMD-883", at:"14:42", who:"ИП", action:"Закрыть задвижки", target:"AB2 · 6 шт", state:"queued" };
setPending(p=>[cmd,...p]);
}
return (
Команды
Прямое управление устройствами с логом и подтверждением
Новая команда
1 · Цель
Задвижки
TDS
Полив
Камеры
3 · Действие
Закрыть
Открыть
Перезагрузить
Калибровать
Превью команды
{"POST /devices/zone:AB2/gates/close\nbody: { duration: \"4h\", reason: \"chem-treatment\" }\ntargets: 6 devices\nestimated: 12s"}
Сохранить пресет
setShowConfirm(true)}>
Выполнить (с подтверждением)
Активные и недавние
ID Действие Цель Кем Статус
{pending.map(c=>(
{c.id}{c.at}
{c.action}
{c.target}
{c.who}
{c.state==="queued"?"в очереди":c.state==="running"?"в работе":"выполнено"}
))}
{showConfirm && (
setShowConfirm(false)}>
e.stopPropagation()}>
Подтвердите команду
Действие затронет 6 устройств и невозможно отменить
setShowConfirm(false)}>
Действие Закрыть задвижки
Цель AB2 · 6 ульев
Длительность 4 часа
Причина Химобработка
Команда требует подтверждения второго пользователя при цели > 5 устройств. Запрос отправлен М. Кузьминой.
setShowConfirm(false)}>Отменить
Выполнить
)}
);
}
/* ─── CP-13 OTA campaigns ─────────────────────────────────── */
function OTA() {
return (
OTA-обновления
5 кампаний · 1 в работе · 1 неудача
История
Новая кампания
Кампания
Цель
Прогресс
Статус
{DATA.ota.map(o=>(
{o.name}
{o.id} · обновлено сегодня 14:38
{o.target}
{o.ok} /{o.total}
{o.fail>0 && · {o.fail} ошибки }
{o.status==="ok"?"Готово":o.status==="failed"?"Ошибка":o.status==="queued"?"В очереди":"В работе"}
))}
);
}
/* ─── CP-14 Reports ───────────────────────────────────────── */
const REPORT_FILTERS = [
{ id: "all", label: "All", icon: "ri-apps-line" },
{ id: "pollination", label: "Pollination", icon: "ri-bug-line" },
{ id: "microclimate", label: "Microclimate", icon: "ri-temp-hot-line" },
{ id: "irrigation", label: "Irrigation", icon: "ri-drop-line" },
{ id: "devices", label: "Devices", icon: "ri-router-line" },
{ id: "summary", label: "Summary", icon: "ri-bar-chart-line" },
];
function reportTemplateType(templateId) {
const value = String(templateId || "").toLowerCase();
if (value.includes("pollination")) return "pollination";
if (value.includes("microclimate")) return "microclimate";
if (value.includes("irrigation")) return "irrigation";
if (value.includes("devices")) return "devices";
if (value.includes("summary")) return "summary";
return "other";
}
function reportScheduleLabel(schedule) {
if (schedule === "daily") return "Ежедневно";
if (schedule === "weekly") return "Еженедельно";
if (schedule === "monthly") return "Ежемесячно";
if (schedule === "on_demand") return "По запросу";
return "—";
}
function formatReportDate(value) {
if (!value) return "—";
const ts = Date.parse(value);
if (!Number.isFinite(ts)) return String(value);
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function normalizeReportSnapshot(snapshot, template) {
if (!snapshot || typeof snapshot !== "object") return null;
const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : {};
const period = snapshot.period && typeof snapshot.period === "object" ? snapshot.period : {};
return {
id: snapshot.id || `snap-${Date.now()}`,
template_id: snapshot.template_id || template?.id || "",
title: snapshot.title || template?.title || "Отчёт",
generated_at: snapshot.generated_at || null,
date_from: snapshot.date_from || period.date_from || null,
date_to: snapshot.date_to || period.date_to || null,
greenhouses: Array.isArray(snapshot.greenhouses)
? snapshot.greenhouses
: Array.isArray(summary.greenhouses)
? summary.greenhouses
: [],
hives: Array.isArray(snapshot.hives)
? snapshot.hives
: Array.isArray(summary.hives)
? summary.hives
: [],
device_uptime: Array.isArray(snapshot.device_uptime)
? snapshot.device_uptime
: Array.isArray(summary.device_uptime)
? summary.device_uptime
: [],
irrigation: Array.isArray(snapshot.irrigation)
? snapshot.irrigation
: Array.isArray(summary.irrigation)
? summary.irrigation
: [],
};
}
function ReportDetail({ snapshot, onExport }) {
if (!snapshot) return null;
const hasGreenhouses = Array.isArray(snapshot.greenhouses) && snapshot.greenhouses.length > 0;
const hasHives = Array.isArray(snapshot.hives) && snapshot.hives.length > 0;
const hasUptime = Array.isArray(snapshot.device_uptime) && snapshot.device_uptime.length > 0;
const hasIrrigation = Array.isArray(snapshot.irrigation) && snapshot.irrigation.length > 0;
const hasAnySection = hasGreenhouses || hasHives || hasUptime || hasIrrigation;
return (
{snapshot.title}
Период: {snapshot.date_from || "—"} → {snapshot.date_to || "—"} · Сформирован: {formatReportDate(snapshot.generated_at)}
onExport(snapshot)}>
Экспорт
{!hasAnySection && (
Сводные таблицы для этого шаблона пока недоступны
)}
{hasGreenhouses && (
Сводка по теплицам
Теплица
Зоны
Ульи
Устройства
T, °C
RH, %
{snapshot.greenhouses.map((row, index) => (
{row.greenhouse_name || row.greenhouse_id || "—"}
{row.zone_count ?? "—"}
{row.hive_count ?? "—"}
{row.device_count ?? "—"}
{row.avg_temperature_c ?? "—"}
{row.avg_humidity_pct ?? "—"}
))}
)}
{hasHives && (
Сводка по ульям
Улей
Теплица
Зона
Статус
Активность/мин
Пик
{snapshot.hives.map((row, index) => (
{row.hive_name || row.hive_id || "—"}
{row.greenhouse_id || "—"}
{row.zone_id || "—"}
{row.status || "—"}
{row.avg_activity_per_min ?? "—"}
{row.peak_activity_per_min ?? "—"}
))}
)}
{hasUptime && (
Стабильность устройств
Устройство
Зона
Uptime
Онлайн, мин
Оффлайн, мин
Батарея, %
{snapshot.device_uptime.map((row, index) => (
{row.device_name || row.device_id || "—"}
{row.zone_id || "—"}
{row.uptime_ratio != null ? `${Math.round(Number(row.uptime_ratio) * 100)}%` : "—"}
{row.uptime_minutes ?? "—"}
{row.downtime_minutes ?? "—"}
{row.avg_battery_pct ?? "—"}
))}
)}
{hasIrrigation && (
Ирригация
Теплица
Влажность субстрата, %
Подача, л
Feed EC
Drain EC
{snapshot.irrigation.map((row, index) => (
{row.greenhouse_name || row.greenhouse_id || "—"}
{row.avg_substrate_moisture_pct ?? "—"}
{row.total_drip_flow_l ?? "—"}
{row.avg_feed_ec ?? "—"}
{row.avg_drain_ec ?? "—"}
))}
)}
);
}
function Reports() {
const fallbackTemplates = Array.isArray(DATA?.reports) ? DATA.reports : [];
const [templates, setTemplates] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
const [demoMode, setDemoMode] = React.useState(false);
const [typeFilter, setTypeFilter] = React.useState("all");
const [detailSnapshot, setDetailSnapshot] = React.useState(null);
const [exportBanner, setExportBanner] = React.useState("");
const [modal, setModal] = React.useState({
open: false,
template: null,
dateFrom: "",
dateTo: "",
submitting: false,
progress: 0,
error: "",
apiError: "",
});
React.useEffect(() => {
let cancelled = false;
async function loadTemplates() {
setLoading(true);
setLoadError("");
setDemoMode(false);
// [TASK_START:T003]
try {
const rows = await Keeper.api.reports();
if (cancelled) return;
setTemplates(Array.isArray(rows) ? rows : []);
// [TASK_COMPLETE:T003]
} catch (error) {
if (cancelled) return;
const fallbackRows = Array.isArray(fallbackTemplates) ? fallbackTemplates : [];
// [TASK_START:T010]
const isApiError =
window.Keeper &&
window.Keeper.ApiError &&
error instanceof window.Keeper.ApiError;
if (isApiError) {
setTemplates(fallbackRows);
setDemoMode(true);
setLoadError("Сервис отчётов недоступен · показаны demo-шаблоны");
setLoading(false);
return;
}
// [TASK_COMPLETE:T010]
setTemplates(fallbackRows);
setDemoMode(true);
setLoadError("Показаны demo-шаблоны отчётов");
} finally {
if (!cancelled) setLoading(false);
}
}
loadTemplates();
return () => {
cancelled = true;
};
}, [fallbackTemplates]);
React.useEffect(() => {
if (!modal.submitting) return undefined;
const timer = setInterval(() => {
setModal((state) => {
if (!state.submitting) return state;
return { ...state, progress: Math.min(92, state.progress + 7) };
});
}, 180);
return () => clearInterval(timer);
}, [modal.submitting]);
React.useEffect(() => {
if (!exportBanner) return undefined;
const timer = setTimeout(() => setExportBanner(""), 2600);
return () => clearTimeout(timer);
}, [exportBanner]);
const filteredTemplates = React.useMemo(() => {
// [TASK_START:T004]
if (typeFilter === "all") return templates;
return templates.filter((template) => reportTemplateType(template.id) === typeFilter);
// [TASK_COMPLETE:T004]
}, [templates, typeFilter]);
const scheduledCount = templates.filter((row) => row.schedule && row.schedule !== "on_demand").length;
const lastRunAt = templates
.map((row) => row.last_run_at)
.filter(Boolean)
.sort()
.slice(-1)[0];
function openGenerateModal(template) {
setModal({
open: true,
template,
dateFrom: "",
dateTo: "",
submitting: false,
progress: 0,
error: "",
apiError: "",
});
}
function closeGenerateModal() {
if (modal.submitting) return;
setModal({
open: false,
template: null,
dateFrom: "",
dateTo: "",
submitting: false,
progress: 0,
error: "",
apiError: "",
});
}
async function confirmGenerateReport() {
if (!modal.template || modal.submitting) return;
// [TASK_START:T009]
if (modal.dateFrom && modal.dateTo && modal.dateFrom > modal.dateTo) {
setModal((state) => ({
...state,
error: "Дата начала не может быть позже даты окончания",
}));
return;
}
// [TASK_COMPLETE:T009]
// [TASK_START:T005]
const payload = {};
if (modal.dateFrom) payload.date_from = modal.dateFrom;
if (modal.dateTo) payload.date_to = modal.dateTo;
setModal((state) => ({
...state,
submitting: true,
progress: 6,
error: "",
apiError: "",
}));
try {
const snapshot = await Keeper.api.runReport(modal.template.id, payload);
setModal((state) => ({ ...state, progress: 100, submitting: false }));
// [TASK_START:T007]
const normalized = normalizeReportSnapshot(snapshot, modal.template);
setDetailSnapshot(normalized);
setModal({
open: false,
template: null,
dateFrom: "",
dateTo: "",
submitting: false,
progress: 0,
error: "",
apiError: "",
});
// [TASK_COMPLETE:T007]
} catch (error) {
setModal((state) => ({
...state,
submitting: false,
progress: 0,
apiError:
(error && error.backendMessage) ||
"Не удалось сформировать отчёт. Повторите попытку.",
}));
}
// [TASK_COMPLETE:T005]
}
function handleExportPlaceholder(snapshot) {
setExportBanner(`Экспорт ${snapshot.title} будет доступен в следующем обновлении`);
}
return (
Отчёты
{templates.length} шаблонов · {scheduledCount} запланированы · последний {formatReportDate(lastRunAt)}
Архив (37)
Новый шаблон
{demoMode && (
Demo mode: отчёты загружены из локальных данных
)}
{loadError &&
{loadError}
}
{exportBanner &&
{exportBanner}
}
{REPORT_FILTERS.map((item) => (
setTypeFilter(item.id)}
>
{item.label}
))}
{loading && (
)}
{!loading && templates.length === 0 && (
// [TASK_START:T008]
// [TASK_COMPLETE:T008]
)}
{!loading && templates.length > 0 && filteredTemplates.length === 0 && (
Нет шаблонов для выбранного фильтра
)}
{!loading && filteredTemplates.length > 0 && (
{filteredTemplates.map((report) => (
{report.title}
{report.description || "—"}
{reportScheduleLabel(report.schedule)}
посл. {formatReportDate(report.last_run_at)}
Шаблон
openGenerateModal(report)}
>
Сгенерировать
))}
)}
{/* [TASK_START:T006] */}
{/* [TASK_COMPLETE:T006] */}
{modal.open && (
event.stopPropagation()}>
Генерация отчёта
{modal.template?.title}
Дата начала
setModal((state) => ({ ...state, dateFrom: event.target.value, error: "" }))}
disabled={modal.submitting}
/>
Дата окончания
setModal((state) => ({ ...state, dateTo: event.target.value, error: "" }))}
disabled={modal.submitting}
/>
{modal.error &&
{modal.error}
}
{modal.apiError &&
{modal.apiError}
}
{modal.progress < 30
? "Сбор данных за период…"
: modal.progress < 65
? "Агрегация по теплицам…"
: modal.progress < 95
? "Формирование сводки…"
: "Готово"}
{" · "}
{modal.progress} %
Отмена
Сформировать
)}
);
}
/* ─── CP-15 Settings ──────────────────────────────────────── */
function _asTrimmedString(value, fallback = "") {
if (typeof value === "string") return value.trim();
if (value === null || value === undefined) return fallback;
return String(value).trim();
}
function _asBool(value, fallback = false) {
if (typeof value === "boolean") return value;
return fallback;
}
function _normalizeSettingsPayload(payload) {
const root = payload && typeof payload === "object" ? payload : {};
const profile = root.profile && typeof root.profile === "object" ? root.profile : {};
const organization = root.organization && typeof root.organization === "object" ? root.organization : {};
const notifications = root.notifications && typeof root.notifications === "object" ? root.notifications : {};
const thresholds = root.thresholds && typeof root.thresholds === "object" ? root.thresholds : {};
const deviceConfig = root.device_config && typeof root.device_config === "object" ? root.device_config : {};
const language = _asTrimmedString(profile.language || organization.locale || "ru", "ru").toLowerCase();
const locale = _asTrimmedString(organization.locale || language || "ru", "ru").toLowerCase();
return {
profile: {
language: language || "ru",
timezone: _asTrimmedString(profile.timezone || organization.timezone || "UTC", "UTC"),
},
organization: {
tenant_id: _asTrimmedString(organization.tenant_id || DATA?.activeTenant?.id || "", ""),
name: _asTrimmedString(organization.name || DATA?.activeTenant?.name || "Ahal Agro Park", "Ahal Agro Park"),
country: _asTrimmedString(organization.country, ""),
timezone: _asTrimmedString(organization.timezone || profile.timezone || "UTC", "UTC"),
locale: locale || "ru",
},
notifications: {
enabled: _asBool(notifications.enabled, true),
alert_email: _asTrimmedString(notifications.alert_email || "", ""),
digest_email: _asBool(notifications.digest_email, true),
push: _asBool(notifications.push, true),
},
thresholds: thresholds,
device_config: deviceConfig,
};
}
function _orgDraftFromSettings(settings) {
const source = settings && settings.organization ? settings.organization : {};
const language = _asTrimmedString(settings?.profile?.language || source.locale || "ru", "ru").toLowerCase();
return {
name: _asTrimmedString(source.name || "Ahal Agro Park", "Ahal Agro Park"),
timezone: _asTrimmedString(source.timezone || "UTC", "UTC"),
language: language || "ru",
};
}
function _notificationsDraftFromSettings(settings) {
const source = settings && settings.notifications ? settings.notifications : {};
return {
enabled: _asBool(source.enabled, true),
push: _asBool(source.push, true),
digest_email: _asBool(source.digest_email, true),
alert_email: _asTrimmedString(source.alert_email || "", ""),
};
}
function _languageLocale(language) {
const normalized = _asTrimmedString(language || "ru", "ru").toLowerCase();
if (normalized === "tk") return "tk";
if (normalized === "en") return "en";
return "ru";
}
function _formatFieldError(error) {
if (typeof Keeper?.extractApiFailure !== "function") return "";
const failure = Keeper.extractApiFailure(error);
if (!failure) return "";
if (!Array.isArray(failure.details)) return "";
const first = failure.details.find((item) => item && typeof item === "object");
if (!first) return "";
const field = _asTrimmedString(first.field || "", "");
const reason = _asTrimmedString(first.reason || first.message || "", "");
if (!field || !reason) return "";
return `${field}: ${reason}`;
}
function Settings() {
const [tab, setTab] = React.useState("org");
const tabs = [
{ id:"org", label:"Организация", icon:"ri-building-line" },
{ id:"members", label:"Пользователи", icon:"ri-team-line" },
{ id:"integ", label:"Интеграции", icon:"ri-plug-line" },
{ id:"notif", label:"Уведомления", icon:"ri-notification-3-line" },
{ id:"api", label:"API-ключи", icon:"ri-key-2-line" },
];
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
const [settings, setSettings] = React.useState(() => _normalizeSettingsPayload(null));
const [orgDraft, setOrgDraft] = React.useState(() => _orgDraftFromSettings(_normalizeSettingsPayload(null)));
const [notifDraft, setNotifDraft] = React.useState(() => _notificationsDraftFromSettings(_normalizeSettingsPayload(null)));
const [orgErrors, setOrgErrors] = React.useState({ name: "", timezone: "" });
const [notifErrors, setNotifErrors] = React.useState({ alert_email: "" });
const [saveState, setSaveState] = React.useState({
org: { submitting: false, message: "", error: "" },
notif: { submitting: false, message: "", error: "" },
});
React.useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setLoadError("");
try {
const payload = await Keeper.api.settings();
if (cancelled) return;
const normalized = _normalizeSettingsPayload(payload);
setSettings(normalized);
setOrgDraft(_orgDraftFromSettings(normalized));
setNotifDraft(_notificationsDraftFromSettings(normalized));
} catch (error) {
if (cancelled) return;
const fallback = _normalizeSettingsPayload(null);
setSettings(fallback);
setOrgDraft(_orgDraftFromSettings(fallback));
setNotifDraft(_notificationsDraftFromSettings(fallback));
const formatted = typeof Keeper.formatApiFailure === "function"
? Keeper.formatApiFailure(error, { fallbackMessage: "Настройки временно недоступны" })
: "Настройки временно недоступны";
setLoadError(formatted);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, []);
const orgDirty = React.useMemo(() => {
const baseline = _orgDraftFromSettings(settings);
return baseline.name !== orgDraft.name
|| baseline.timezone !== orgDraft.timezone
|| baseline.language !== orgDraft.language;
}, [settings, orgDraft]);
const notifDirty = React.useMemo(() => {
const baseline = _notificationsDraftFromSettings(settings);
return baseline.enabled !== notifDraft.enabled
|| baseline.push !== notifDraft.push
|| baseline.digest_email !== notifDraft.digest_email
|| baseline.alert_email !== notifDraft.alert_email;
}, [settings, notifDraft]);
function validateOrgDraft(draft) {
const normalized = {
name: _asTrimmedString(draft.name, ""),
timezone: _asTrimmedString(draft.timezone, ""),
language: _languageLocale(draft.language),
};
const nextErrors = {
name: normalized.name.length >= 2 ? "" : "Введите название (минимум 2 символа)",
timezone: normalized.timezone ? "" : "Выберите часовой пояс",
};
setOrgErrors(nextErrors);
return {
normalized,
valid: !nextErrors.name && !nextErrors.timezone,
};
}
function validateNotificationsDraft(draft) {
const normalized = {
enabled: Boolean(draft.enabled),
push: Boolean(draft.push),
digest_email: Boolean(draft.digest_email),
alert_email: _asTrimmedString(draft.alert_email || "", ""),
};
const hasAlertEmail = normalized.alert_email.length > 0;
const emailOk = !hasAlertEmail || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized.alert_email);
const nextErrors = {
alert_email: emailOk ? "" : "Введите корректный email для аварийных уведомлений",
};
setNotifErrors(nextErrors);
return {
normalized,
valid: !nextErrors.alert_email,
};
}
async function saveOrganization() {
const checked = validateOrgDraft(orgDraft);
if (!checked.valid) return;
const previousSettings = settings;
const previousOrgDraft = orgDraft;
const locale = _languageLocale(checked.normalized.language);
const optimistic = {
...settings,
profile: {
...settings.profile,
language: checked.normalized.language,
timezone: checked.normalized.timezone,
},
organization: {
...settings.organization,
name: checked.normalized.name,
timezone: checked.normalized.timezone,
locale,
},
};
setSaveState((state) => ({
...state,
org: { submitting: true, message: "Сохраняем…", error: "" },
}));
setSettings(optimistic);
setOrgDraft(_orgDraftFromSettings(optimistic));
try {
const response = await Keeper.api.saveSettingsOrganization({
name: checked.normalized.name,
timezone: checked.normalized.timezone,
locale,
});
const normalized = _normalizeSettingsPayload(response);
setSettings(normalized);
setOrgDraft(_orgDraftFromSettings(normalized));
setSaveState((state) => ({
...state,
org: { submitting: false, message: "Сохранено", error: "" },
}));
} catch (error) {
setSettings(previousSettings);
setOrgDraft(previousOrgDraft);
const baseMessage = typeof Keeper.formatApiFailure === "function"
? Keeper.formatApiFailure(error, { fallbackMessage: "Не удалось сохранить настройки организации" })
: "Не удалось сохранить настройки организации";
const fieldMessage = _formatFieldError(error);
setSaveState((state) => ({
...state,
org: {
submitting: false,
message: "",
error: fieldMessage ? `${baseMessage} · ${fieldMessage}` : baseMessage,
},
}));
}
}
async function saveNotifications() {
const checked = validateNotificationsDraft(notifDraft);
if (!checked.valid) return;
const previousSettings = settings;
const previousNotifDraft = notifDraft;
const optimistic = {
...settings,
notifications: {
...settings.notifications,
...checked.normalized,
},
};
setSaveState((state) => ({
...state,
notif: { submitting: true, message: "Сохраняем…", error: "" },
}));
setSettings(optimistic);
setNotifDraft(_notificationsDraftFromSettings(optimistic));
try {
const response = await Keeper.api.saveSettingsNotifications({
enabled: checked.normalized.enabled,
alert_email: checked.normalized.alert_email || null,
digest_email: checked.normalized.digest_email,
push: checked.normalized.push,
});
const normalized = _normalizeSettingsPayload(response);
setSettings(normalized);
setNotifDraft(_notificationsDraftFromSettings(normalized));
setSaveState((state) => ({
...state,
notif: { submitting: false, message: "Сохранено", error: "" },
}));
} catch (error) {
setSettings(previousSettings);
setNotifDraft(previousNotifDraft);
const baseMessage = typeof Keeper.formatApiFailure === "function"
? Keeper.formatApiFailure(error, { fallbackMessage: "Не удалось сохранить настройки уведомлений" })
: "Не удалось сохранить настройки уведомлений";
const fieldMessage = _formatFieldError(error);
setSaveState((state) => ({
...state,
notif: {
submitting: false,
message: "",
error: fieldMessage ? `${baseMessage} · ${fieldMessage}` : baseMessage,
},
}));
}
}
const tzOptions = [
{ value: "Asia/Ashgabat", label: "UTC+05:00 Ашхабад" },
{ value: "Europe/Amsterdam", label: "UTC+01:00 Амстердам" },
{ value: "Europe/Madrid", label: "UTC+01:00 Мадрид" },
{ value: "UTC", label: "UTC+00:00" },
];
const inputStyle = {
height: 36,
border: "1px solid var(--border)",
borderRadius: 8,
padding: "0 12px",
font: "500 14px Inter",
width: 300,
};
return (
Настройки
Управление организацией и доступом
{tabs.map(t=>(
setTab(t.id)}>
{t.label}
))}
{loading &&
Загрузка настроек…
}
{!loading && loadError &&
{loadError}
}
{tab==="org" && (<>
Организация
Изменения применяются к активному tenant
{saveState.org.error && {saveState.org.error} }
{!saveState.org.error && saveState.org.message && (
{saveState.org.message}
)}
{saveState.org.submitting ? "Сохранение..." : "Сохранить"}
Название
Видно всем участникам
{
setOrgDraft((state) => ({ ...state, name: event.target.value }));
setOrgErrors((state) => ({ ...state, name: "" }));
setSaveState((state) => ({ ...state, org: { ...state.org, message: "", error: "" } }));
}}
style={{
...inputStyle,
borderColor: orgErrors.name ? "#E5342B" : "var(--border)",
}}
/>
{orgErrors.name &&
{orgErrors.name}
}
Часовой пояс
Используется для расписаний и отчётов
{
setOrgDraft((state) => ({ ...state, timezone: event.target.value }));
setOrgErrors((state) => ({ ...state, timezone: "" }));
setSaveState((state) => ({ ...state, org: { ...state.org, message: "", error: "" } }));
}}
style={{
...inputStyle,
borderColor: orgErrors.timezone ? "#E5342B" : "var(--border)",
}}
>
{tzOptions.map((option) => (
{option.label}
))}
{orgErrors.timezone &&
{orgErrors.timezone}
}
Язык интерфейса
По умолчанию для новых пользователей
{
setOrgDraft((state) => ({ ...state, language: "ru" }));
setSaveState((state) => ({ ...state, org: { ...state.org, message: "", error: "" } }));
}}
>
Русский
{
setOrgDraft((state) => ({ ...state, language: "en" }));
setSaveState((state) => ({ ...state, org: { ...state.org, message: "", error: "" } }));
}}
>
English
{
setOrgDraft((state) => ({ ...state, language: "tk" }));
setSaveState((state) => ({ ...state, org: { ...state.org, message: "", error: "" } }));
}}
>
Türkmen
Лого
Используется в шапке и отчётах
>)}
{tab==="members" && (<>
Пользователи Пригласить
Имя Email Роль Активность
{DATA.members.map((m,i)=>(
{m.init} {m.name}
{m.email}
{m.role}
{m.last}
))}
>)}
{tab==="integ" && (<>
Интеграции
{[
{ n:"Telegram", desc:"Уведомления и команды через бот", on:true, icon:"ri-telegram-line" },
{ n:"Email (SMTP)", desc:"Транзакционные письма через ваш SMTP", on:true, icon:"ri-mail-line" },
{ n:"1С: Предприятие", desc:"Выгрузка наряд-задач и расходников", on:false, icon:"ri-file-text-line" },
{ n:"S3 / MinIO", desc:"Архив отчётов и снимков с камер", on:true, icon:"ri-database-2-line" },
{ n:"Webhook", desc:"Push событий во внешние системы", on:false, icon:"ri-link" },
].map((it,i)=>(
))}
>)}
{tab==="notif" && (<>
Уведомления
Выберите каналы и email для аварийных сообщений
{saveState.notif.error && {saveState.notif.error} }
{!saveState.notif.error && saveState.notif.message && (
{saveState.notif.message}
)}
{saveState.notif.submitting ? "Сохранение..." : "Сохранить"}
{[
{key:"enabled", n:"Включить уведомления", desc:"Глобальный переключатель для всех каналов"},
{key:"push", n:"Push в приложении", desc:"Мгновенные уведомления для операторов"},
{key:"digest_email", n:"Email-дайджест", desc:"Ежедневная сводка по событиям и статусам"},
].map((it)=>(
{
setNotifDraft((state) => ({ ...state, [it.key]: !state[it.key] }));
setSaveState((state) => ({ ...state, notif: { ...state.notif, message: "", error: "" } }));
}}
>
{notifDraft[it.key] ? "Вкл" : "Выкл"}
))}
Email для аварий
Куда отправлять критические уведомления
{
setNotifDraft((state) => ({ ...state, alert_email: event.target.value }));
setNotifErrors((state) => ({ ...state, alert_email: "" }));
setSaveState((state) => ({ ...state, notif: { ...state.notif, message: "", error: "" } }));
}}
placeholder="ops@example.com"
style={{
...inputStyle,
borderColor: notifErrors.alert_email ? "#E5342B" : "var(--border)",
}}
/>
{notifErrors.alert_email &&
{notifErrors.alert_email}
}
>)}
{tab==="api" && (<>
API-ключи Создать
Имя Префикс Создан Последний запрос
Grafana readonly kpr_8a2f... 14 янв 2026 5 мин назад Активен
1С интеграция kpr_771c... 2 ноя 2025 1 ч назад Активен
Старый ключ Zabbix kpr_4421... 17 авг 2025 — Отозван
>)}
);
}
window.Telemetry = Telemetry;
window.Microclimate = Microclimate;
window.Recommendations = Recommendations;
window.RecommendationsPanel = RecommendationsPanel;
window.TasksKanban = TasksKanban;
window.Commands = Commands;
window.OTA = OTA;
window.Reports = Reports;
window.Settings = Settings;