// shell.jsx — Sidebar, TopBar, Login, Tenant picker — using DS classes verbatim
function Sidebar({
route,
onNavigate,
alertCount = 3,
taskCount = 14,
tenant,
user,
role = "",
onLogout,
collapsed = false,
onToggleCollapse,
}) {
// Primary nav items are grouped by domain.
// Sub-pages (site, hives, telemetry) and advanced screens (rules, commands,
// ota, recommendations) are reached contextually, not from the sidebar.
const NAV = [
{ group: "Объект", items: [
{ id:"overview", icon:"ri-dashboard-2-line", label:"Обзор" },
{ id:"sites", icon:"ri-leaf-line", label:"Теплицы" },
{ id:"devices", icon:"ri-router-line", label:"Устройства" },
{ id:"telemetry", icon:"ri-pulse-line", label:"Телеметрия" },
{ id:"microclimate", icon:"ri-temp-hot-line", label:"Микроклимат" },
]},
{ group: "Работа", items: [
{ id:"alerts", icon:"ri-alarm-warning-line", label:"Уведомления", badge: alertCount, badgeKind:"crit" },
{ id:"tasks", icon:"ri-task-line", label:"Задачи", badge: taskCount, badgeKind:"warn" },
{ id:"commands", icon:"ri-command-line", label:"Команды", roleMin:"operator" },
{ id:"ota", icon:"ri-upload-cloud-2-line", label:"OTA", roleMin:"operator" },
{ id:"rules", icon:"ri-git-branch-line", label:"Правила", roleMin:"operator" },
{ id:"reports", icon:"ri-bar-chart-2-line", label:"Отчёты" },
{ id:"roi", icon:"ri-funds-line", label:"ROI" },
]},
{ group: "Настройки", items: [
{ id:"settings", icon:"ri-settings-3-line", label:"Настройки" },
{ id:"simulator", icon:"ri-cpu-line", label:"Виртуальный стенд" },
]},
];
// [TASK_START:T014]
const normalizedRole = String(role || "").toLowerCase();
const isLimitedOperator = normalizedRole === "worker" || normalizedRole === "technician";
const visibleNav = NAV.map(group => ({
...group,
items: group.items.filter(item => !(item.roleMin === "operator" && isLimitedOperator)),
}));
// [TASK_COMPLETE:T014]
return (
);
}
function TopBar({ route, onSearch }) {
// Derive the breadcrumb trail from the current route ID using the
// centralised breadcrumb map. Falls back to ["Ahal Agro Park"] for
// unknown routes (matches Keeper.breadcrumbs fallback behaviour).
const crumbs = Keeper.breadcrumbs.get(route);
return (
);
}
/* Login & tenant picker */
function Login({ onLogin, sessionMessage = "" }) {
const [step, setStep] = React.useState("creds"); // creds | tenants
// [TASK_START:T010]
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [remember, setRemember] = React.useState(true);
const [loading, setLoading] = React.useState(false);
const [formError, setFormError] = React.useState("");
const [fieldErrors, setFieldErrors] = React.useState({ email: "", password: "" });
// [TASK_COMPLETE:T010]
const [user, setUser] = React.useState(null);
const [tenants, setTenants] = React.useState([]);
// [TASK_START:T011]
function validate() {
const errors = { email: "", password: "" };
if (!email || !email.trim() || !email.includes("@")) {
errors.email = "Введите корректный адрес почты";
}
if (!password || !password.trim()) {
errors.password = "Введите пароль";
}
setFieldErrors(errors);
return !errors.email && !errors.password;
}
// [TASK_COMPLETE:T011]
async function handleLoginSubmit(event) {
event.preventDefault();
setFormError("");
// [TASK_START:T012]
if (!validate()) return;
setLoading(true);
try {
const response = await Keeper.api.login(email.trim(), password);
const availableTenants = Array.isArray(response.tenants) ? response.tenants : [];
setUser(response.user || null);
setTenants(availableTenants);
window.DATA = Object.assign({}, window.DATA || {}, { user: response.user || null, tenants: availableTenants });
setStep("tenants");
} catch (error) {
const fallbackMessage =
error instanceof Keeper.ApiError && (error.status === 401 || error.status === 403)
? "Неверный адрес почты или пароль"
: "Нет соединения с сервером";
const formatted = typeof Keeper.formatApiFailure === "function"
? Keeper.formatApiFailure(error, { fallbackMessage })
: fallbackMessage;
setFormError(formatted);
} finally {
setLoading(false);
}
// [TASK_COMPLETE:T012]
}
// [TASK_START:T013]
async function handleTenantSelect(selectedTenant) {
setLoading(true);
setFormError("");
try {
const selected = await Keeper.api.selectTenant(selectedTenant.id);
const activeTenant = selected.activeTenant || selected.active_tenant || selectedTenant;
if (remember) {
localStorage.setItem("keeper.activeTenant", JSON.stringify(activeTenant));
} else {
localStorage.removeItem("keeper.activeTenant");
}
window.DATA = Object.assign({}, window.DATA || {}, { activeTenant, user: user || window.DATA?.user || null });
onLogin({ user: user || window.DATA?.user || null, tenant: activeTenant });
} catch (error) {
const fallbackMessage = "Не удалось выбрать объект";
const formatted = typeof Keeper.formatApiFailure === "function"
? Keeper.formatApiFailure(error, { fallbackMessage })
: fallbackMessage;
setFormError(formatted);
} finally {
setLoading(false);
}
}
// [TASK_COMPLETE:T013]
return (
{step === "creds" && (
)}
{step === "tenants" && (
Выберите объект
Вам доступны {tenants.length} организации
{tenants.map(t => (
!loading && handleTenantSelect(t)}>
{t.name}
{t.country} · {t.greenhouses} теплиц · {t.devices} устройств
{t.role}
))}
{formError &&
{formError}
}
)}
Видеть пасеку и теплицу как один организм
Активность колоний, состояние субстрата, прошивки шлейфов — на одном экране, в реальном времени, с автоматизацией для ваших агрономов.
);
}
window.Sidebar = Sidebar;
window.TopBar = TopBar;
window.Login = Login;