Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
.git
*.log
.env
.env.*
+6
View File
@@ -0,0 +1,6 @@
# Copy to .env for local dev: cp .env.example .env
# In dev, API proxy is enabled by default (requests go through Vite, not cross-origin).
VITE_USE_API_PROXY=true
VITE_API_URL=http://localhost:8000
VITE_APP_NAME=Compton
VITE_SENTRY_DSN=
+31
View File
@@ -0,0 +1,31 @@
# Monorepo dev image: build context must be the repository root (see docker-compose.yml).
FROM node:22-alpine
# pnpm 9+ via Corepack (bundled with Node 22)
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
# --- dependency layer: copy only manifests so `pnpm install` can be cached ---
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/eslint-config/package.json ./packages/eslint-config/
# Workspace packages referenced by the lockfile (minimal source for install/link)
COPY packages/shared-types ./packages/shared-types/
COPY packages/eslint-config ./packages/eslint-config/
RUN pnpm install --frozen-lockfile
# App source baked into the image; at runtime bind-mount overrides for live-reload
COPY apps/web ./apps/web/
EXPOSE 5173
# Chokidar polling helps file watching on Docker Desktop (Windows/macOS bind mounts)
ENV CHOKIDAR_USEPOLLING=true
ENV WATCHPACK_POLLING=true
# `pnpm exec` runs vite directly in the web workspace (avoids `--` arg forwarding issues)
CMD ["pnpm", "--filter", "web", "exec", "vite", "--host", "0.0.0.0"]
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Compton platform by Organic Tech" />
<link rel="stylesheet" href="/main/css/tokens.css" />
<style>
html.admin-route,
html.admin-route body {
background: #f6f6f4;
margin: 0;
}
html.admin-route[data-admin-theme="dark"],
html.admin-route[data-admin-theme="dark"] body {
background: #1f2229;
}
</style>
<script>
if (/^\/admin(\/|$)/.test(location.pathname)) {
document.documentElement.classList.add("admin-route");
if (localStorage.getItem("wespAdminTheme") === "dark") {
document.documentElement.setAttribute("data-admin-theme", "dark");
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Compton</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
import { expect, test } from "@playwright/test";
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
test("blocked user access token rejected immediately after block", async ({ request }) => {
const email = uniqueEmail("e2e-block-jwt");
const session = await registerVerifyLogin(request, email);
const admin = await adminLogin(request);
const adminToken = (await admin.json()).access_token;
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { status: "blocked" }
});
expect(patch.ok()).toBeTruthy();
const me = await request.get(`${API_URL}/api/v1/users/me`, {
headers: { Authorization: `Bearer ${session.accessToken}` }
});
expect(me.status()).toBe(401);
expect((await me.json()).detail).toBe("TOKEN_REVOKED");
});
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
const email = uniqueEmail("e2e-block");
const session = await registerVerifyLogin(request, email);
const admin = await adminLogin(request);
const adminToken = (await admin.json()).access_token;
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { status: "blocked" }
});
expect(patch.ok()).toBeTruthy();
await loginViaUi(page, email, "Valid1234");
await expect(page).toHaveURL(/\/login$/);
});
test("admin can open platform users page", async ({ page }) => {
await loginViaUi(page, "admin@compton.example", "Admin1234");
await expect(page).toHaveURL(/\/recipes$/);
await page.goto("/platform");
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
});
test("admin session survives reload on /platform", async ({ page }) => {
await loginViaUi(page, "admin@compton.example", "Admin1234");
await page.goto("/platform");
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
await page.goto("/platform");
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
});
test("non-admin cannot open platform page", async ({ page, request }) => {
const email = uniqueEmail("e2e-nonadmin");
await registerVerifyLogin(request, email);
await loginViaUi(page, email, "Valid1234");
await expect(page).toHaveURL(/\/profile$/);
await page.goto("/platform");
await expect(page).toHaveURL(/\/$/);
});
test("admin cannot block self", async ({ request }) => {
const admin = await adminLogin(request);
const body = await admin.json();
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
headers: { Authorization: `Bearer ${body.access_token}` },
data: { status: "blocked" }
});
expect(response.status()).toBe(400);
expect((await response.json()).detail).toBe("SELF_BLOCK_FORBIDDEN");
});
test("admin cannot demote self", async ({ request }) => {
const admin = await adminLogin(request);
const body = await admin.json();
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
headers: { Authorization: `Bearer ${body.access_token}` },
data: { role: "user" }
});
expect(response.status()).toBe(400);
expect((await response.json()).detail).toBe("SELF_DEMOTION_FORBIDDEN");
});
});
+6
View File
@@ -0,0 +1,6 @@
import { expect, test } from "@playwright/test";
test("login page renders", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible();
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
import { API_URL, fetchLatestToken, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 3: Password reset", () => {
test("forgot password → reset → login with new password", async ({ request }) => {
const email = uniqueEmail("e2e-reset");
const oldPassword = "Valid1234";
const newPassword = "ResetValid1";
await registerVerifyLogin(request, email, oldPassword);
const forgot = await request.post(`${API_URL}/api/v1/auth/forgot-password`, {
data: { email }
});
expect(forgot.ok()).toBeTruthy();
const token = await fetchLatestToken(request, email, "reset_password");
const reset = await request.post(`${API_URL}/api/v1/auth/reset-password`, {
data: { token, new_password: newPassword }
});
expect(reset.ok()).toBeTruthy();
const oldLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { email, password: oldPassword }
});
expect(oldLogin.status()).toBe(401);
const newLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { email, password: newPassword }
});
expect(newLogin.ok()).toBeTruthy();
});
});
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
function extractRefreshCookie(headers: Record<string, string>): string {
const setCookie = headers["set-cookie"] ?? headers["Set-Cookie"] ?? "";
const match = setCookie.match(/refresh_token=([^;]+)/);
if (!match) {
throw new Error("refresh_token cookie missing");
}
return match[1];
}
test.describe("§15.7 scenario 7: Refresh rotation", () => {
test("old refresh token rejected after rotation; reuse revokes family", async ({ request }) => {
const email = uniqueEmail("e2e-refresh");
await registerVerifyLogin(request, email);
const login = await request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password: "Valid1234" } });
const oldRefresh = extractRefreshCookie(login.headers());
const rotated = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${oldRefresh}` }
});
expect(rotated.ok()).toBeTruthy();
const newRefresh = extractRefreshCookie(rotated.headers());
const oldReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${oldRefresh}` }
});
expect(oldReuse.status()).toBe(401);
const familyReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${newRefresh}` }
});
expect(familyReuse.status()).toBe(401);
});
});
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import {
loginViaUi,
registerUser,
uniqueEmail,
verifyEmail
} from "../helpers/api";
test.describe("§15.7 scenario 2: Auth full journey", () => {
test("register → verify → login → profile edit → change password → logout", async ({
page,
request
}) => {
const email = uniqueEmail("e2e-flow");
const password = "Valid1234";
const newPassword = "NewValid1";
await registerUser(request, email, password);
await verifyEmail(request, email);
await loginViaUi(page, email, password);
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByText(email)).toBeVisible();
await page.getByLabel("Display name").fill("E2E User");
await page.getByRole("button", { name: "Save name" }).click();
await expect(page.getByText("Profile updated")).toBeVisible();
await page.getByPlaceholder("Current password").fill(password);
await page.getByPlaceholder("New password").fill(newPassword);
await page.getByRole("button", { name: "Change password" }).click();
await expect(page.getByText("Password changed")).toBeVisible();
await page.getByRole("button", { name: "Logout" }).click();
await expect(page).toHaveURL(/\/login$/);
await loginViaUi(page, email, newPassword);
await expect(page).toHaveURL(/\/profile$/);
});
});
@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
import { API_URL, adminLogin } from "../helpers/api";
test.describe("§15.7 scenario 4: Admin content publish", () => {
test("admin creates content → publish → visible on /pages/:slug", async ({ page, request }) => {
const admin = await adminLogin(request);
const headers = { Authorization: `Bearer ${(await admin.json()).access_token}` };
const slug = `e2e-page-${Date.now()}`;
const created = await request.post(`${API_URL}/api/v1/content/pages`, {
headers,
data: {
slug,
title: "E2E Published Page",
body: "<p>Published by admin</p>",
status: "published"
}
});
expect(created.ok()).toBeTruthy();
await page.goto(`/pages/${slug}`);
await expect(page.getByRole("heading", { name: "E2E Published Page" })).toBeVisible();
await expect(page.getByText("Published by admin")).toBeVisible();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { APIRequestContext } from "@playwright/test";
export const API_URL = process.env.E2E_API_URL ?? "http://127.0.0.1:8001";
export function uniqueEmail(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
}
export async function registerUser(request: APIRequestContext, email: string, password = "Valid1234") {
const response = await request.post(`${API_URL}/api/v1/auth/register`, {
data: { email, password }
});
return response;
}
export async function fetchLatestToken(
request: APIRequestContext,
email: string,
template: "verify_email" | "reset_password"
): Promise<string> {
const response = await request.get(`${API_URL}/api/v1/test/emails/latest-token`, {
params: { to: email, template }
});
if (!response.ok()) {
throw new Error(`Token not found for ${email} (${template})`);
}
const body = await response.json();
return body.token as string;
}
export async function verifyEmail(request: APIRequestContext, email: string) {
const token = await fetchLatestToken(request, email, "verify_email");
return request.post(`${API_URL}/api/v1/auth/verify-email`, { data: { token } });
}
export async function loginApi(request: APIRequestContext, email: string, password = "Valid1234") {
return request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password } });
}
export async function adminLogin(request: APIRequestContext) {
return loginApi(request, "admin@compton.example", "Admin1234");
}
export async function registerVerifyLogin(
request: APIRequestContext,
email: string,
password = "Valid1234"
) {
await registerUser(request, email, password);
await verifyEmail(request, email);
const login = await loginApi(request, email, password);
const body = await login.json();
return {
accessToken: body.access_token as string,
user: body.user as { id: string; email: string; role: string; status: string }
};
}
export async function loginViaUi(page: import("@playwright/test").Page, email: string, password: string) {
await page.goto("/login");
await page.getByPlaceholder("Email").fill(email);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Login" }).click();
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
test.describe("§15.7 scenario 1: Landing", () => {
test("hero and marquee are visible", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
await expect(page.locator("#integrations .marquee-track")).toBeVisible();
});
test("respects prefers-reduced-motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
});
});
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64"
);
test.describe("§15.7 scenario 10: Avatar validation", () => {
test("rejects svg, oversize and accepts valid png", async ({ request }) => {
const session = await registerVerifyLogin(request, uniqueEmail("e2e-avatar"));
const headers = { Authorization: `Bearer ${session.accessToken}` };
const svg = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "avatar.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'></svg>")
}
}
});
expect(svg.status()).toBe(400);
const oversize = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "big.png",
mimeType: "image/png",
buffer: Buffer.concat([tinyPng, Buffer.alloc(2 * 1024 * 1024 + 1)])
}
}
});
expect(oversize.status()).toBe(400);
expect((await oversize.json()).detail).toBe("FILE_TOO_LARGE");
const valid = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "avatar.png",
mimeType: "image/png",
buffer: tinyPng
}
}
});
expect(valid.ok()).toBeTruthy();
expect((await valid.json()).profile.avatar_url).toContain("/api/v1/media/files/avatars/");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
import { registerUser, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 6: Pending user", () => {
test("pending user cannot access /profile", async ({ page, request }) => {
const email = uniqueEmail("e2e-pending");
await registerUser(request, email, "Valid1234");
await page.goto("/profile");
await expect(page).toHaveURL(/\/login$/);
await page.goto("/login");
await page.getByPlaceholder("Email").fill(email);
await page.getByPlaceholder("Password").fill("Valid1234");
await page.getByRole("button", { name: "Login" }).click();
await expect(page).toHaveURL(/\/login$/);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 8: IDOR", () => {
test("user A cannot patch user B via admin route", async ({ request }) => {
const userA = await registerVerifyLogin(request, uniqueEmail("e2e-a"));
const userB = await registerVerifyLogin(request, uniqueEmail("e2e-b"));
const forbidden = await request.patch(`${API_URL}/api/v1/admin/users/${userB.user.id}`, {
headers: { Authorization: `Bearer ${userA.accessToken}` },
data: { status: "blocked" }
});
expect(forbidden.status()).toBe(403);
});
});
+39
View File
@@ -0,0 +1,39 @@
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import boundaries from "eslint-plugin-boundaries";
export default [
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: { project: "./tsconfig.json" }
},
plugins: {
"@typescript-eslint": tsPlugin,
boundaries
},
rules: {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
{ from: "app", allow: ["app", "pages", "modules", "shared"] },
{ from: "pages", allow: ["pages", "modules", "shared"] },
{ from: "modules", allow: ["modules", "shared"] },
{ from: "shared", allow: ["shared"] }
]
}
]
},
settings: {
"boundaries/elements": [
{ type: "app", pattern: "src/app/*" },
{ type: "pages", pattern: "src/pages/*" },
{ type: "modules", pattern: "src/modules/*" },
{ type: "shared", pattern: "src/shared/*" }
]
}
}
];
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Комптон</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/main/css/style.css" />
</head>
<body>
<!-- ====== НАВБАР ====== -->
<header class="navbar" role="navigation">
<div class="container">
<a href="#" class="navbar-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<input type="checkbox" id="burgerToggle" class="burger-checkbox" />
<label for="burgerToggle" class="burger-icon" aria-label="Открыть меню">
<span></span><span></span><span></span>
</label>
<ul class="navbar-menu">
<li><a href="#solutions">Система</a></li>
<li><a href="#integrations">Технологии</a></li>
<li><a href="#security">Безопасность</a></li>
<li class="navbar-auth">
<a href="/login" class="btn btn-primary btn-sm">Вход</a>
<a href="/register" class="btn btn-primary btn-sm">Регистрация</a>
</li>
</ul>
</div>
</header>
<!-- ====== HERO С ВИДЕО ====== -->
<section class="hero section-padding" id="hero">
<video class="hero-video" autoplay muted loop playsinline>
<source src="/main/video/Тестовое видео.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
<div class="container">
<h1 class="scramble-target" id="heroTitle">Технологии будущего на вашей ферме</h1>
<p class="subtitle">
Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта.
Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом.
</p>
<div class="btn-group">
<a href="#cta" class="btn btn-primary">Запросить демо</a>
<a href="#solutions" class="btn btn-outline">Узнать больше</a>
</div>
</div>
</section>
<!-- ====== БЛОК: КАК ЭТО УСТРОЕНО ====== -->
<section class="steps-section section-padding" id="how-it-works">
<div class="container">
<div class="section-intro">
<span class="text-mono section-label">Процесс автоматизации</span>
<h2>Как устроена наша система</h2>
</div>
<div class="steps-grid">
<!-- Шаг 1 с изображением zoo.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/zoo.png" alt="План рационов" />
</div>
<h3>План рационов</h3>
<p>Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.</p>
</div>
<!-- Шаг 2 с изображением wifi.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/wifi.png" alt="Синхронизация данных" />
</div>
<h3>Синхронизация данных</h3>
<p>Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.</p>
</div>
<!-- Шаг 3 с изображением load.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/load.png" alt="Точная загрузка" />
</div>
<h3>Точная загрузка</h3>
<p>Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.</p>
</div>
<!-- Шаг 4 с изображением control.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/control.png" alt="Контроль руководителя" />
</div>
<h3>Контроль руководителя</h3>
<p>Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- ====== БЛОК: ОТ ФОРМУЛЫ ДО КОРМУШКИ (ТОЛЬКО ЗАГОЛОВОК + СЛОГАН) ====== -->
<!-- ============================================================ -->
<section class="funnel-section" id="funnel">
<div class="container">
<!-- Заголовок (без подзаголовка и шагов) -->
<div class="funnel-header">
<h2>От формулы до кормушки</h2>
</div>
<!-- Финальный слоган -->
<div class="funnel-final-slogan">
<p>«Каждый килограмм корма — под контролем.<br>Каждая копейка — на счету. Каждая минута — сэкономлена»</p>
</div>
</div>
</section>
<!-- ====== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ====== -->
<section class="expand-block section-padding" id="about">
<div class="container">
<h2 class="section-title">Работа на любом устройстве</h2>
<p class="section-subtitle">
Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран.
</p>
<div class="expand-content" id="expandContent">
<div class="side-panel left">
<p><strong>Всегда на связи с фермой</strong> Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.</p>
</div>
<div class="image-wrapper">
<img src="/main/img/Комптон всегда под рукой на белом фоне.jpg" alt="Комптон всегда под рукой" />
</div>
<div class="side-panel right">
<p><strong>Прозрачность и контроль</strong> Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.</p>
</div>
</div>
</div>
</section>
<!-- ====== КАРТОЧКИ РЕШЕНИЙ ====== -->
<section class="section-padding" id="solutions">
<div class="container">
<h2 class="section-intro section-intro--mb">Начни кормить по новому!</h2>
<p class="section-subtitle-centered">
Всё, что нужно для точного, экономного и эффективного кормления КРС.
</p>
<div class="cards-grid">
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Индивидуальный рацион" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</div>
<h3>Автоматический расчет рационов</h3>
<p>Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/рецепты.gif" alt="Удобное редактирование рационов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<h3>Удобное редактирование рационов</h3>
<p>Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/учет потребления.gif" alt="Прогноз продуктивности" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h3>Учет кормов</h3>
<p>Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Визуализация экономии кормов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M12 22V12"/><path d="M3.3 7L12 12l8.7-5"/></svg>
</div>
<h3>Гибкая система уведомлений</h3>
<p>Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Анализ микроклимата" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
</div>
<h3>Анализ микроклимата</h3>
<p>Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Единая платформа" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/><path d="M12 7v10"/></svg>
</div>
<h3>Единая платформа</h3>
<p>Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ====== БЕГУЩАЯ СТРОКА ====== -->
<section class="marquee-section" id="integrations">
<div class="marquee-track" id="marqueeTrack">
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
</div>
</section>
<!-- ====== CTA ====== -->
<section class="section-padding cta-section" id="cta">
<div class="container">
<h2>Внедряйте технологии будущего</h2>
<p>
Получите консультацию по настройке системы для вашего хозяйства.
Первые 2 месяца – полная поддержка и мониторинг.
</p>
<a href="#" class="btn btn-primary btn-lg">Оставить заявку</a>
</div>
</section>
<!-- ====== ФУТЕР ====== -->
<footer class="footer" id="security">
<div class="container">
<a href="#" class="footer-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<span class="footer-copy">&copy; 2026 — FeedControl</span>
<div class="footer-links">
<a href="#">Политика</a>
<a href="#">Поддержка</a>
<a href="#">Контакты</a>
</div>
</div>
</footer>
<script src="/main/js/main.js" defer></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
/* Compton design tokens — single source of truth */
:root {
/* Brand */
--primary: #48816d;
--primary-dark: #3a6b58;
--primary-light: #5a9a82;
--primary-accent: #5a9a82;
--primary-tint: #e8f2ef;
--primary-color: var(--primary);
/* Light surfaces */
--bg: #f6f6f4;
--foreground: #1a1e1c;
--muted: #4a5a52;
--marquee-bg: #ebebe5;
--surface: #ffffff;
--border-light: #e8e8e2;
--card: #ffffff;
--card-border: #e8e8e2;
--footer-bg: #1a1e1c;
--bg-page: var(--bg);
--text: var(--foreground);
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", monospace;
--zootech-font: var(--font-sans);
/* Radius */
--radius-card: 18px;
--radius-btn: 40px;
--radius-icon: 16px;
--zt-radius-lg: 16px;
/* Effects */
--shadow-soft: 0 16px 48px rgba(26, 30, 28, 0.08), 0 0 0 1px rgba(26, 30, 28, 0.04);
/* Semantic */
--color-error: #b42318;
--color-success: #0f766e;
--color-danger: #cd3838;
--color-danger-hover: #e94b4b;
--color-warning: #d4a72c;
/* Admin (light — matches site) */
--admin-bg: var(--bg);
--admin-surface: var(--surface);
--admin-surface-2: var(--marquee-bg);
--admin-stroke: var(--border-light);
--admin-soft: var(--muted);
--admin-text: var(--foreground);
--admin-primary: var(--primary);
--admin-primary-hover: var(--primary-tint);
--admin-primary-selected: rgba(72, 129, 109, 0.16);
--admin-primary-option: rgba(72, 129, 109, 0.1);
--admin-btn-bg: var(--surface);
--admin-btn-border: var(--border-light);
--admin-btn-text: var(--foreground);
--admin-success: var(--color-success);
--admin-tag-ok-border: rgba(72, 129, 109, 0.35);
--admin-sider-bg: var(--surface);
--admin-table-header: var(--marquee-bg);
--admin-card-radius: 20px;
/* WESP admin aliases */
--wesp-admin-bg: var(--admin-bg);
--wesp-admin-surface: var(--admin-surface);
--wesp-admin-surface-2: var(--admin-surface-2);
--wesp-admin-stroke: var(--admin-stroke);
--wesp-admin-soft: var(--admin-soft);
--wesp-admin-text: var(--admin-text);
--wesp-admin-primary: var(--admin-primary);
}
html[data-theme="organic"] {
color-scheme: light;
}
html.admin-route,
html.admin-route body {
background: var(--admin-bg);
}
/* Admin dark theme — isolated from public site */
html.admin-route[data-admin-theme="dark"] {
color-scheme: dark;
--admin-deep: #0b0a10;
--admin-bg: #1f2229;
--admin-surface: #0b0a10;
--admin-surface-2: #15141c;
--admin-stroke: rgba(186, 184, 208, 0.14);
--admin-soft: #a8adbb;
--admin-text: #ececf1;
--admin-primary: #5a9a82;
--admin-primary-hover: rgba(90, 154, 130, 0.2);
--admin-primary-selected: rgba(90, 154, 130, 0.32);
--admin-primary-option: rgba(90, 154, 130, 0.16);
--admin-btn-bg: #15141c;
--admin-btn-border: rgba(186, 184, 208, 0.16);
--admin-btn-text: #ececf1;
--admin-success: #6bc4a8;
--admin-tag-ok-border: rgba(90, 154, 130, 0.5);
--admin-sider-bg: #0b0a10;
--admin-table-header: #1a1924;
--admin-card-radius: 20px;
--surface: #0b0a10;
--foreground: #ececf1;
--muted: #a8adbb;
--border-light: rgba(186, 184, 208, 0.14);
--marquee-bg: #1a1924;
--primary-tint: rgba(90, 154, 130, 0.16);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+111
View File
@@ -0,0 +1,111 @@
(function() {
'use strict';
// ===== SCRAMBLE HERO =====
const heroTitleEl = document.getElementById('heroTitle');
if (heroTitleEl) {
const originalText = heroTitleEl.textContent.trim() || 'Технологии будущего на вашей ферме';
const chars = '!@#$%^&*()_+{}[]|;:,.<>?';
let iteration = 0;
let isScrambling = true;
let frameId = null;
const maxIterations = 30;
function scramble() {
if (!isScrambling) return;
const result = originalText
.split('')
.map((char, index) => {
if (index < iteration) return originalText[index];
if (char === ' ') return ' ';
return chars[Math.floor(Math.random() * chars.length)];
})
.join('');
heroTitleEl.textContent = result;
iteration += 1;
if (iteration <= maxIterations) {
frameId = requestAnimationFrame(scramble);
} else {
heroTitleEl.textContent = originalText;
isScrambling = false;
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
}
}
setTimeout(() => {
scramble();
}, 300);
}
// ===== FADE-UP =====
const fadeElements = document.querySelectorAll('.fade-up');
if (fadeElements.length > 0 && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -30px 0px' });
fadeElements.forEach(el => observer.observe(el));
} else {
fadeElements.forEach(el => el.classList.add('visible'));
}
// ===== EXPAND BLOCK =====
const expandContent = document.getElementById('expandContent');
if (expandContent && 'IntersectionObserver' in window) {
let isExpanded = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (!isExpanded) {
expandContent.classList.add('expanded');
isExpanded = true;
}
} else {
if (isExpanded) {
expandContent.classList.remove('expanded');
isExpanded = false;
}
}
});
}, { threshold: 0.25, rootMargin: '0px 0px -30px 0px' });
observer.observe(expandContent);
} else if (expandContent) {
expandContent.classList.add('expanded');
}
// ===== MARQUEE =====
const marqueeTrack = document.getElementById('marqueeTrack');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (prefersReducedMotion.matches && marqueeTrack) {
marqueeTrack.style.animation = 'none';
marqueeTrack.style.transform = 'translateX(0)';
}
prefersReducedMotion.addEventListener('change', (e) => {
if (marqueeTrack) {
if (e.matches) {
marqueeTrack.style.animation = 'none';
marqueeTrack.style.transform = 'translateX(0)';
} else {
marqueeTrack.style.animation = 'marqueeScroll 30s linear infinite';
marqueeTrack.style.transform = '';
}
}
});
// ===== HERO VIDEO =====
const heroVideo = document.querySelector('.hero-video');
if (heroVideo) {
heroVideo.play().catch(() => {
document.addEventListener('click', () => {
heroVideo.play();
}, { once: true });
});
}
})();
View File
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Комптон</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/main/css/style.css" />
</head>
<body>
<!-- ====== НАВБАР ====== -->
<header class="navbar" role="navigation">
<div class="container">
<a href="#" class="navbar-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<input type="checkbox" id="burgerToggle" class="burger-checkbox" />
<label for="burgerToggle" class="burger-icon" aria-label="Открыть меню">
<span></span><span></span><span></span>
</label>
<ul class="navbar-menu">
<li><a href="#solutions">Система</a></li>
<li><a href="#integrations">Технологии</a></li>
<li><a href="#security">Безопасность</a></li>
<li class="navbar-auth">
<a href="/login" class="btn btn-primary btn-sm">Вход</a>
<a href="/register" class="btn btn-primary btn-sm">Регистрация</a>
</li>
</ul>
</div>
</header>
<!-- ====== HERO С ВИДЕО ====== -->
<section class="hero section-padding" id="hero">
<video class="hero-video" autoplay muted loop playsinline>
<source src="/main/video/Тестовое видео.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
<div class="container">
<h1 class="scramble-target" id="heroTitle">Технологии будущего на вашей ферме</h1>
<p class="subtitle">
Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта.
Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом.
</p>
<div class="btn-group">
<a href="#cta" class="btn btn-primary">Запросить демо</a>
<a href="#solutions" class="btn btn-outline">Узнать больше</a>
</div>
</div>
</section>
<!-- ====== БЛОК: КАК ЭТО УСТРОЕНО ====== -->
<section class="steps-section section-padding" id="how-it-works">
<div class="container">
<div class="section-intro">
<span class="text-mono section-label">Процесс автоматизации</span>
<h2>Как устроена наша система</h2>
</div>
<div class="steps-grid">
<!-- Шаг 1 с изображением zoo.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/zoo.png" alt="План рационов" />
</div>
<h3>План рационов</h3>
<p>Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.</p>
</div>
<!-- Шаг 2 с изображением wifi.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/wifi.png" alt="Синхронизация данных" />
</div>
<h3>Синхронизация данных</h3>
<p>Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.</p>
</div>
<!-- Шаг 3 с изображением load.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/load.png" alt="Точная загрузка" />
</div>
<h3>Точная загрузка</h3>
<p>Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.</p>
</div>
<!-- Шаг 4 с изображением control.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/control.png" alt="Контроль руководителя" />
</div>
<h3>Контроль руководителя</h3>
<p>Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- ====== БЛОК: ОТ ФОРМУЛЫ ДО КОРМУШКИ (ТОЛЬКО ЗАГОЛОВОК + СЛОГАН) ====== -->
<!-- ============================================================ -->
<section class="funnel-section" id="funnel">
<div class="container">
<!-- Заголовок (без подзаголовка и шагов) -->
<div class="funnel-header">
<h2>От формулы до кормушки</h2>
</div>
<!-- Финальный слоган -->
<div class="funnel-final-slogan">
<p>«Каждый килограмм корма — под контролем.<br>Каждая копейка — на счету. Каждая минута — сэкономлена»</p>
</div>
</div>
</section>
<!-- ====== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ====== -->
<section class="expand-block section-padding" id="about">
<div class="container">
<h2 class="section-title">Работа на любом устройстве</h2>
<p class="section-subtitle">
Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран.
</p>
<div class="expand-content" id="expandContent">
<div class="side-panel left">
<p><strong>Всегда на связи с фермой</strong> Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.</p>
</div>
<div class="image-wrapper">
<img src="/main/img/Комптон всегда под рукой на белом фоне.jpg" alt="Комптон всегда под рукой" />
</div>
<div class="side-panel right">
<p><strong>Прозрачность и контроль</strong> Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.</p>
</div>
</div>
</div>
</section>
<!-- ====== КАРТОЧКИ РЕШЕНИЙ ====== -->
<section class="section-padding" id="solutions">
<div class="container">
<h2 class="section-intro section-intro--mb">Начни кормить по новому!</h2>
<p class="section-subtitle-centered">
Всё, что нужно для точного, экономного и эффективного кормления КРС.
</p>
<div class="cards-grid">
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Индивидуальный рацион" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</div>
<h3>Автоматический расчет рационов</h3>
<p>Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/рецепты.gif" alt="Удобное редактирование рационов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<h3>Удобное редактирование рационов</h3>
<p>Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/учет потребления.gif" alt="Прогноз продуктивности" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h3>Учет кормов</h3>
<p>Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Визуализация экономии кормов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M12 22V12"/><path d="M3.3 7L12 12l8.7-5"/></svg>
</div>
<h3>Гибкая система уведомлений</h3>
<p>Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Анализ микроклимата" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
</div>
<h3>Анализ микроклимата</h3>
<p>Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Единая платформа" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/><path d="M12 7v10"/></svg>
</div>
<h3>Единая платформа</h3>
<p>Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ====== БЕГУЩАЯ СТРОКА ====== -->
<section class="marquee-section" id="integrations">
<div class="marquee-track" id="marqueeTrack">
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
</div>
</section>
<!-- ====== CTA ====== -->
<section class="section-padding cta-section" id="cta">
<div class="container">
<h2>Внедряйте технологии будущего</h2>
<p>
Получите консультацию по настройке системы для вашего хозяйства.
Первые 2 месяца – полная поддержка и мониторинг.
</p>
<a href="#" class="btn btn-primary btn-lg">Оставить заявку</a>
</div>
</section>
<!-- ====== ФУТЕР ====== -->
<footer class="footer" id="security">
<div class="container">
<a href="#" class="footer-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<span class="footer-copy">&copy; 2026 — FeedControl</span>
<div class="footer-links">
<a href="#">Политика</a>
<a href="#">Поддержка</a>
<a href="#">Контакты</a>
</div>
</div>
</footer>
<script src="/main/js/main.js" defer></script>
</body>
</html>
Binary file not shown.
View File
+50
View File
@@ -0,0 +1,50 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ci": "vitest run --coverage",
"e2e": "playwright test"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.59.0",
"antd": "^5.29.3",
"axios": "^1.7.7",
"dompurify": "^3.2.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.53.0",
"react-router-dom": "^7.0.0",
"zod": "^3.23.8",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/dompurify": "^3.2.0",
"@types/node": "^22.8.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.12.0",
"eslint-plugin-boundaries": "^4.2.0",
"eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.21",
"vitest": "^2.1.9"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { defineConfig } from "@playwright/test";
const webPort = process.env.E2E_WEB_PORT ?? "5175";
const webUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${webPort}`;
const apiPort = process.env.E2E_API_PORT ?? "8001";
const apiUrl = process.env.E2E_API_URL ?? `http://127.0.0.1:${apiPort}`;
const startApi = process.env.E2E_START_API !== "false";
export default defineConfig({
testDir: "./e2e",
retries: process.env.CI ? 1 : 0,
workers: 1,
webServer: startApi
? [
{
command: "python scripts/start_e2e_api.py",
cwd: "../api",
url: `${apiUrl}/api/v1/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
E2E_API_PORT: apiPort,
E2E_WEB_PORT: webPort
}
},
{
command: `npx vite --host 127.0.0.1 --port ${webPort}`,
url: webUrl,
reuseExistingServer: !process.env.CI,
env: {
VITE_USE_API_PROXY: "true",
VITE_API_URL: apiUrl
}
}
]
: {
command: `npx vite --host 127.0.0.1 --port ${webPort}`,
url: webUrl,
reuseExistingServer: !process.env.CI
},
use: {
baseURL: webUrl,
trace: "on-first-retry",
video: "on-first-retry"
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }]
});
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>http://localhost:5173/</loc></url>
<url><loc>http://localhost:5173/pages/about</loc></url>
<url><loc>http://localhost:5173/pages/privacy</loc></url>
<url><loc>http://localhost:5173/pages/terms</loc></url>
</urlset>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,239 @@
.lab-profiles-page .lab-profiles-header {
margin-bottom: 1rem;
}
.lab-profiles-page .lab-profiles-header h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header p {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header a {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header a:hover {
color: #fde047;
}
.lab-profiles-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
@media (max-width: 992px) {
.lab-profiles-layout {
grid-template-columns: 1fr;
}
}
.lab-profiles-list {
max-height: calc(100vh - 12rem);
overflow: auto;
}
.lab-profiles-list button {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 0.45rem 0.5rem;
border-radius: 8px;
font-size: 0.8125rem;
cursor: pointer;
}
.lab-profiles-list button:hover {
background: rgba(3, 105, 161, 0.08);
}
.lab-profiles-list button.is-active {
background: #e0f2fe;
font-weight: 600;
}
html[data-theme="dark"] .lab-profiles-list button.is-active {
background: rgba(3, 105, 161, 0.25);
}
.lab-profiles-list .profile-key {
display: block;
font-size: 0.7rem;
color: var(--text-muted, #64748b);
}
.lab-profiles-editor table {
width: 100%;
font-size: 0.8125rem;
}
.lab-profiles-editor th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #64748b);
white-space: nowrap;
}
.lab-profiles-editor td {
vertical-align: middle;
}
.lab-profiles-editor input[type="number"] {
min-width: 5.5rem;
max-width: 7rem;
}
.lab-profiles-editor .norm-calc-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: #dbeafe;
color: #1d4ed8;
}
.lab-profiles-editor .norm-source-badge {
display: inline-block;
font-size: 0.62rem;
padding: 0.08rem 0.32rem;
border-radius: 4px;
margin-right: 0.2rem;
white-space: nowrap;
font-weight: 600;
letter-spacing: 0.02em;
}
.lab-profiles-editor .norm-source-badge--db {
background: #f1f5f9;
color: #475569;
}
.lab-profiles-editor .norm-source-badge--racion {
background: #fef3c7;
color: #b45309;
}
.lab-profiles-editor .norm-source-badge--derived {
background: #ede9fe;
color: #6d28d9;
}
.lab-profiles-editor .norm-source-badge--fallback,
.lab-profiles-editor .norm-source-badge--stored {
background: #dcfce7;
color: #15803d;
}
.lab-profiles-editor .norm-source-badge--gfe {
background: #e0f2fe;
color: #0369a1;
}
.lab-profiles-editor .norm-source-badge--empty {
background: transparent;
color: var(--text-muted, #94a3b8);
font-weight: 400;
}
.lab-profiles-source-cell {
white-space: nowrap;
}
.lab-profiles-norms-summary strong {
font-weight: 600;
}
html[data-theme="dark"] .lab-profiles-editor .norm-calc-badge {
background: rgba(29, 78, 216, 0.25);
color: #93c5fd;
}
.lab-profiles-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.lab-profiles-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.lab-profiles-table-wrap {
max-height: calc(100vh - 18rem);
overflow: auto;
}
.lab-profiles-gfe {
font-size: 0.8125rem;
}
.lab-profiles-gfe strong {
font-weight: 600;
}
.lab-profiles-seed {
font-size: 0.8125rem;
}
.lab-profiles-seed select {
max-width: 100%;
}
.lab-heisenberg-prime .lab-profiles-list button:hover {
background: rgba(0, 200, 255, 0.1);
color: #f8fafc;
}
.lab-heisenberg-prime .lab-profiles-list button.is-active {
background: rgba(253, 224, 71, 0.15);
color: var(--lab-prime-yellow, #fde047);
box-shadow: inset 0 0 0 1px rgba(253, 224, 71, 0.3);
}
.lab-heisenberg-prime .lab-profiles-list .profile-key {
color: #94a3b8;
}
.lab-heisenberg-prime .lab-profiles-editor th {
color: var(--lab-prime-crystal, #00c8ff);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-calc-badge {
background: rgba(0, 200, 255, 0.15);
color: var(--lab-prime-crystal, #00c8ff);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--racion {
background: rgba(253, 224, 71, 0.2);
color: var(--lab-prime-yellow, #fde047);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--derived {
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd;
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--fallback,
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--stored {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--db {
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
@@ -0,0 +1,667 @@
.lab-sandbox-page {
position: relative;
min-height: 100vh;
background-color: transparent;
}
/* Прозрачный body — иначе --bg-page перекрывает фон Heisenberg (::before) */
body.zootech-app-body.lab-sandbox-page {
background-color: transparent !important;
}
html:has(body.lab-sandbox-page) {
background-color: transparent;
}
.lab-sandbox-page::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background: url("/static/img/lab-heisenberg-bg.jpg") center center / cover no-repeat;
background-attachment: fixed;
}
.lab-sandbox-page::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.35) 0%,
rgba(0, 0, 0, 0.08) 40%,
rgba(0, 0, 0, 0.2) 100%
);
}
.lab-sandbox-page .wesp-page-main {
background: transparent;
}
.lab-sandbox-page .lab-sandbox-header {
margin-bottom: 1.25rem;
}
.lab-sandbox-page .lab-sandbox-header h1,
.lab-sandbox-page .lab-sandbox-header h2,
.lab-sandbox-page .lab-sandbox-header p,
.lab-sandbox-page .lab-sandbox-header a {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-sandbox-page .lab-sandbox-header a:hover {
color: #fde047;
}
.lab-sandbox-page .lab-sandbox-header h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
}
.lab-sandbox-page .lab-sandbox-header p {
margin: 0;
color: var(--text-muted, #64748b);
font-size: 0.9rem;
}
.lab-sandbox-flow {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr minmax(280px, 360px);
gap: 1rem;
align-items: start;
}
@media (max-width: 1200px) {
.lab-sandbox-flow {
grid-template-columns: 1fr;
}
}
.lab-sandbox-panel {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 12px;
padding: 1rem;
}
html[data-theme="dark"] .lab-sandbox-panel {
background: var(--bg-white, #1e2428);
border-color: rgba(255, 255, 255, 0.12);
}
.lab-sandbox-panel__title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #64748b);
margin-bottom: 0.75rem;
}
.lab-sandbox-field {
margin-bottom: 0.65rem;
}
.lab-sandbox-field label {
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.lab-sandbox-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.lab-sandbox-table {
width: 100%;
font-size: 0.875rem;
}
.lab-sandbox-table th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #64748b);
white-space: nowrap;
}
.lab-sandbox-table td {
vertical-align: middle;
}
.lab-sandbox-table .form-control,
.lab-sandbox-table .form-select {
min-width: 0;
}
.lab-sandbox-results table {
width: 100%;
font-size: 0.8125rem;
}
.lab-sandbox-results th,
.lab-sandbox-results td {
padding: 0.35rem 0.4rem;
border-bottom: 1px solid var(--border-light, #e2e8f0);
}
.lab-sandbox-diff--ok {
color: #15803d;
font-weight: 600;
}
.lab-sandbox-diff--bad {
color: #b91c1c;
font-weight: 600;
}
.lab-sandbox-diff--na {
color: var(--text-muted, #94a3b8);
}
.lab-sandbox-norm--empty {
color: var(--text-muted, #94a3b8);
font-style: italic;
font-size: 0.78rem;
}
.lab-sandbox-norms {
font-size: 0.78rem;
max-height: 220px;
overflow: auto;
}
.lab-sandbox-norms dt {
font-weight: 600;
margin-top: 0.35rem;
}
.lab-sandbox-norms dd {
margin: 0 0 0.25rem;
color: var(--text-muted, #64748b);
}
.lab-sandbox-empty {
color: var(--text-muted, #94a3b8);
font-size: 0.875rem;
text-align: center;
padding: 1.5rem 0.5rem;
}
.lab-sandbox-step {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
margin-bottom: 0.75rem;
}
html[data-theme="dark"] .lab-sandbox-step {
background: rgba(3, 105, 161, 0.25);
color: #7dd3fc;
}
.lab-formulate-section {
border-top: 1px solid var(--border-light, #e2e8f0);
padding-top: 1.5rem;
}
.lab-agrostar-section {
border-top: 1px solid var(--border-light, #e2e8f0);
padding-top: 1.5rem;
}
.lab-agrostar-drop {
border: 2px dashed var(--border-light, #cbd5e1);
border-radius: 0.75rem;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: rgba(248, 250, 252, 0.6);
}
html[data-theme="dark"] .lab-agrostar-drop {
background: rgba(15, 23, 42, 0.35);
}
.lab-agrostar-drop:hover,
.lab-agrostar-drop:focus-visible {
border-color: #38bdf8;
outline: none;
}
.lab-agrostar-drop--active {
border-color: #0ea5e9;
background: rgba(224, 242, 254, 0.45);
}
html[data-theme="dark"] .lab-agrostar-drop--active {
background: rgba(3, 105, 161, 0.2);
}
.lab-agrostar-drop__icon {
font-size: 2rem;
color: #0ea5e9;
margin-bottom: 0.5rem;
}
.lab-agrostar-drop__title {
font-weight: 600;
margin: 0;
}
.lab-agrostar-drop__file {
color: #0369a1;
font-weight: 500;
}
.lab-agrostar-warn {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
background: #fef3c7;
color: #92400e;
margin: 0.1rem 0.15rem 0.1rem 0;
}
html[data-theme="dark"] .lab-agrostar-warn {
background: rgba(146, 64, 14, 0.25);
color: #fcd34d;
}
.lab-agrostar-result--ok {
color: #15803d;
}
.lab-agrostar-result--err {
color: #b91c1c;
}
.lab-agrostar-sniff {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(148, 163, 184, 0.45);
}
.lab-agrostar-sniff-card {
background: rgba(248, 250, 252, 0.85);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 0.5rem;
padding: 1rem 1.1rem;
margin-bottom: 0.75rem;
}
html[data-theme="dark"] .lab-agrostar-sniff-card {
background: rgba(15, 23, 42, 0.55);
border-color: rgba(71, 85, 105, 0.6);
}
.lab-agrostar-sniff-card__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.lab-agrostar-sniff__target--warn {
color: #92400e;
font-size: 0.875rem;
}
html[data-theme="dark"] .lab-agrostar-sniff__target--warn {
color: #fcd34d;
}
.lab-agrostar-sniff-meta th {
width: 38%;
font-weight: 500;
color: #64748b;
vertical-align: top;
}
.lab-agrostar-sniff-nutrients td:first-child {
max-width: 14rem;
}
.lab-agrostar-sniff__skip {
color: var(--bs-secondary-color);
line-height: 1.45;
}
.lab-agrostar-sniff__notes ul {
padding-left: 1.1rem;
color: #92400e;
}
html[data-theme="dark"] .lab-agrostar-sniff__notes ul {
color: #fcd34d;
}
/* —— Heisenberg prime (ошибка на /lab) —— */
.lab-heisenberg-prime-overlay {
position: fixed;
inset: 0;
z-index: 6500;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
--lab-prime-eye-y: 26.5vh;
--lab-prime-eye-gap: 78px;
}
.lab-heisenberg-prime-active .lab-heisenberg-prime-overlay {
opacity: 1;
}
.lab-heisenberg-prime-active.lab-sandbox-page::before {
filter: saturate(0.45) contrast(1.2) brightness(0.62) sepia(0.15);
transform: scale(1.06);
background-position: center 28%;
transition: filter 0.4s ease, transform 0.6s ease, background-position 0.5s ease;
}
.lab-heisenberg-prime-active.lab-sandbox-page::after {
background: linear-gradient(
180deg,
rgba(40, 0, 0, 0.35) 0%,
rgba(10, 0, 0, 0.08) 38%,
rgba(50, 0, 0, 0.55) 100%
);
}
/* UI убираем — остаётся Walter и баннер prime */
.lab-heisenberg-prime-active #wespZootechNavMount,
.lab-heisenberg-prime-active .lab-sandbox-header,
.lab-heisenberg-prime-active .lab-sandbox-flow,
.lab-heisenberg-prime-active .lab-agrostar-section,
.lab-heisenberg-prime-active .lab-formulate-section {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
transform: translateY(16px) scale(0.97);
transition: opacity 0.35s ease, visibility 0.35s ease, transform 0.35s ease;
}
.lab-heisenberg-prime-active .wesp-page-main {
filter: none;
}
.lab-heisenberg-prime__vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at 50% var(--lab-prime-eye-y),
transparent 0%,
rgba(0, 0, 0, 0.12) 38%,
rgba(80, 0, 0, 0.78) 100%
);
animation: labPrimeVignette 0.9s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__glow {
position: absolute;
left: 50%;
top: var(--lab-prime-eye-y);
width: min(36vw, 280px);
height: min(28vw, 200px);
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgba(220, 38, 38, 0.42) 0%, transparent 68%);
animation: labPrimeGlow 1.1s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__eyes {
position: absolute;
inset: 0;
pointer-events: none;
}
.lab-heisenberg-prime__eye {
position: absolute;
top: var(--lab-prime-eye-y);
width: 102px;
height: 58px;
margin: 0;
border-radius: 50%;
background: #ef4444;
box-shadow:
0 0 10px 3px rgba(239, 98, 98, 0.95),
0 0 22px 8px rgba(185, 28, 28, 0.6),
0 0 40px 16px rgba(127, 29, 29, 0.35);
animation: labPrimeEyePulse 0.55s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__eye--left {
left: calc(50% - var(--lab-prime-eye-gap));
transform: translate(-50%, -50%);
}
.lab-heisenberg-prime__eye--right {
left: calc(50% + var(--lab-prime-eye-gap));
transform: translate(-50%, -50%);
}
.lab-heisenberg-prime__scanlines {
position: absolute;
inset: 0;
opacity: 0.12;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 255, 255, 0.04) 2px,
rgba(255, 255, 255, 0.04) 4px
);
}
.lab-heisenberg-prime__banner {
position: absolute;
left: 50%;
bottom: clamp(1.5rem, 8vh, 4rem);
transform: translateX(-50%);
width: min(92vw, 520px);
padding: 0.85rem 1.1rem;
border-radius: 0.75rem;
text-align: center;
background: rgba(15, 0, 0, 0.82);
border: 1px solid rgba(239, 68, 68, 0.45);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
color: #fecaca;
backdrop-filter: blur(6px);
}
.lab-heisenberg-prime__title {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #fff;
text-shadow: 0 0 18px rgba(239, 68, 68, 0.8);
}
.lab-heisenberg-prime__subtitle {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #f87171;
margin-top: 0.15rem;
}
.lab-heisenberg-prime__reason {
margin-top: 0.55rem;
font-size: 0.92rem;
line-height: 1.35;
color: #fee2e2;
}
.lab-heisenberg-prime__source {
margin-top: 0.35rem;
font-size: 0.72rem;
color: #fca5a5;
opacity: 0.9;
}
@keyframes labPrimeEyePulse {
from {
opacity: 0.75;
filter: brightness(0.92);
}
to {
opacity: 1;
filter: brightness(1.15);
}
}
@keyframes labPrimeVignette {
from {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes labPrimeGlow {
from {
opacity: 0.45;
transform: translate(-50%, -50%) scale(0.96);
}
to {
opacity: 0.85;
transform: translate(-50%, -50%) scale(1.04);
}
}
@media (max-width: 768px) {
.lab-heisenberg-prime-overlay {
--lab-prime-eye-y: 24.5vh;
--lab-prime-eye-gap: 1198px;
}
}
@media (min-width: 1400px) {
.lab-heisenberg-prime-overlay {
--lab-prime-eye-y: 27vh;
--lab-prime-eye-gap: 74px;
}
}
@media (prefers-reduced-motion: reduce) {
.lab-heisenberg-prime__eye,
.lab-heisenberg-prime__vignette,
.lab-heisenberg-prime__glow {
animation: none;
}
.lab-heisenberg-prime-overlay {
transition: none;
}
}
.lab-formulate-steps {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.lab-formulate-step {
border: 1px solid var(--border-light, #e2e8f0);
background: transparent;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.65rem;
cursor: pointer;
}
.lab-formulate-step--active {
background: #e0f2fe;
border-color: #7dd3fc;
color: #0369a1;
}
.lab-formulate-step-panel--calc.d-none {
display: none !important;
}
.lab-formulate-pool-wrap {
max-height: 320px;
overflow: auto;
}
.lab-formulate-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.lab-formulate-badge--ok {
background: #dcfce7;
color: #166534;
}
.lab-formulate-badge--bad {
background: #fee2e2;
color: #991b1b;
}
.lab-formulate-row--ineligible {
opacity: 0.55;
}
.lab-formulate-opt-keys {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 220px;
overflow: auto;
font-size: 0.8rem;
}
.lab-formulate-opt-key {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.5rem;
align-items: start;
cursor: pointer;
}
.lab-formulate-opt-key small {
grid-column: 2;
}
.lab-formulate-result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
margin-bottom: 0.5rem;
}
.lab-formulate-row--target td {
background: rgba(14, 165, 233, 0.08);
font-weight: 600;
}
.lab-formulate-row--target td.lab-sandbox-diff--bad,
.lab-formulate-row--target td.lab-sandbox-diff--ok {
font-size: 0.95rem;
}
@@ -0,0 +1,521 @@
/**
* Recipe editor — form grid, section cards, CSS Grid data tables (#recipeEdit).
* Loaded after zootech shell/components; replaces Bootstrap .table in editor sections.
*/
/* —— Form 2×2 grid —— */
#recipeEdit .recipe-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem 1.25rem;
margin-bottom: 0;
align-items: start;
}
#recipeEdit .recipe-form-grid .form-group {
margin-bottom: 0;
min-width: 0;
}
#recipeEdit .recipe-form-grid .form-group label,
#recipeEdit .recipe-form-grid .zt-form-label-with-help {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
min-height: 1.375rem;
line-height: 1.375rem;
}
#recipeEdit .zt-form-label-with-help {
position: relative;
padding-right: 2rem;
}
#recipeEdit .zt-form-label-with-help .recipe-help-icon-btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
#recipeEdit .recipe-form-grid .form-control {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
#recipeEdit .recipe-form-grid + .zt-notice {
margin-top: 0.5rem;
}
#recipeEdit .recipe-section-card__help.zt-notice {
margin: 0.75rem 1.5rem 0;
}
#recipeEdit .recipe-section-card__help.zt-notice.is-visible {
margin-bottom: 0.5rem;
}
#recipeEdit .card-body > .zt-notice.is-visible {
margin-bottom: 0.75rem;
}
@media (max-width: 768px) {
#recipeEdit .recipe-plain-help.zt-notice {
display: none !important;
}
}
@media (max-width: 767.98px) {
#recipeEdit .recipe-form-grid {
grid-template-columns: 1fr;
}
}
/* —— Section cards (single border, no nested shell) —— */
#recipeEdit .recipe-section-card {
margin-bottom: 0 !important;
flex-shrink: 0;
border-radius: 12px;
border: 1px solid var(--border-subtle);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
overflow: hidden;
}
#recipeEdit .recipe-section-card > .card-header {
background: var(--surface-muted) !important;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-subtle);
/* Глобальный .card .card-header = 14px; карточка секции = 12px — без сброса виден «ступень» в углу */
border-radius: 0;
}
#recipeEdit .recipe-section-card > .card-body {
padding: 1rem 1.5rem 1.25rem;
}
#recipeEdit .recipe-section-card > .card-body.recipe-section-card__body--grid {
padding: 0;
}
#recipeEdit #recipeForm > .card-body > .recipe-section-card + .recipe-section-card {
margin-top: 0.25rem;
}
#recipeEdit .recipe-section-card__help {
padding: 0.75rem 1.5rem 0;
margin: 0;
}
#recipeEdit .recipe-section-card__body--grid .recipe-section-card__help:last-child {
padding-bottom: 0.75rem;
}
html[data-theme="dark"] #recipeEdit .recipe-section-card > .card-header {
background: var(--dm-field) !important;
border-bottom-color: var(--dm-border);
}
html[data-theme="dark"] #recipeEdit .recipe-section-card {
border-color: var(--dm-border);
box-shadow: none;
}
/* —— Data grid base —— */
#recipeEdit .zt-data-grid__scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
#recipeEdit .zt-data-grid {
display: flex;
flex-direction: column;
gap: 0.625rem;
min-width: 100%;
width: 100%;
}
#recipeEdit .zt-data-grid__body {
display: flex;
flex-direction: column;
}
#recipeEdit .zt-data-grid__head,
#recipeEdit .zt-data-grid__row {
display: grid;
grid-template-columns: var(--zt-data-grid-cols);
min-width: min(100%, 52rem);
}
#recipeEdit .zt-data-grid--ingredients {
--zt-data-grid-actions-col: 10.75rem;
--zt-data-grid-cols: minmax(0, 1.5fr) repeat(5, minmax(0, 0.72fr)) var(--zt-data-grid-actions-col);
}
#recipeEdit .zt-data-grid--unloading-groups {
--zt-data-grid-actions-col: 10.75rem;
--zt-data-grid-cols: minmax(0, 1.35fr) minmax(0, 1.15fr) minmax(0, 0.78fr) minmax(0, 0.78fr) var(--zt-data-grid-actions-col);
}
#recipeEdit .zt-data-grid__cell {
min-width: 0;
padding: 0.5rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--border-subtle);
font-variant-numeric: tabular-nums;
font-family: var(--font-numeric, inherit);
background: var(--surface, #fff);
}
#recipeEdit .zt-data-grid__cell--head {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border-subtle);
font-family: inherit;
font-variant-numeric: normal;
}
#recipeEdit .zt-data-grid__cell--num {
text-align: center;
}
#recipeEdit .zt-data-grid__cell--actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
text-align: right;
}
#recipeEdit .zt-data-grid__cell--head.zt-data-grid__cell--actions {
justify-content: center;
text-align: center;
}
#recipeEdit .zt-data-grid__body .zt-data-grid__row:last-child .zt-data-grid__cell {
border-bottom: none;
}
#recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell {
background: rgba(var(--primary-rgb), 0.05);
}
/* Итого: gap у .zt-data-grid отделяет body от foot; внутри foot — только боковые отступы */
#recipeEdit .zt-data-grid__foot {
padding: 0 0.75rem 0.75rem;
margin-top: 0;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__row--total {
background: var(--surface-muted);
border-radius: 10px;
overflow: hidden;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell {
padding: 0.625rem 0.75rem;
font-weight: 600;
font-size: 0.875rem;
background: transparent;
border: none;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--foot-label,
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--span-2 {
display: flex;
align-items: center;
text-align: left;
justify-content: flex-start;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--num {
display: flex;
align-items: center;
justify-content: center;
}
#recipeEdit .zt-total-line {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
#recipeEdit .zt-total-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
line-height: 1.2;
}
html[data-theme="dark"] #recipeEdit .zt-total-label {
color: var(--dm-muted);
}
#recipeEdit .zt-total-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 0.875rem;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--actions {
padding: 0.625rem 0.75rem;
}
#recipeEdit .zt-data-grid__cell--span-2 {
grid-column: span 2;
}
#recipeEdit .zt-data-grid .form-control,
#recipeEdit .zt-data-grid .form-select {
width: 100%;
max-width: 100%;
min-height: 2.25rem;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
#recipeEdit .zt-data-grid .text-cell {
display: inline-block;
min-height: 2.25rem;
line-height: 2.25rem;
font-size: 0.875rem;
}
#recipeEdit .zt-data-grid input[type="number"] {
-moz-appearance: textfield;
}
#recipeEdit .zt-data-grid input[type="number"]::-webkit-outer-spin-button,
#recipeEdit .zt-data-grid input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Row actions — кнопки по высоте полей строки, колонка без лишнего воздуха */
#recipeEdit .recipe-table-row-actions {
gap: 0.375rem;
justify-content: flex-end;
width: 100%;
max-width: 100%;
}
/* Список рейсов: не width:100% — иначе прозрачный слой перекрывает .recipe-drag-handle слева */
#recipesList .recipe-table-row-actions {
gap: 0.375rem;
justify-content: flex-end;
flex: 1 1 0;
min-width: 0;
width: auto;
max-width: 100%;
pointer-events: none;
}
#recipesList .recipe-table-row-actions .btn,
#recipesList .recipe-table-row-actions .btn-group {
pointer-events: auto;
}
#recipesList .list-item-leading {
position: relative;
z-index: 2;
flex-shrink: 0;
pointer-events: auto;
}
#recipeEdit .recipe-table-move-group,
#recipesList .recipe-table-move-group,
.feed-dispensers-page .dispenser-card .recipe-table-move-group,
.components-page .component-card .recipe-table-move-group {
display: inline-flex;
flex-shrink: 0;
gap: 0.25rem;
}
#recipeEdit .recipe-table-move-group > .btn + .btn,
#recipesList .recipe-table-move-group > .btn + .btn,
.feed-dispensers-page .dispenser-card .recipe-table-move-group > .btn + .btn,
.components-page .component-card .recipe-table-move-group > .btn + .btn {
margin-left: 0 !important;
}
#recipeEdit .recipe-table-move-group .btn,
#recipeEdit .recipe-table-delete-btn,
#recipesList .recipe-table-move-group .btn,
#recipesList .recipe-table-delete-btn,
.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
.components-page .component-card .recipe-table-move-group .btn,
.components-page .component-card .recipe-table-delete-btn,
#recipesList .recipe-drag-handle {
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0 !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: var(--zt-radius-sm, 8px) !important;
flex-shrink: 0;
}
#recipeEdit .recipe-table-move-group .btn i,
#recipeEdit .recipe-table-delete-btn i,
#recipesList .recipe-table-move-group .btn i,
#recipesList .recipe-table-delete-btn i,
.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn i,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn i,
.components-page .component-card .recipe-table-move-group .btn i,
.components-page .component-card .recipe-table-delete-btn i,
#recipesList .recipe-drag-handle i {
font-size: 0.875rem;
}
#recipesList .recipe-drag-handle {
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
#recipesList .recipe-drag-handle:active {
cursor: grabbing;
}
#recipesList .recipe-drag-handle:focus-visible {
outline: 2px solid var(--primary-color, #0d6efd);
outline-offset: 2px;
z-index: 2;
}
#recipesList .recipe-drag-handle i {
pointer-events: none;
}
#recipeEdit .recipe-table-move-group .btn:focus,
#recipesList .recipe-table-move-group .btn:focus {
z-index: 2;
}
#recipeEdit .recipe-table-delete-btn,
#recipesList .recipe-table-delete-btn,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
.components-page .component-card .recipe-table-delete-btn {
background: transparent !important;
border: 1px solid var(--border-light, rgba(148, 163, 184, 0.35)) !important;
color: var(--text-muted, #94a3b8) !important;
transition: var(--transition);
}
#recipeEdit .recipe-table-delete-btn:hover,
#recipeEdit .recipe-table-delete-btn:focus,
#recipesList .recipe-table-delete-btn:hover,
#recipesList .recipe-table-delete-btn:focus,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus,
.components-page .component-card .recipe-table-delete-btn:hover,
.components-page .component-card .recipe-table-delete-btn:focus {
color: var(--danger-color, #ef4444) !important;
background: rgba(239, 68, 68, 0.08) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
}
/* Dark theme */
html[data-theme="dark"] #recipeEdit .zt-data-grid__cell {
background: var(--dm-surface);
border-bottom-color: var(--dm-border);
color: var(--dm-text);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__cell--head {
background: var(--dm-field);
color: var(--dm-muted);
border-bottom-color: var(--dm-border);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell {
background: rgba(var(--primary-rgb), 0.15);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row--total {
background: var(--dm-field);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__cell {
background: transparent;
color: var(--dm-text);
}
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:hover,
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:focus,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn:hover,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn:focus,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:hover,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:focus {
color: #f87171 !important;
background: rgba(239, 68, 68, 0.12) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control {
min-height: 2.5rem;
line-height: 1.5;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
box-shadow: none !important;
opacity: 1 !important;
border-width: 1px;
border-style: solid;
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:focus:not(:disabled):not(.mode-locked-field) {
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control.mode-locked-field,
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:disabled,
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control[readonly] {
background-color: #23292f !important;
background-image: none !important;
border: 1px solid rgba(255, 255, 255, 0.38) !important;
color: var(--dm-text) !important;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35) !important;
min-height: 2.5rem;
-webkit-text-fill-color: var(--dm-text) !important;
}
@@ -0,0 +1,888 @@
/**
* Mobile layout for /recipes — dashboard steps, card tables, fullscreen editor.
* Breakpoint matches recipes.html mobile-nav (@768px).
*/
.ingredient-mobile-summary,
.ingredient-mobile-sheet,
.unloading-group-mobile-summary,
.unloading-group-mobile-sheet {
display: none;
}
@media (max-width: 768px) {
/* --- Dashboard step navigation --- */
.recipes-mobile-nav {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.65rem 0.85rem;
background: var(--surface-card, #fff);
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 12px;
box-shadow: var(--box-shadow, 0 1px 3px rgba(15, 23, 42, 0.06));
}
.recipes-mobile-nav[hidden] {
display: none !important;
}
.recipes-mobile-nav-back {
flex-shrink: 0;
min-height: 44px;
padding: 0.4rem 0.75rem;
border-radius: 10px;
touch-action: manipulation;
}
.recipes-mobile-nav-title {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dashboard-mobile-step-dispensers #periodsCol,
body.dashboard-mobile-step-dispensers #recipesCol {
display: none !important;
}
body.dashboard-mobile-step-periods #dispensersCol,
body.dashboard-mobile-step-periods #recipesCol {
display: none !important;
}
body.dashboard-mobile-step-recipes #dispensersCol,
body.dashboard-mobile-step-recipes #periodsCol {
display: none !important;
}
body.dashboard-mobile-step-periods #periodsCol,
body.dashboard-mobile-step-recipes #recipesCol {
display: block !important;
}
body.dashboard-mobile-step-dispensers #dispensersCol {
display: block !important;
}
/* --- Recipe list cards --- */
#recipesList .recipe-actions .btn {
min-width: 44px;
min-height: 44px;
padding: 0.45rem 0.65rem;
}
#recipesList .recipe-actions-row {
flex-wrap: wrap;
}
#recipesList .recipe-drag-handle {
touch-action: none;
-webkit-user-drag: element;
}
#recipesList .list-item.recipe-card-dragging {
opacity: 0.72;
}
#recipesList.recipe-list-reorder-active {
touch-action: none;
}
body.recipe-list-reorder-tracking {
touch-action: none;
}
.recipe-reorder-edge-hints {
position: fixed;
inset: 0;
z-index: 1295;
pointer-events: none;
}
.recipe-reorder-edge-hint {
position: absolute;
left: 0;
right: 0;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.18s ease;
}
.recipe-reorder-edge-hint--top {
top: 0;
background: linear-gradient(to bottom, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0));
}
.recipe-reorder-edge-hint--bottom {
bottom: 0;
background: linear-gradient(to top, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0));
}
.recipe-reorder-edge-hint.is-active {
opacity: 1;
}
.recipe-reorder-hint--floating {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px));
z-index: 1296;
margin: 0;
}
.recipe-list-scroll-zone-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--primary-color, #2196f3);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
}
.recipe-reorder-hint,
.recipe-reorder-hint--floating {
padding: 0.55rem 0.75rem;
border-radius: 10px;
font-size: 0.78rem;
line-height: 1.4;
font-weight: 500;
color: var(--primary-color, #2196f3);
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid rgba(var(--primary-rgb), 0.22);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.recipe-reorder-hint.is-visible,
.recipe-reorder-hint--floating.is-visible {
opacity: 1;
transform: translateY(0);
}
#recipesList {
position: relative;
}
#recipesList .recipe-card-insert-indicator {
position: absolute;
left: 0.5rem;
right: 0.5rem;
height: 3px;
border-radius: 999px;
background: var(--primary-color, #2196f3);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.18);
pointer-events: none;
z-index: 8;
}
#recipesList .list-item.recipe-card-dragging {
opacity: 0.35;
border-style: dashed;
}
.recipe-card-drag-ghost--card-clone {
position: fixed;
z-index: 1300;
pointer-events: none;
opacity: 0.96;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
margin: 0 !important;
transition: none !important;
transform: none;
will-change: left, top;
}
body.recipe-mobile-transfer-armed #recipesList .recipe-drag-handle {
color: var(--primary-color, #2196f3);
border-color: rgba(var(--primary-rgb), 0.45);
background: rgba(var(--primary-rgb), 0.12);
}
body.recipe-mobile-transfer-armed #recipesCol .card-header::after {
content: "Режим переноса: выберите период или оборудование";
display: block;
width: 100%;
margin-top: 0.35rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--primary-color, #2196f3);
line-height: 1.35;
}
/* --- Transfer drag: show hidden dashboard columns as overlay --- */
body.recipe-transfer-session #recipesMobileNav {
display: none !important;
}
body.recipe-transfer-session #dispensersCol,
body.recipe-transfer-session #periodsCol {
display: block !important;
position: fixed;
left: 0;
right: 0;
z-index: 1250;
margin: 0 !important;
padding: 0 0.5rem;
max-height: 42vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: var(--surface-page, #f5f7fa);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
}
body.recipe-transfer-session #dispensersCol {
top: 0;
}
body.recipe-transfer-session #periodsCol {
top: 42vh;
max-height: 38vh;
}
body.recipe-transfer-session #recipesCol {
display: block !important;
margin-top: 80vh;
}
body.recipe-transfer-session #recipesCol .card-header {
position: sticky;
top: 0;
z-index: 2;
}
#recipesList .recipe-card-meta {
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
justify-content: flex-start !important;
}
#recipesList .recipe-card-meta > * {
flex: 0 1 auto;
}
/* --- Fullscreen recipe editor --- */
body.recipe-edit-mobile-open {
overflow: hidden;
touch-action: pan-x pan-y;
}
body.recipe-edit-mobile-open .mobile-navbar,
body.recipe-edit-mobile-open .app-footer,
body.recipe-edit-mobile-open .page-content {
display: none !important;
}
body.recipe-edit-mobile-open #recipesDashboard,
body.recipe-edit-mobile-open #recipesMobileNav {
display: none !important;
}
body.recipe-edit-mobile-open #recipeEdit,
#recipeEdit.recipe-edit--mobile-active {
display: flex !important;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: none;
height: 100%;
height: 100dvh;
z-index: 1200;
margin: 0 !important;
overflow: hidden !important;
background: var(--surface-page, #f5f7fa);
padding-bottom: 0;
touch-action: pan-x pan-y;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-panel,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-panel {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 0;
box-shadow: none;
border: none;
overflow: hidden !important;
touch-action: pan-x pan-y;
padding-bottom: 0;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm,
#recipeEdit.recipe-edit--mobile-active #recipeForm {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
touch-action: pan-y;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-header,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-header {
flex-shrink: 0;
}
body.recipe-edit-mobile-open #recipeEdit .card-body,
#recipeEdit.recipe-edit--mobile-active .card-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
/* --- Compact recipe edit header --- */
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header {
padding: 0.4rem 0.85rem;
padding-top: calc(0.4rem + env(safe-area-inset-top, 0px));
background: var(--surface-card, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-text,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-text {
display: none !important;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-inner,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-inner {
justify-content: flex-end;
gap: 0.35rem;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions {
flex: 1 1 auto;
width: 100%;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions .d-flex,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions .d-flex {
justify-content: flex-end;
gap: 0.35rem;
flex-wrap: nowrap;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-icon-btn,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-icon-btn {
min-width: 36px;
min-height: 36px;
padding: 0.28rem 0.38rem;
}
#recipeEdit .recipe-section-toolbar > .btn-primary,
#recipeEdit .recipe-section-toolbar .recipe-section-toggle-btn {
min-height: 36px;
padding: 0.32rem 0.7rem;
font-size: 0.875rem;
touch-action: manipulation;
}
#recipeEdit .recipe-section-card > .card-header {
padding: 0.65rem 0.85rem;
}
#recipeEdit .recipe-section-card > .card-body {
padding: 0.75rem 1rem 1rem;
}
#recipeEdit .recipe-section-toolbar {
margin-bottom: 0.75rem;
gap: 0.5rem;
}
#recipeEdit .recipe-section-toolbar-title {
font-size: 0.95rem;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row {
margin-bottom: 0.65rem !important;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row .form-group,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row .form-group {
margin-bottom: 0.55rem;
}
/* --- Card layout for edit data grids --- */
#recipeEdit .zt-data-grid__scroll {
overflow: visible;
}
#recipeEdit .zt-data-grid__head {
display: none;
}
#recipeEdit #ingredientsTable.ingredients-table--dm-unlocked .ingredient-detail-cell.dry-matter-per-head {
display: none;
}
/* --- Compact ingredient list (mobile) --- */
#recipeEdit #ingredientsTableBody .ingredient-row {
display: block;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
#recipeEdit #ingredientsTableBody .ingredient-row:last-child {
border-bottom: none;
}
#recipeEdit #ingredientsTableBody .ingredient-row .zt-data-grid__cell {
border-bottom: none;
padding: 0;
background: transparent;
}
#recipeEdit #ingredientsTableBody .ingredient-row .ingredient-mobile-summary {
display: block;
padding: 0;
}
#recipeEdit #ingredientsTableBody .ingredient-row .ingredient-detail-cell {
display: none !important;
}
/* --- Compact unloading groups list (mobile) --- */
#recipeEdit #unloadingGroupsTableBody .unloading-group-row {
display: block;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row:last-child {
border-bottom: none;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .zt-data-grid__cell {
border-bottom: none;
padding: 0;
background: transparent;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-mobile-summary {
display: block;
padding: 0;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-detail-cell {
display: none !important;
}
#recipeEdit #unloadingGroupsTable .zt-data-grid__foot,
#recipeEdit #ingredientsTable .zt-data-grid__foot {
display: none !important;
}
#recipeEdit .recipe-table-row-actions {
justify-content: flex-start !important;
flex-wrap: wrap;
gap: 0.5rem !important;
}
#recipeEdit .recipe-table-move-group .btn,
#recipeEdit .recipe-table-delete-btn {
min-height: 44px;
min-width: 44px;
touch-action: manipulation;
}
.recipe-mobile-row-summary-btn,
.ingredient-mobile-summary-btn,
.unloading-group-mobile-summary-btn {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
padding: 0.75rem 0.85rem;
border: none;
background: transparent;
text-align: left;
gap: 0.55rem;
touch-action: manipulation;
cursor: pointer;
color: inherit;
}
.ingredient-mobile-name,
.unloading-group-mobile-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ingredient-mobile-trip,
.unloading-group-mobile-meta {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #64748b);
white-space: nowrap;
}
.recipe-mobile-row-summary-chevron,
.ingredient-mobile-summary-chevron {
flex-shrink: 0;
color: var(--text-secondary, #64748b);
font-size: 0.85rem;
}
/* Hide desktop totals block — values are in edit sheet or recalculated */
/* --- Mobile edit bottom sheets --- */
.recipe-mobile-edit-sheet[hidden],
.ingredient-mobile-sheet[hidden],
.unloading-group-mobile-sheet[hidden] {
display: none !important;
}
.recipe-mobile-edit-sheet:not([hidden]),
.ingredient-mobile-sheet:not([hidden]),
.unloading-group-mobile-sheet:not([hidden]) {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
body.ingredient-mobile-sheet-open,
body.unloading-group-mobile-sheet-open,
body.recipe-help-modal-open {
overflow: hidden;
}
.recipe-mobile-edit-sheet-backdrop,
.ingredient-mobile-sheet-backdrop {
position: absolute;
inset: 0;
border: none;
padding: 0;
background: rgba(15, 23, 42, 0.45);
cursor: pointer;
}
.recipe-mobile-edit-sheet-panel,
.ingredient-mobile-sheet-panel {
position: relative;
display: flex;
flex-direction: column;
max-height: min(82dvh, 620px);
min-height: 0;
background: var(--surface-card, #fff);
border-radius: 16px 16px 0 0;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(15, 23, 42, 0.15);
touch-action: pan-y;
will-change: transform;
}
.recipe-mobile-edit-sheet-grab {
flex-shrink: 0;
width: 2.75rem;
height: 0.28rem;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: var(--border-subtle, rgba(148, 163, 184, 0.55));
touch-action: none;
cursor: grab;
}
.recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-grab {
cursor: grabbing;
}
.recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-body,
.recipe-mobile-edit-sheet--dragging .ingredient-mobile-sheet-body {
overflow: hidden;
}
.recipe-mobile-edit-sheet-header,
.ingredient-mobile-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
padding: 0.85rem 1.1rem 0.75rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
flex-shrink: 0;
touch-action: none;
cursor: grab;
}
.recipe-mobile-edit-sheet-done,
.ingredient-mobile-sheet-done {
min-height: 36px;
min-width: 5.5rem;
padding-left: 0.85rem;
padding-right: 0.85rem;
font-size: 0.875rem;
flex-shrink: 0;
touch-action: manipulation;
cursor: pointer;
}
.recipe-mobile-edit-sheet-title,
.ingredient-mobile-sheet-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
padding-right: 0.35rem;
}
.recipe-mobile-edit-sheet-body,
.ingredient-mobile-sheet-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 1rem 1.1rem calc(1.1rem + env(safe-area-inset-bottom, 0px));
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.ingredient-mobile-sheet-field,
.unloading-group-mobile-sheet-field {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.ingredient-mobile-sheet-field:last-child,
.unloading-group-mobile-sheet-field:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.ingredient-mobile-sheet-label,
.unloading-group-mobile-sheet-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary, #64748b);
margin-bottom: 0.5rem;
}
.ingredient-mobile-sheet-field .form-control,
.ingredient-mobile-sheet-field .form-select,
.unloading-group-mobile-sheet-field .form-control,
.unloading-group-mobile-sheet-field .form-select {
width: 100%;
font-size: 16px;
min-height: 44px;
}
.ingredient-mobile-sheet-field.ingredient-mobile-sheet-actions,
.unloading-group-mobile-sheet-field.unloading-group-mobile-sheet-actions {
padding-top: 0.75rem;
margin-top: 0.25rem;
margin-bottom: 0;
border-top: 1px solid var(--border-color, rgba(148, 163, 184, 0.2));
border-bottom: none;
padding-bottom: 0;
}
.ingredient-mobile-sheet-actions .recipe-table-row-actions,
.unloading-group-mobile-sheet-actions .recipe-table-row-actions {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
justify-content: stretch !important;
flex-wrap: wrap;
}
.ingredient-mobile-sheet-actions .recipe-table-move-group,
.unloading-group-mobile-sheet-actions .recipe-table-move-group {
display: contents;
}
.ingredient-mobile-sheet-actions .recipe-table-move-group .btn,
.unloading-group-mobile-sheet-actions .recipe-table-move-group .btn,
.ingredient-mobile-sheet-actions .recipe-table-delete-btn,
.unloading-group-mobile-sheet-actions .recipe-table-delete-btn {
width: 100%;
min-height: 48px;
min-width: 0;
padding: 0.55rem 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.05rem;
border-radius: 0.5rem !important;
touch-action: manipulation;
}
.ingredient-mobile-sheet-actions .recipe-table-delete-btn,
.unloading-group-mobile-sheet-actions .recipe-table-delete-btn {
grid-column: 1 / -1;
}
.ingredient-mobile-sheet.ingredients-table--dm-unlocked .ingredient-mobile-sheet-field.dry-matter-per-head {
display: none;
}
#recipeEdit .recipe-help-icon-btn {
width: 2rem;
height: 2rem;
touch-action: manipulation;
}
.recipe-help-modal-body {
padding: 0 1rem 1.25rem;
}
.recipe-help-modal-body .zt-notice--static {
margin: 0;
box-shadow: none;
}
/* Footer actions: hidden on mobile — use header icons (save / print / close) */
body.recipe-edit-mobile-open #recipeEdit .recipe-form-footer-actions,
#recipeEdit.recipe-edit--mobile-active .recipe-form-footer-actions {
display: none !important;
}
/* iOS zoom prevention for top-level form fields */
#recipeEdit .form-control,
#recipeEdit .form-select {
font-size: 16px;
}
}
@media (max-width: 400px) {
#recipesList .recipe-card-meta {
flex-direction: column;
align-items: flex-start !important;
}
}
@media (max-width: 768px) {
html[data-theme="dark"] .recipes-mobile-nav {
background: var(--dm-surface, #1a2228);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipes-mobile-nav-title {
color: var(--dm-text, #e9ecef);
}
html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row,
html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit,
html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active {
background: var(--dm-bg, #12181b);
}
html[data-theme="dark"] body.recipe-transfer-session #dispensersCol,
html[data-theme="dark"] body.recipe-transfer-session #periodsCol {
background: var(--dm-bg, #12181b);
}
html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header,
html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active .recipe-edit-header {
background: var(--dm-surface, #1a2228);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipe-help-modal-panel,
html[data-theme="dark"] .recipe-mobile-edit-sheet-panel,
html[data-theme="dark"] .ingredient-mobile-sheet-panel {
background: var(--dm-surface, #1a2228);
}
html[data-theme="dark"] .recipe-mobile-edit-sheet-header,
html[data-theme="dark"] .ingredient-mobile-sheet-header {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipe-mobile-edit-sheet-grab {
background: rgba(255, 255, 255, 0.28);
}
html[data-theme="dark"] .ingredient-mobile-sheet-field,
html[data-theme="dark"] .unloading-group-mobile-sheet-field {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .ingredient-mobile-name,
html[data-theme="dark"] .unloading-group-mobile-name {
color: var(--dm-text, #e9ecef);
}
html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row,
html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] #recipesList .recipe-card-insert-indicator {
box-shadow: 0 0 0 3px rgba(100, 181, 246, 0.22);
}
html[data-theme="dark"] .recipe-card-drag-ghost--card-clone {
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.45);
}
html[data-theme="dark"] .recipe-list-scroll-zone-label {
background: rgba(26, 34, 40, 0.94);
color: #64b5f6;
}
html[data-theme="dark"] .recipe-reorder-hint {
background: rgba(100, 181, 246, 0.1);
border-color: rgba(100, 181, 246, 0.24);
color: #64b5f6;
}
}
@@ -0,0 +1,370 @@
/**
* Skeleton loaders for /recipes — list cards, edit panel, drag ghost.
* Uses page tokens from recipes.html :root and dark --dm-* from wesp-zootech-shell.css.
*/
@keyframes dashboard-skeleton-shimmer {
0% {
background-position: 240% 0;
}
100% {
background-position: -240% 0;
}
}
@keyframes dashboard-stat-card-unfold {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* --- List skeleton container --- */
.dashboard-list-skeleton {
display: flex;
flex-direction: column;
gap: 0;
padding: 2px 0;
min-height: 11.5rem;
transition: opacity 150ms ease;
}
.dashboard-list-skeleton--exit {
opacity: 0;
pointer-events: none;
}
/* Card row — matches .list-item */
.dashboard-skeleton-row {
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1.25rem;
background: var(--surface-card, #fff);
border: 1px solid var(--border-strong, #cbd5e1);
pointer-events: none;
}
.dashboard-skeleton-row:last-child {
margin-bottom: 0;
}
.dashboard-skeleton-row:nth-child(1) { --skel-i: 0; }
.dashboard-skeleton-row:nth-child(2) { --skel-i: 1; }
.dashboard-skeleton-row:nth-child(3) { --skel-i: 2; }
.dashboard-skeleton-row:nth-child(4) { --skel-i: 3; }
.dashboard-skeleton-row--recipe {
padding: 1.25rem;
}
.dashboard-skeleton-row--field {
background: transparent;
border: none;
padding: 0;
margin-bottom: 0;
}
/* --- Bars & pills --- */
.dashboard-skeleton-bar,
.dashboard-skeleton-pill {
border-radius: 5px;
background: linear-gradient(
105deg,
var(--border-subtle, #e2e8f0) 0%,
var(--border-subtle, #e2e8f0) 34%,
rgba(255, 255, 255, 0.92) 50%,
var(--border-subtle, #e2e8f0) 66%,
var(--border-subtle, #e2e8f0) 100%
);
background-size: 240% 100%;
animation: dashboard-skeleton-shimmer 1.2s ease-in-out infinite;
animation-delay: calc(var(--skel-i, 0) * 0.08s);
}
.dashboard-skeleton-bar {
height: 0.72rem;
margin-bottom: 0.65rem;
width: 100%;
}
.dashboard-skeleton-bar:last-child {
margin-bottom: 0;
}
.dashboard-skeleton-bar--narrow {
width: 58%;
height: 0.62rem;
}
.dashboard-skeleton-bar--head-title {
flex: 1 1 auto;
min-width: 0;
max-width: 72%;
margin-bottom: 0;
}
.dashboard-skeleton-bar--meta-half {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
}
.dashboard-skeleton-bar--actions-left {
width: 32%;
height: 0.62rem;
margin-bottom: 0;
}
.dashboard-skeleton-bar--actions-right {
width: 38%;
margin-bottom: 0;
margin-left: auto;
max-width: 40%;
}
.dashboard-skeleton-bar--title {
width: 92%;
margin-bottom: 0;
}
.dashboard-skeleton-bar--subtitle {
width: 70%;
margin-top: 0.65rem;
}
.dashboard-skeleton-bar--meta {
height: 0.58rem;
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
}
.dashboard-skeleton-bar--meta.dashboard-skeleton-bar--narrow {
flex: 0 0 30%;
width: auto;
}
.dashboard-skeleton-bar--label {
width: 40%;
height: 0.62rem;
margin-bottom: 0.5rem;
}
.dashboard-skeleton-bar--input {
height: 2.5rem;
margin-bottom: 0;
border-radius: 6px;
}
.dashboard-skeleton-bar--hero-title {
width: min(420px, 72%);
height: 1rem;
}
.dashboard-skeleton-bar--hero-sub {
width: 36%;
margin-top: 0.65rem;
}
.dashboard-skeleton-pill {
flex: 0 0 auto;
width: 4.75rem;
height: 1.25rem;
border-radius: 999px;
margin-bottom: 0;
}
.dashboard-skeleton-pill--wide {
width: 5.5rem;
}
/* --- Layout rows inside skeleton cards --- */
.dashboard-skeleton-row-head,
.dashboard-skeleton-row-foot {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
justify-content: space-between;
}
.dashboard-skeleton-row-head {
margin-bottom: 0.75rem;
}
.dashboard-skeleton-row-foot--end {
justify-content: flex-end;
}
.dashboard-skeleton-flex-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.dashboard-skeleton-flex-row--actions {
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--actions-end {
justify-content: flex-end;
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--actions-end .dashboard-skeleton-pill {
width: 2.25rem;
height: 2.25rem;
}
.dashboard-skeleton-card-inner--mill > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--meta .dashboard-skeleton-bar {
flex: 1 1 0;
min-width: 0;
}
.dashboard-skeleton-card-inner {
display: flex;
flex-direction: column;
gap: 0;
}
.dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
/* --- Edit panel skeleton --- */
.recipe-edit-skeleton-panel {
display: none;
}
.recipe-edit-panel--loading .recipe-edit-skeleton-panel {
display: flex;
align-items: center;
justify-content: center;
}
.recipe-edit-panel--loading #recipeForm {
display: none !important;
}
.recipe-edit-skeleton-hero {
margin-bottom: 1.35rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.recipe-edit-skeleton-field {
margin-bottom: 0;
}
.recipe-edit-skeleton-table {
display: flex;
flex-direction: column;
gap: 0.65rem;
min-height: 10rem;
padding: 0.25rem 0;
}
.recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter {
animation: dashboard-stat-card-unfold 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* --- Drag ghost --- */
.recipe-card-drag-ghost {
box-sizing: border-box;
padding: 1.25rem;
margin: 0;
border-radius: 8px;
border: 1px solid var(--border-strong, #cbd5e1);
background: var(--surface-card, #fff);
box-shadow: var(--box-shadow-hover, 0 10px 28px rgba(15, 23, 42, 0.14));
}
.recipe-card-drag-ghost--follow {
position: fixed;
z-index: 1300;
pointer-events: none;
transform: translate(-50%, -50%);
opacity: 0.96;
will-change: transform, left, top;
}
.recipe-card-drag-ghost .dashboard-skeleton-bar {
margin-bottom: 0;
}
.recipe-card-drag-ghost .dashboard-skeleton-flex-row--actions {
margin-bottom: 0.65rem;
}
.recipe-card-drag-ghost .dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
/* --- Dark theme --- */
html[data-theme="dark"] .dashboard-skeleton-row {
background: var(--dm-surface, #2c3338);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .dashboard-skeleton-bar,
html[data-theme="dark"] .dashboard-skeleton-pill {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0.06) 0%,
rgba(255, 255, 255, 0.06) 34%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.06) 66%,
rgba(255, 255, 255, 0.06) 100%
);
background-size: 240% 100%;
}
html[data-theme="dark"] .recipe-card-drag-ghost {
background: var(--dm-surface) !important;
border-color: var(--dm-border) !important;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-panel {
background: var(--dm-surface) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-hero {
border-bottom-color: var(--dm-border) !important;
}
/* --- Reduced motion --- */
@media (prefers-reduced-motion: reduce) {
.dashboard-skeleton-bar,
.dashboard-skeleton-pill {
animation: none !important;
background: var(--border-subtle, #e2e8f0);
}
html[data-theme="dark"] .dashboard-skeleton-bar,
html[data-theme="dark"] .dashboard-skeleton-pill {
background: rgba(255, 255, 255, 0.12);
}
.dashboard-list-skeleton--exit {
transition: none;
}
.recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter {
animation: none !important;
}
}
@@ -0,0 +1,986 @@
/**
* /reports — таблицы, фильтры, отступы (карточки — inline в reports.html как на /components).
*/
/* Карточки отчётов не интерактивные — без hover-подсветки (кроме контроля отклонений) */
.zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover {
background: var(--bg-white, #fff) !important;
border-color: var(--border-light) !important;
box-shadow: none !important;
transform: none !important;
}
html[data-theme="dark"] .zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover {
background: var(--bg-white, #2c3338) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.reports-page .reports-container {
max-width: none;
margin: 0;
padding: 0;
}
.reports-page .period-selectors {
margin-bottom: 0.75rem;
}
/* Шапка и вкладки */
.reports-page .reports-controls__head {
margin-bottom: var(--zt-space-3, 1rem);
}
.reports-page .reports-controls__head .form-title {
margin-bottom: 0;
}
.reports-page .reports-controls__block + .reports-controls__block {
margin-top: var(--zt-space-3, 1rem);
padding-top: var(--zt-space-3, 1rem);
border-top: 1px solid var(--border-light);
}
.reports-page .reports-controls__block--tabs {
padding-top: 0;
border-top: none;
margin-top: 0;
}
.reports-page .reports-controls__block--filters {
margin-bottom: 0;
overflow: visible;
}
.reports-page .reports-controls .zt-tabs {
margin-bottom: 0;
}
.reports-page .reports-view-tabs-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--zt-space-2, 0.5rem) var(--zt-space-3, 0.75rem);
}
.reports-page .reports-view-tabs-row__sep {
width: 1px;
height: 1.25rem;
background: var(--border-light);
flex-shrink: 0;
}
.reports-page .reports-journal-tabs-wrap {
display: flex;
align-items: center;
gap: var(--zt-space-2, 0.5rem);
}
/* Компактная строка фильтров — единая сетка */
.reports-page .reports-filter-bar {
display: grid;
grid-template-columns: minmax(11rem, 1.15fr) minmax(8.5rem, 0.95fr) minmax(7.5rem, 0.85fr) auto;
gap: var(--zt-space-2, 0.5rem);
align-items: center;
}
.reports-page .reports-filter-bar--with-farm {
grid-template-columns:
minmax(10rem, 1fr)
minmax(7.5rem, 0.85fr)
minmax(7rem, 0.8fr)
minmax(6.5rem, 0.75fr)
auto;
}
.reports-page .reports-filter-bar__control,
.reports-page .reports-filter-bar__select,
.reports-page .reports-drp__trigger,
.reports-page .reports-filter-bar__apply {
width: 100%;
min-height: var(--zt-btn-height, 40px);
height: var(--zt-btn-height, 40px);
font-size: var(--zt-font-body, 0.875rem);
}
.reports-page .reports-filter-bar__apply {
width: auto;
min-width: 7.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
white-space: nowrap;
padding-inline: 1rem;
font-weight: 600;
}
.reports-page .reports-filter-select {
position: relative;
min-width: 0;
}
.reports-page .reports-filter-select__native {
position: absolute !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
padding: 0;
margin: -1px;
}
.reports-page .reports-filter-dropdown__trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
width: 100%;
padding: 0 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
cursor: pointer;
text-align: left;
box-shadow: none;
appearance: none;
font-size: var(--zt-font-body, 0.875rem);
}
.reports-page .reports-filter-dropdown__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-filter-dropdown__chev {
flex-shrink: 0;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
}
.reports-page .reports-filter-dropdown__trigger:hover {
border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35);
}
.reports-page .reports-filter-dropdown__trigger:focus,
.reports-page .reports-filter-dropdown__trigger[aria-expanded="true"] {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
outline: none;
}
.reports-page .reports-filter-dropdown__menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 0.35rem);
z-index: 1200;
max-height: 16rem;
overflow-y: auto;
padding: 0.35rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-filter-dropdown__item {
display: block;
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 0.45rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
font-size: var(--zt-font-body, 0.875rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-filter-dropdown__item:hover,
.reports-page .reports-filter-dropdown__item--active {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
color: var(--primary-color);
}
.reports-page .reports-filter-dropdown__check {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.45rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
font-size: var(--zt-font-body, 0.875rem);
user-select: none;
}
.reports-page .reports-filter-dropdown__check:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.06);
}
.reports-page .reports-filter-dropdown__check-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__trigger,
html[data-theme="dark"] .reports-page .reports-filter-dropdown__menu {
background: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__item {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__check {
color: #f5f5f5;
}
.reports-page .reports-filter-select .form-select {
appearance: none;
-webkit-appearance: none;
padding-right: 2rem;
background-color: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
box-shadow: none;
}
.reports-page .reports-filter-select .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
}
.reports-page .reports-filter-select__chev {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
}
html[data-theme="dark"] .reports-page .reports-filter-select .form-select {
background-color: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
.reports-page .reports-export-dropdown {
position: relative;
}
.reports-page .reports-export-dropdown__btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.reports-page .reports-export-dropdown__chev {
font-size: 0.6rem;
opacity: 0.7;
}
.reports-page .reports-export-dropdown__menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
z-index: 1200;
min-width: 16.5rem;
max-width: 19rem;
padding: 0.35rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-export-dropdown__group + .reports-export-dropdown__group {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px solid var(--border-light);
}
.reports-page .reports-export-dropdown__group-label {
padding: 0.2rem 0.65rem 0.35rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted, var(--text-muted));
}
.reports-page .reports-export-dropdown__item {
display: flex;
align-items: flex-start;
gap: 0.55rem;
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 0.55rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
}
.reports-page .reports-export-dropdown__icon {
flex-shrink: 0;
width: 1.125rem;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.05rem;
font-size: 1rem;
line-height: 1;
}
.reports-page .reports-export-dropdown__text {
flex: 1;
min-width: 0;
}
.reports-page .reports-export-dropdown__item:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
}
.reports-page .reports-export-dropdown__item strong {
display: block;
font-size: 0.8125rem;
}
.reports-page .reports-export-dropdown__item small {
display: block;
color: var(--muted, var(--text-muted));
font-size: 0.6875rem;
margin-top: 0.1rem;
}
html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
background: var(--dm-field, #2c3338);
border-color: rgba(255, 255, 255, 0.12);
}
.reports-page .reports-drp {
position: relative;
min-width: 0;
}
.reports-page .reports-drp__trigger {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
cursor: pointer;
text-align: left;
box-shadow: none;
appearance: none;
}
.reports-page .reports-drp__trigger:hover {
border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35);
}
.reports-page .reports-drp__trigger:focus,
.reports-page .reports-drp__trigger[aria-expanded="true"] {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
outline: none;
}
.reports-page .reports-drp__trigger .fa-calendar-alt {
color: var(--primary-color);
flex-shrink: 0;
font-size: 0.875rem;
}
.reports-page .reports-drp__trigger #reportsDateRangeLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-drp__chev {
margin-left: auto;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
flex-shrink: 0;
}
.reports-page .reports-drp__panel {
position: fixed;
z-index: 2500;
width: min(19rem, calc(100vw - 1.5rem));
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg, 12px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-drp__presets {
display: flex;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.reports-page .reports-drp__preset {
flex: 1;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-sm, 8px);
background: transparent;
color: var(--text, var(--text-dark));
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.35rem;
min-height: 2rem;
cursor: pointer;
transition: background var(--transition, 0.15s), border-color var(--transition, 0.15s);
}
.reports-page .reports-drp__preset.active,
.reports-page .reports-drp__preset:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
}
.reports-page .reports-drp__cal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.35rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text, var(--text-dark));
}
.reports-page .reports-drp__nav {
border: none;
background: transparent;
color: var(--muted, var(--text-muted));
width: 1.75rem;
height: 1.75rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.reports-page .reports-drp__nav:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
color: var(--primary-color);
}
.reports-page .reports-drp__weekdays,
.reports-page .reports-drp__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.15rem;
}
.reports-page .reports-drp__weekdays {
margin-bottom: 0.25rem;
font-size: 0.625rem;
font-weight: 600;
color: var(--muted, var(--text-muted));
text-align: center;
}
.reports-page .reports-drp__day {
border: none;
background: transparent;
border-radius: var(--zt-radius-sm, 8px);
font-size: 0.75rem;
line-height: 1.75rem;
height: 1.75rem;
padding: 0;
cursor: pointer;
color: var(--text, var(--text-dark));
}
.reports-page .reports-drp__day--empty {
pointer-events: none;
}
.reports-page .reports-drp__day--today {
font-weight: 700;
color: var(--primary-color);
}
.reports-page .reports-drp__day--in-range {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.12);
}
.reports-page .reports-drp__day--edge {
background: var(--primary-color);
color: #fff;
}
.reports-page .reports-drp__day--edge.reports-drp__day--today {
color: #fff;
}
html[data-theme="dark"] .reports-page .reports-drp__trigger,
html[data-theme="dark"] .reports-page .reports-drp__panel {
background: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__preset {
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="dark"] .reports-page .reports-drp__day {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__cal-head {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__weekdays {
color: #a8b0b8;
}
@media (max-width: 768px) {
.reports-page .reports-filter-bar,
.reports-page .reports-filter-bar--with-farm {
grid-template-columns: 1fr 1fr;
}
.reports-page .reports-drp {
grid-column: 1 / -1;
}
.reports-page .reports-filter-bar__apply {
grid-column: 1 / -1;
width: 100%;
}
}
@media (max-width: 640px) {
.reports-page .reports-filter-bar__apply-text {
display: none;
}
}
.reports-page .calendar-inputs {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.reports-page .calendar-group {
flex: 1;
min-width: min(100%, 200px);
}
.reports-page .filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
align-items: end;
}
.reports-page .filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.reports-page .report-section {
margin-top: 0.75rem;
}
.reports-page .report-section__title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
gap: 0.5rem;
}
.reports-page .report-section__title i {
color: var(--primary-color);
}
.reports-page .report-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-sm, 8px);
}
.reports-page .components-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
background: transparent;
margin: 0;
}
.reports-page .components-table th,
.reports-page .components-table td {
padding: 0.5rem 0.65rem;
text-align: right;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
}
.reports-page .components-table th:first-child,
.reports-page .components-table td:first-child {
text-align: left;
}
.reports-page .components-table th {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.06);
font-weight: 600;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.reports-page .components-table th:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.1);
}
.reports-page .components-table tbody tr:last-child td {
border-bottom: none;
}
.reports-page .components-table tbody tr:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.04);
}
.reports-page .deviation-negative {
color: #ef4444;
font-weight: 500;
}
.reports-page .deviation-positive {
color: #10b981;
font-weight: 500;
}
.reports-page .deviation-significant {
color: #ef4444;
font-weight: 600;
}
.reports-page .loading-duration {
font-variant-numeric: tabular-nums;
color: var(--primary-color);
}
.reports-page .empty-state {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
}
.reports-page .empty-state .empty-icon {
font-size: 3rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.reports-page .reports-controls .btn-outline-success {
--bs-btn-color: #059669;
--bs-btn-border-color: rgba(5, 150, 105, 0.45);
--bs-btn-hover-bg: rgba(5, 150, 105, 0.08);
--bs-btn-hover-border-color: #059669;
}
html[data-theme="dark"] .reports-page .report-section__title {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .components-table th {
background: rgba(255, 255, 255, 0.06);
}
html[data-theme="dark"] .reports-page .components-table th,
html[data-theme="dark"] .reports-page .components-table td {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .reports-page .report-table-wrap {
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="dark"] .reports-page .reports-controls .btn-outline-success {
--bs-btn-color: #6ee7b7;
--bs-btn-border-color: rgba(110, 231, 183, 0.35);
}
@media (max-width: 768px) {
.reports-page .report-card__top {
flex-direction: column;
align-items: flex-start;
}
.reports-page .component-info {
grid-template-columns: 1fr;
}
.reports-page .calendar-inputs {
flex-direction: column;
align-items: stretch;
}
.reports-page .filter-buttons {
width: 100%;
}
.reports-page .filter-buttons .btn {
flex: 1;
}
}
.reports-page .reports-controls__head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.reports-page .feed-alerts-summary-chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--warning-color, #d97706);
background: rgba(217, 119, 6, 0.12);
border-radius: 999px;
padding: 0.25rem 0.65rem;
}
.reports-page .reports-view-tabs,
.reports-page .alerts-severity-tabs {
margin-bottom: 0;
}
/* Лента отклонений — карточки как на /components */
.reports-page .deviation-feed {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reports-page .deviation-alert-card.list-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
width: 100%;
text-align: left;
appearance: none;
-webkit-appearance: none;
font: inherit;
color: inherit;
margin: 0;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.25rem;
cursor: pointer;
box-shadow: var(--shadow-soft);
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.reports-page .deviation-alert-card.list-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-soft), 0 0 0 1px rgba(var(--primary-rgb, 33, 150, 243), 0.2);
background-color: rgba(var(--primary-rgb), 0.04);
}
.reports-page .deviation-alert-card__top {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.reports-page .deviation-alert-card__head {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.reports-page .deviation-alert-card__title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--text-dark);
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.reports-page .deviation-alert-card__title i {
color: var(--primary-color);
font-size: 0.8rem;
flex-shrink: 0;
}
.reports-page .deviation-type-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.reports-page .deviation-type-badge--warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.reports-page .deviation-type-badge--error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.reports-page .deviation-alert-card__info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.35rem 1rem;
}
.reports-page .deviation-alert-card__context {
font-size: 0.875rem;
color: var(--text-dark);
font-weight: 600;
line-height: 1.35;
}
.reports-page .deviation-alert-card__meta {
font-size: 0.8125rem;
color: var(--text-muted);
}
.reports-page .deviation-feed-item__dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-top: 0.45rem;
}
.reports-page .deviation-feed-item__dot--warning {
background: var(--warning-color, #f59e0b);
}
.reports-page .deviation-feed-item__dot--error {
background: var(--danger-color, #ef4444);
}
.reports-page .deviation-feed-item__dot--info {
background: var(--info-color, #0ea5e9);
}
.reports-page .deviation-feed-item__delta {
flex-shrink: 0;
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.15rem;
}
.reports-page .deviation-feed-item__chevron {
align-self: flex-end;
color: var(--text-muted);
opacity: 0.45;
font-size: 0.7rem;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.reports-page .deviation-alert-card:hover .deviation-feed-item__chevron {
opacity: 0.85;
transform: translateX(2px);
}
html[data-theme="dark"] .reports-page .deviation-alert-card.list-item {
background: var(--bg-white) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
box-shadow: none !important;
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .deviation-alert-card.list-item:hover {
background-color: rgba(var(--primary-rgb), 0.1) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="dark"] .reports-page .deviation-alert-card__title,
html[data-theme="dark"] .reports-page .deviation-alert-card__context {
color: #f5f5f5 !important;
}
html[data-theme="dark"] .reports-page .deviation-alert-card__meta {
color: #94a3b8;
}
html[data-theme="dark"] .reports-page .deviation-type-badge--warning {
background: rgba(251, 191, 36, 0.22);
color: #fcd34d;
}
html[data-theme="dark"] .reports-page .deviation-type-badge--error {
background: rgba(248, 113, 113, 0.22);
color: #fca5a5;
}
html[data-theme="dark"] .reports-page .deviation-feed-item__delta {
color: #f1f5f9;
}
html[data-theme="dark"] .reports-page .deviation-negative,
html[data-theme="dark"] .reports-page .deviation-significant {
color: #f87171;
}
html[data-theme="dark"] .reports-page .deviation-positive {
color: #34d399;
}
html[data-theme="dark"] .reports-page .feed-alerts-summary-chip {
color: #fbbf24;
background: rgba(251, 191, 36, 0.16);
}
@@ -0,0 +1,807 @@
/**
* Компоненты операторского UI — карточки, кнопки, формы, табы, loading, empty.
* После wesp-zootech-layout.css
*/
/* —— Section —— */
.zt-section {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg);
box-shadow: var(--shadow-soft);
padding: var(--zt-space-4);
margin-bottom: var(--zt-space-4);
}
.zt-section__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--zt-space-3);
margin-bottom: var(--zt-space-3);
flex-wrap: wrap;
}
.zt-section__title {
display: flex;
align-items: center;
gap: var(--zt-space-2);
font-size: var(--zt-font-section);
font-weight: 600;
color: var(--text);
margin: 0;
}
.zt-section__title i {
color: var(--primary-color);
font-size: 1.1rem;
}
.zt-section__subtitle {
font-size: var(--zt-font-body);
color: var(--muted);
margin: 0.25rem 0 0;
}
.zt-section__actions {
display: flex;
align-items: center;
gap: var(--zt-space-2);
flex-shrink: 0;
}
/* —— Card —— */
.zt-card {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg);
padding: var(--zt-space-4);
box-shadow: var(--shadow-soft);
transition: border-color var(--transition), box-shadow var(--transition);
}
.zt-card--interactive {
cursor: pointer;
}
.zt-card--interactive:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-soft), 0 0 0 2px rgba(var(--primary-rgb), 0.18);
}
.zt-card--dashed {
border-style: dashed;
border-color: var(--border-light);
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--zt-space-3);
}
.zt-card--stat {
border-left: 3px solid var(--primary-color);
padding: var(--zt-space-3) var(--zt-space-4);
}
.zt-card-grid {
display: grid;
gap: var(--zt-space-3);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* Legacy aliases */
.component-card,
.dispenser-card,
.consumption-block .zt-card {
/* migrated pages use zt-card; keep hooks for gradual migration */
}
/* —— Loading placeholder —— */
.zt-loading-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
min-height: 8rem;
padding: var(--zt-space-3) 0;
}
.zt-loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
}
.zt-loading-list.dashboard-list-skeleton--exit,
.dashboard-list-skeleton--exit {
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.zt-card--loading {
pointer-events: none;
min-height: 5.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
animation: zt-card-pulse 1.6s ease-in-out infinite;
}
@keyframes zt-card-pulse {
0%,
100% {
border-color: var(--border-light);
opacity: 1;
}
50% {
border-color: rgba(var(--primary-rgb), 0.35);
opacity: 0.92;
}
}
.zt-spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: zt-spin 0.7s linear infinite;
}
.zt-spinner--lg {
width: 32px;
height: 32px;
border-width: 3px;
}
@keyframes zt-spin {
to {
transform: rotate(360deg);
}
}
.zt-loading-caption {
font-size: var(--zt-font-caption);
color: var(--muted);
margin: 0;
}
.zt-loading-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-3);
padding: var(--zt-space-5);
min-height: 12rem;
}
/* —— Tabs —— */
.zt-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--zt-space-1);
margin-bottom: var(--zt-space-3);
}
.zt-tab,
.fa-tab-btn.period-btn {
border: 1px solid var(--border-light);
background: transparent;
border-radius: var(--zt-radius-sm);
padding: 0.45rem 0.85rem;
font-size: var(--zt-font-body);
font-weight: 500;
color: var(--text);
cursor: pointer;
transition: background var(--transition), border-color var(--transition),
color var(--transition);
min-height: var(--zt-btn-height);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.zt-tab:hover,
.fa-tab-btn:not(.active):hover {
background: rgba(var(--primary-rgb), 0.06);
border-color: rgba(var(--primary-rgb), 0.25);
}
.zt-tab.active,
.zt-tab--active,
.fa-tab-btn.active,
.period-btn.active {
background: var(--gradient-blue) !important;
border-color: transparent !important;
color: #fff !important;
}
/* —— Empty state —— */
.zt-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--zt-space-5) var(--zt-space-4);
color: var(--muted);
}
.zt-empty__icon {
font-size: 2.5rem;
margin-bottom: var(--zt-space-3);
opacity: 0.45;
}
.zt-empty__title {
font-size: var(--zt-font-section);
font-weight: 600;
color: var(--text);
margin: 0 0 var(--zt-space-1);
}
.zt-empty__hint {
font-size: var(--zt-font-body);
margin: 0;
max-width: 28rem;
}
/* —— Inline notices & toasts (единый вид подсказок и уведомлений) —— */
.zt-notice {
display: none;
margin: 0;
padding: 0.75rem 1rem;
font-size: var(--zt-font-body);
line-height: 1.55;
color: var(--text);
background: var(--bg-light);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md);
border-left-width: 3px;
border-left-style: solid;
box-shadow: var(--box-shadow);
}
.zt-notice.is-visible,
.zt-notice--static {
display: block;
}
.zt-notice--info {
border-left-color: var(--primary-color);
}
.zt-notice--success {
border-left-color: var(--success-color);
background: rgba(16, 185, 129, 0.08);
}
.zt-notice--warning {
border-left-color: var(--warning-color);
background: rgba(245, 158, 11, 0.1);
}
.zt-notice--error {
border-left-color: var(--danger-color);
background: rgba(239, 68, 68, 0.08);
}
.zt-notice__p {
margin: 0 0 0.65rem;
}
.zt-notice__p:last-child {
margin-bottom: 0;
}
.zt-notice__p strong {
font-weight: 600;
color: var(--text);
}
html[data-theme="dark"] .zt-notice {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: var(--text);
box-shadow: none;
}
html[data-theme="dark"] .zt-notice--success {
background: rgba(16, 185, 129, 0.12);
}
html[data-theme="dark"] .zt-notice--warning {
background: rgba(245, 158, 11, 0.12);
}
html[data-theme="dark"] .zt-notice--error {
background: rgba(239, 68, 68, 0.12);
}
/* —— Bottom-center toast (Yandex Music style) —— */
@keyframes wesp-zt-toast-enter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wesp-zt-toast-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(12px);
}
}
@keyframes wesp-zt-toast-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes wesp-zt-toast-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.wesp-zt-toast-host {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 6000;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
}
.wesp-zt-toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 0.6rem;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
min-height: 40px;
padding: 0.45rem 0.55rem 0.45rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 14, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
animation: wesp-zt-toast-enter 280ms ease-out both;
}
.wesp-zt-toast.is-exiting {
animation: wesp-zt-toast-exit 220ms ease-in both;
}
@media (prefers-reduced-motion: reduce) {
.wesp-zt-toast {
animation: wesp-zt-toast-fade-in 200ms ease-out both;
}
.wesp-zt-toast.is-exiting {
animation: wesp-zt-toast-fade-out 180ms ease-in both;
}
}
.wesp-zt-toast__accent {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
align-self: center;
border-radius: 50%;
background: var(--success-color);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.45);
}
.wesp-zt-toast--error .wesp-zt-toast__accent {
background: var(--danger-color);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.45);
}
.wesp-zt-toast--warning .wesp-zt-toast__accent {
background: var(--warning-color);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.45);
}
.wesp-zt-toast__message {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding-right: 0.15rem;
font-size: 0.9375rem;
line-height: 1.35;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wesp-zt-toast--multiline .wesp-zt-toast__message {
white-space: normal;
}
.wesp-zt-toast__message strong {
font-weight: 600;
color: #fff;
}
.wesp-zt-toast__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
margin: 0;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.55);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.wesp-zt-toast__close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="light"] .wesp-zt-toast {
background: rgba(30, 30, 34, 0.9);
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="light"] .wesp-zt-toast__message {
color: rgba(255, 255, 255, 0.92);
}
/* —— Notification center FAB (bottom-left) —— */
.wesp-zt-notify-fab {
position: fixed;
left: max(20px, calc(env(safe-area-inset-left, 0px) + 16px));
bottom: max(24px, calc(env(safe-area-inset-bottom, 0px) + 20px));
z-index: 5500;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
background: rgba(12, 12, 14, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.wesp-zt-notify-fab:hover {
transform: scale(1.04);
}
.wesp-zt-notify-fab:focus-visible {
outline: 2px solid rgba(var(--primary-rgb), 0.85);
outline-offset: 2px;
}
.wesp-zt-notify-fab__glyph {
font-family: Inter, Arial, Helvetica, "DejaVu Sans", sans-serif;
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
color: #fff;
transform: translateY(0.5px);
}
.wesp-zt-notify-fab__badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(12, 12, 14, 0.95);
}
.wesp-zt-notify-fab__badge[hidden] {
display: none !important;
}
/* —— Button grid (modals) —— */
.zt-btn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--zt-space-2);
}
@media (max-width: 480px) {
.zt-btn-grid {
grid-template-columns: 1fr;
}
}
/* —— Form grid —— */
.zt-form-grid {
display: grid;
gap: var(--zt-space-3);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.zootech-app-body .form-label,
.zt-section .form-label {
font-size: var(--zt-font-caption);
font-weight: 500;
color: var(--muted);
margin-bottom: 0.35rem;
}
/* —— Bootstrap overrides (operator pages) —— */
.zootech-app-body .btn {
min-height: var(--zt-btn-height);
padding: 0.45rem 1rem;
border-radius: var(--zt-radius-md);
font-weight: 500;
font-size: var(--zt-font-body);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--zt-space-1);
transition: transform var(--transition), filter var(--transition),
background var(--transition), border-color var(--transition);
}
.zootech-app-body .btn:active:not(:disabled) {
transform: scale(0.98);
}
.zootech-app-body .btn:focus-visible {
outline: none;
box-shadow: var(--zt-focus-ring);
}
.zootech-app-body .btn-primary {
background: var(--gradient-blue) !important;
border: none !important;
color: #fff !important;
}
.zootech-app-body .btn-primary:hover:not(:disabled) {
filter: brightness(1.06);
}
.zootech-app-body .btn-success {
background: var(--success-color) !important;
border-color: var(--success-color) !important;
color: #fff !important;
}
.zootech-app-body .btn-success:hover:not(:disabled) {
filter: brightness(1.05);
}
.zootech-app-body .btn-secondary {
background: var(--field) !important;
border-color: var(--border-light) !important;
color: var(--text) !important;
}
.zootech-app-body .btn-outline-primary {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.zootech-app-body .btn-outline-primary:hover:not(:disabled) {
background: rgba(var(--primary-rgb), 0.08) !important;
}
.zootech-app-body .btn-outline-secondary {
color: var(--text) !important;
border-color: var(--border-light) !important;
}
.zootech-app-body .btn-outline-danger {
color: var(--danger-color) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
}
.zootech-app-body .btn-outline-success {
color: var(--success-color) !important;
border-color: rgba(16, 185, 129, 0.5) !important;
}
.zootech-app-body .btn-outline-success:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.1) !important;
}
.zootech-app-body .form-control,
.zootech-app-body .form-select {
min-height: var(--zt-btn-height);
border-radius: var(--zt-radius-md);
border-color: var(--border-light);
font-size: var(--zt-font-body);
}
.zootech-app-body .form-control:focus,
.zootech-app-body .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
}
/* Magic / modern btn aliases */
.zootech-app-body .magic-button,
.zootech-app-body .modern-btn.btn-primary-modern,
.zootech-app-body .btn-primary-modern {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--zt-space-1);
padding: 0.55rem 1.25rem;
font-weight: 600;
color: #fff;
background: var(--gradient-blue);
border: none;
border-radius: var(--zt-radius-md);
cursor: pointer;
min-height: var(--zt-btn-height);
box-shadow: var(--shadow-soft);
}
.zootech-app-body .magic-button:hover {
filter: brightness(1.06);
}
/* Recipe editor — квадратные icon-кнопки в шапке */
#recipeEdit .recipe-edit-header-icon-btn {
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--zt-radius-md);
}
#recipeEdit .recipe-section-toolbar > .btn-primary,
#recipeEdit .recipe-form-footer-actions .btn-primary {
min-height: var(--zt-btn-height);
padding: 0.45rem 1rem;
font-weight: 600;
}
#recipeEdit .recipe-save-exit-btn {
min-width: 11rem;
}
@keyframes zt-reveal {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Recipes boot overlay — только зона контента, navbar остаётся виден */
#wesp-recipes-boot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 900;
background: var(--bg-page);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.35s ease;
pointer-events: none;
}
#wesp-recipes-boot.wesp-recipes-boot--hide {
opacity: 0;
}
html[data-theme="dark"] #wesp-recipes-boot {
background: var(--bg-page);
}
#wesp-recipes-boot .wesp-logo-loader--embedded {
width: min(480px, 90vw);
height: min(222px, 36vh);
}
/* Gray scale legacy (reports) */
:root {
--gray-50: var(--bg-light);
--gray-100: #f3f4f6;
--gray-200: var(--border-light);
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: var(--text-muted);
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: var(--text-dark);
--white: var(--surface);
}
html[data-theme="dark"] {
--gray-50: var(--bg-page);
--gray-100: var(--surface);
--gray-200: #2a3036;
--gray-300: var(--field);
--gray-400: var(--muted);
--gray-500: #b8c0c8;
--gray-600: #d0d4d8;
--gray-700: #e8eaec;
--gray-800: #f0f4f8;
--gray-900: var(--text);
--white: var(--surface);
}
@media (max-width: 768px) {
.zootech-app-body .btn {
min-height: var(--zt-btn-height-touch);
}
}
@media (prefers-reduced-motion: reduce) {
.zt-card--loading {
animation: none;
}
.zt-spinner {
animation: none;
border-top-color: var(--primary-color);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,946 @@
/**
* Общая вёрстка операторских (зоотех) страниц: токены, шрифт, навбар, футер.
* Подключать после wesp-zootech-shell.css и bootstrap.min.css.
*/
:root {
--primary-color: #2196f3;
--primary-rgb: 33, 150, 243;
--primary: var(--primary-color);
--gradient-border: linear-gradient(
135deg,
var(--primary-color),
var(--primary-light),
var(--primary-dark)
);
--primary-light: #42a5f5;
--primary-dark: #1976d2;
--primary-darker: #1565c0;
--primary-blue: var(--primary-color);
--light-blue: #42a5f5;
--accent-blue: #1565c0;
--secondary-color: #6c757d;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #0ea5e9;
--bg-page: #f5f7fa;
--bg-light: #f8fafc;
--bg-white: #ffffff;
--surface-muted: #f2f5f9;
/* Слот логотипа: logo2.png 2222×1024; contain + left (обе темы — один файл) */
--nav-logo-slot-h: 38px;
--nav-logo-slot-w: 115px;
--mobile-menu-logo-slot-h: 32px;
--mobile-menu-logo-slot-w: 96px;
--text-dark: #1e293b;
--text-muted: #64748b;
--border-light: #e2e8f0;
--border-radius: 10px;
--box-shadow: 0 2px 4px rgba(15, 23, 42, 0.06);
--box-shadow-hover: 0 8px 22px rgba(15, 23, 42, 0.1),
0 0 0 1px rgba(15, 23, 42, 0.04);
--shadow-soft: 0 4px 6px -1px rgb(15 23 42 / 0.08),
0 2px 4px -2px rgb(15 23 42 / 0.06);
--shadow-medium: 0 10px 20px -4px rgb(15 23 42 / 0.12);
--gradient-blue: linear-gradient(
135deg,
var(--primary-color) 0%,
var(--primary-light) 100%
);
--transition: 0.22s ease;
--zootech-font: "Inter", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
--zootech-font-mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace;
--zootech-content-max: 1400px;
--zootech-content-pad-x: clamp(0.75rem, 2.5vw, 1.25rem);
--zootech-nav-pad-y: 0.875rem;
/* Semantic aliases */
--surface: var(--bg-white);
--field: #ffffff;
--text: var(--text-dark);
--muted: var(--text-muted);
/* Spacing (8px grid) */
--zt-space-1: 8px;
--zt-space-2: 12px;
--zt-space-3: 16px;
--zt-space-4: 24px;
--zt-space-5: 32px;
--zt-radius-sm: 8px;
--zt-radius-md: 10px;
--zt-radius-lg: 12px;
--zt-font-caption: 0.8125rem;
--zt-font-body: 0.9375rem;
--zt-font-section: 1.125rem;
--zt-font-page: 1.5rem;
--zt-btn-height: 40px;
--zt-btn-height-touch: 44px;
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px rgba(var(--primary-rgb), 0.45);
}
html[data-theme="organic"] {
--primary-color: #48816d;
--primary-rgb: 72, 129, 109;
--primary-light: #5a9a82;
--primary-dark: #3a6b58;
--primary-darker: #2f5748;
--bg-page: #f6f6f4;
--bg-light: #f6f6f4;
--bg-white: #ffffff;
--surface-muted: #eff4f2;
--text-dark: #1a1e1c;
--text-muted: #4a5a52;
--border-light: #e8e8e2;
--box-shadow: 0 2px 4px rgba(26, 30, 28, 0.04);
--box-shadow-hover: 0 8px 22px rgba(26, 30, 28, 0.08),
0 0 0 1px rgba(26, 30, 28, 0.03);
--shadow-soft: 0 5px 20px rgba(0, 0, 0, 0.02);
--shadow-medium: 0 10px 20px -4px rgba(26, 30, 28, 0.1);
--surface: var(--bg-white);
--field: #ffffff;
--text: var(--text-dark);
--muted: var(--text-muted);
--zt-radius-lg: 24px;
--zt-radius-md: 16px;
--organic-logo-filter: brightness(0) saturate(100%) invert(43%) sepia(19%) saturate(747%)
hue-rotate(118deg) brightness(92%) contrast(87%);
--bs-primary: #48816d;
--bs-primary-rgb: 72, 129, 109;
--bs-link-color: #48816d;
--bs-link-hover-color: #3a6b58;
}
html[data-theme="organic"] .nav-brand .logo.logo--light-theme,
html[data-theme="organic"] .mobile-brand .logo.logo--light-theme {
filter: var(--organic-logo-filter) !important;
}
/* Усиленный контраст — все темы (ручной data-high-contrast или системный prefers-contrast) */
html[data-high-contrast="true"][data-theme="light"],
html[data-high-contrast="true"]:not([data-theme]) {
--text-dark: #0f172a;
--text-muted: #334155;
--border-light: #94a3b8;
--primary-color: #1565c0;
--primary-rgb: 21, 101, 192;
--primary-dark: #0d47a1;
--primary-darker: #0a3d91;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
}
html[data-high-contrast="true"][data-theme="organic"] {
--primary-color: #2f6b57;
--primary-rgb: 47, 107, 87;
--primary-light: #3d8570;
--primary-dark: #234a3d;
--primary-darker: #1a3830;
--text-dark: #0f1211;
--text-muted: #2e3834;
--border-light: #b0b8b2;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
--organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%)
hue-rotate(118deg) brightness(88%) contrast(96%);
--bs-primary: #2f6b57;
--bs-primary-rgb: 47, 107, 87;
--bs-link-color: #2f6b57;
--bs-link-hover-color: #234a3d;
--bs-success: #2f6b57;
--bs-success-rgb: 47, 107, 87;
}
html[data-high-contrast="true"][data-theme="dark"] {
--text-dark: #ffffff;
--text-muted: #d1d9e0;
--border-light: rgba(255, 255, 255, 0.28);
--bg-white: #353d44;
--field: #4a5259;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light);
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff;
}
@media (prefers-contrast: more) {
html:not([data-high-contrast="false"])[data-theme="light"],
html:not([data-high-contrast="false"]):not([data-theme]) {
--text-dark: #0f172a;
--text-muted: #334155;
--border-light: #94a3b8;
--primary-color: #1565c0;
--primary-rgb: 21, 101, 192;
--primary-dark: #0d47a1;
--primary-darker: #0a3d91;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
}
html:not([data-high-contrast="false"])[data-theme="organic"] {
--primary-color: #2f6b57;
--primary-rgb: 47, 107, 87;
--primary-light: #3d8570;
--primary-dark: #234a3d;
--primary-darker: #1a3830;
--text-dark: #0f1211;
--text-muted: #2e3834;
--border-light: #b0b8b2;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
--organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%)
hue-rotate(118deg) brightness(88%) contrast(96%);
--bs-primary: #2f6b57;
--bs-primary-rgb: 47, 107, 87;
--bs-link-color: #2f6b57;
--bs-link-hover-color: #234a3d;
--bs-success: #2f6b57;
--bs-success-rgb: 47, 107, 87;
}
html:not([data-high-contrast="false"])[data-theme="dark"] {
--text-dark: #ffffff;
--text-muted: #d1d9e0;
--border-light: rgba(255, 255, 255, 0.28);
--bg-white: #353d44;
--field: #4a525a;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light);
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff;
}
}
html[data-theme="dark"] {
--bg-page: #12181b;
--bg-light: #12181b;
--bg-white: #2c3338;
--text-dark: #f5f5f5;
--text-muted: #a8b0b8;
--border-light: rgba(255, 255, 255, 0.12);
--shadow-soft: 0 4px 12px rgb(0 0 0 / 0.35);
--shadow-medium: 0 12px 24px rgb(0 0 0 / 0.45);
--box-shadow-hover: 0 8px 22px rgb(0 0 0 / 0.35);
--surface: var(--bg-white);
--field: #3e444a;
--text: var(--text-dark);
--muted: var(--text-muted);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body.zootech-app-body,
body:has(.mobile-navbar) {
margin: 0;
font-family: var(--zootech-font);
font-size: 0.9375rem;
color: var(--text-dark);
line-height: 1.55;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--bg-page);
-webkit-font-smoothing: antialiased;
}
.zootech-app-body .container,
body:has(.mobile-navbar) .container {
max-width: var(--zootech-content-max);
margin-left: auto;
margin-right: auto;
padding-left: var(--zootech-content-pad-x);
padding-right: var(--zootech-content-pad-x);
}
.app-footer {
margin-top: auto;
padding: 14px var(--zootech-content-pad-x) 20px;
text-align: center;
font-size: 0.8125rem;
color: var(--text-muted);
}
html[data-theme="dark"] .app-footer,
html[data-theme="dark"] .app-footer .text-muted {
color: var(--text-muted) !important;
}
.page-content {
flex: 1 0 auto;
width: 100%;
}
.main-content {
flex: 1 1 auto;
padding-bottom: 1.5rem;
}
/* —— Верхняя панель —— */
.mobile-navbar {
background: var(--gradient-blue);
padding: var(--zootech-nav-pad-y) 0;
margin-bottom: 1.75rem;
box-shadow: var(--box-shadow-hover);
position: sticky;
top: 0;
z-index: 1000;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.01em;
flex-shrink: 0;
}
.nav-brand i {
font-size: 1.35rem;
color: #ffd54f;
}
.nav-brand .nav-logo-link {
position: relative;
width: var(--nav-logo-slot-w);
height: var(--nav-logo-slot-h);
flex-shrink: 0;
isolation: isolate;
}
/* Блик только по контуру букв (mask = тот же PNG, что и логотип) */
.nav-logo-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.72) 50%,
rgba(255, 255, 255, 0) 70%,
rgba(255, 255, 255, 0) 100%
);
background-size: 280% 100%;
background-position: 100% 0;
background-repeat: no-repeat;
-webkit-mask-image: url("/static/logo2.png");
mask-image: url("/static/logo2.png");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: left center;
mask-position: left center;
mix-blend-mode: soft-light;
will-change: background-position, opacity;
}
/* Блик после появления navbar (ready → done), иначе анимация обрывается */
html.wesp-page-enter-ready .nav-logo-link::after,
html.wesp-page-enter-done .nav-logo-link::after {
animation: wesp-nav-logo-shimmer 3s cubic-bezier(0.42, 0, 0.18, 1) infinite;
animation-delay: 0.35s;
}
@keyframes wesp-nav-logo-shimmer {
0%,
100% {
background-position: 100% 0;
opacity: 0;
}
6% {
opacity: 1;
}
24% {
background-position: 0% 0;
opacity: 1;
}
32%,
100% {
background-position: 0% 0;
opacity: 0;
}
}
html[data-theme="dark"] .nav-logo-link::after {
display: none;
}
html[data-theme="dark"] .nav-logo-link--canvas-shimmer .nav-logo__shimmer-canvas {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.mobile-brand .nav-logo-link {
position: relative;
width: var(--mobile-menu-logo-slot-w);
height: var(--mobile-menu-logo-slot-h);
flex-shrink: 0;
isolation: isolate;
}
.nav-brand .logo,
.mobile-brand .logo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: left center;
filter: brightness(0) invert(1);
}
.nav-brand .logo--dark-theme,
.mobile-brand .logo--dark-theme {
display: none;
}
html[data-theme="dark"] .nav-brand .logo--light-theme,
html[data-theme="dark"] .mobile-brand .logo--light-theme {
display: none;
}
html[data-theme="dark"] .nav-brand .logo--dark-theme,
html[data-theme="dark"] .mobile-brand .logo--dark-theme {
display: block;
}
.hamburger-menu {
display: none;
flex-direction: column;
gap: 5px;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: background var(--transition), transform var(--transition);
}
.hamburger-menu:hover {
background: rgba(255, 255, 255, 0.12);
}
.hamburger-line {
width: 24px;
height: 2.5px;
background: #fff;
border-radius: 2px;
transition: transform var(--transition), opacity var(--transition);
}
.hamburger-menu.active .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-menu.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-menu.active .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.desktop-menu {
display: flex;
gap: 0.45rem;
align-items: center;
flex-wrap: nowrap;
}
.menu-btn {
position: relative;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.9rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
transition: transform var(--transition), background var(--transition),
box-shadow var(--transition);
cursor: pointer;
text-decoration: none;
color: #fff;
font-weight: 500;
font-size: 0.875rem;
overflow: hidden;
}
.menu-btn:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.28);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
color: #fff;
}
.menu-btn.active {
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.35);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.menu-btn i {
font-size: 0.95rem;
color: #fff;
}
.user-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-info {
color: #fff;
font-weight: 500;
font-size: 0.875rem;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout-btn {
display: flex;
align-items: center;
gap: 0.4rem;
color: #ffc9c9;
text-decoration: none;
padding: 0.45rem 0.85rem;
border-radius: 8px;
transition: background var(--transition), color var(--transition);
font-weight: 500;
font-size: 0.875rem;
}
.logout-btn:hover {
background: rgba(255, 107, 107, 0.2);
color: #fff;
}
/* Мобильное меню */
.mobile-menu-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
z-index: 2000;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition), visibility var(--transition);
}
.mobile-menu-overlay.active {
opacity: 1;
visibility: visible;
}
.mobile-menu {
position: fixed;
top: 0;
right: -100%;
width: min(88vw, 340px);
height: 100%;
background: var(--gradient-blue);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25);
transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
flex-direction: column;
}
.mobile-menu-overlay.active .mobile-menu {
right: 0;
}
.mobile-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mobile-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
font-weight: 600;
font-size: 1.05rem;
}
.mobile-brand i {
font-size: 1.35rem;
color: #ffd54f;
}
.close-menu {
color: #fff;
font-size: 1.35rem;
cursor: pointer;
padding: 8px;
border-radius: 50%;
line-height: 1;
transition: background var(--transition);
}
.close-menu:hover {
background: rgba(255, 255, 255, 0.12);
}
.mobile-menu-items {
flex: 1;
padding: 0.5rem 0;
overflow-y: auto;
}
.mobile-menu-item {
display: flex;
align-items: center;
gap: 0.85rem;
color: #fff;
text-decoration: none;
padding: 0.9rem 1.25rem;
font-weight: 500;
font-size: 0.9375rem;
border-left: 3px solid transparent;
transition: background var(--transition), border-color var(--transition);
}
.mobile-menu-item:hover,
.mobile-menu-item.active {
background: rgba(255, 255, 255, 0.1);
border-left-color: #ffd54f;
color: #fff;
}
.mobile-menu-item i {
font-size: 1.1rem;
width: 22px;
text-align: center;
}
.mobile-menu-footer {
padding: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.mobile-user-info {
color: #fff;
font-weight: 500;
font-size: 0.875rem;
margin-bottom: 0.85rem;
padding: 0.6rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.mobile-logout-btn {
display: flex;
align-items: center;
gap: 0.5rem;
color: #ffc9c9;
text-decoration: none;
padding: 0.7rem 0.85rem;
border-radius: 8px;
font-weight: 500;
background: rgba(255, 107, 107, 0.12);
transition: background var(--transition);
}
.mobile-logout-btn:hover {
background: rgba(255, 107, 107, 0.22);
color: #fff;
}
/* Светлая тема: навбар как заголовки таблиц (surface-muted), без синего градиента */
html:not([data-theme="dark"]) .mobile-navbar {
background: var(--surface-muted);
box-shadow: var(--box-shadow);
border-bottom: 1px solid var(--border-light);
}
html:not([data-theme="dark"]) .nav-brand {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .nav-brand i {
color: var(--primary-color);
}
html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo,
html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo {
filter: none;
}
html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo--dark-theme,
html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo--dark-theme {
display: none;
}
html:not([data-theme="dark"]) .hamburger-line {
background: var(--text-dark);
}
html:not([data-theme="dark"]) .hamburger-menu:hover {
background: rgba(15, 23, 42, 0.06);
}
html:not([data-theme="dark"]) .menu-btn {
background: var(--bg-white);
border: 1px solid var(--border-light);
color: var(--text-dark);
box-shadow: none;
}
html:not([data-theme="dark"]) .menu-btn:hover {
background: var(--bg-white);
border-color: var(--primary-color);
color: var(--primary-darker);
box-shadow: var(--box-shadow);
}
html:not([data-theme="dark"]) .menu-btn.active {
background: rgba(var(--primary-rgb), 0.1);
border-color: var(--primary-color);
color: var(--primary-darker);
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.12);
}
html:not([data-theme="dark"]) .menu-btn i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .user-info {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .logout-btn {
color: var(--danger-color);
}
html:not([data-theme="dark"]) .logout-btn:hover {
background: rgba(239, 68, 68, 0.08);
color: var(--danger-color);
}
html:not([data-theme="dark"]) .mobile-menu {
background: var(--bg-white);
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
}
html:not([data-theme="dark"]) .mobile-menu-header {
background: var(--surface-muted);
border-bottom-color: var(--border-light);
}
html:not([data-theme="dark"]) .mobile-brand {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .mobile-brand i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .close-menu {
color: var(--text-muted);
}
html:not([data-theme="dark"]) .close-menu:hover {
background: rgba(15, 23, 42, 0.06);
}
html:not([data-theme="dark"]) .mobile-menu-item {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .mobile-menu-item:hover,
html:not([data-theme="dark"]) .mobile-menu-item.active {
background: rgba(var(--primary-rgb), 0.08);
border-left-color: var(--primary-color);
color: var(--primary-darker);
}
html:not([data-theme="dark"]) .mobile-menu-item i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .mobile-menu-footer {
border-top-color: var(--border-light);
}
html:not([data-theme="dark"]) .mobile-user-info {
color: var(--text-dark);
background: var(--surface-muted);
}
html:not([data-theme="dark"]) .mobile-logout-btn {
color: var(--danger-color);
background: rgba(239, 68, 68, 0.08);
}
html:not([data-theme="dark"]) .mobile-logout-btn:hover {
background: rgba(239, 68, 68, 0.12);
color: var(--danger-color);
}
/* Заголовок раздела (общий паттерн) */
.page-header {
background: var(--bg-white);
border-radius: var(--border-radius);
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft);
border: 1px solid var(--border-light);
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
margin: 0 0 0.35rem;
letter-spacing: -0.02em;
}
.page-subtitle {
font-size: 0.9375rem;
color: var(--text-muted);
margin: 0;
}
@media (max-width: 1200px) {
.desktop-menu {
gap: 0.35rem;
}
.menu-btn {
padding: 0.5rem 0.7rem;
font-size: 0.8125rem;
}
}
@media (max-width: 992px) {
.desktop-menu {
display: none;
}
}
@media (max-width: 768px) {
.hamburger-menu {
display: flex;
}
.user-section {
display: none;
}
:root {
--nav-logo-slot-h: 32px;
--nav-logo-slot-w: 96px;
--mobile-menu-logo-slot-h: 28px;
--mobile-menu-logo-slot-w: 84px;
}
.page-header {
padding: 1.15rem 1.25rem;
}
.page-title {
font-size: 1.3rem;
}
}
/* —— Page enter (navbar → main → blocks), только opacity — без translate (иначе «дёргается») —— */
:root {
--wesp-enter-duration: 350ms;
--wesp-enter-duration-fast: 350ms;
--wesp-enter-ease: cubic-bezier(0.22, 1, 0.36, 1);
--wesp-enter-stagger: 55ms;
--wesp-enter-nav-delay: 0ms;
--wesp-enter-items-base-delay: 80ms;
}
@keyframes wesp-enter-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
html.wesp-page-enter-pending .mobile-navbar {
opacity: 0;
}
html.wesp-page-enter-ready .mobile-navbar {
animation: wesp-enter-fade var(--wesp-enter-duration-fast) var(--wesp-enter-ease) both;
animation-delay: var(--wesp-enter-nav-delay);
}
html.wesp-page-enter-pending .wesp-page-main {
opacity: 0;
}
html.wesp-page-enter-grid-page.wesp-page-enter-pending .wesp-page-main,
html.wesp-page-enter-grid-page.wesp-page-enter-ready .wesp-page-main {
opacity: 1;
}
html.wesp-page-enter-ready .wesp-page-main {
opacity: 1;
}
html.wesp-page-enter-pending .wesp-page-enter-item {
opacity: 0;
}
html.wesp-page-enter-ready .wesp-page-enter-item {
animation: wesp-enter-fade var(--wesp-enter-duration) var(--wesp-enter-ease) both;
animation-delay: calc(
var(--wesp-enter-items-base-delay) + var(--wesp-enter-i, 0) * var(--wesp-enter-stagger)
);
}
html.wesp-page-enter-done .mobile-navbar,
html.wesp-page-enter-done .wesp-page-main,
html.wesp-page-enter-done .wesp-page-enter-item {
animation: none !important;
opacity: 1;
transform: none;
will-change: auto;
}
@media (prefers-reduced-motion: reduce) {
html.wesp-page-enter-pending .mobile-navbar,
html.wesp-page-enter-pending .wesp-page-main,
html.wesp-page-enter-pending .wesp-page-enter-item {
opacity: 1 !important;
}
html.wesp-page-enter-ready .mobile-navbar,
html.wesp-page-enter-ready .wesp-page-main,
html.wesp-page-enter-ready .wesp-page-enter-item {
animation: none !important;
}
.nav-logo-link::after {
animation: none !important;
display: none;
}
}
@@ -0,0 +1,629 @@
/**
* Общая доводка UI зоотех-страниц (consumption, components, feed_dispensers, reports).
* Подключать после page-inline <style>, до </head>.
*/
.page-content,
.zootech-app-body .wesp-page-main {
flex: 1 0 auto;
width: 100%;
}
/* Панели (отчёты): компактная шапка, скругление на контейнере */
.zootech-panel {
background: var(--surface, var(--bg-white));
border: 1px solid var(--border-light);
border-radius: 16px;
box-shadow: var(--shadow-soft);
margin-bottom: var(--zt-space-4, 1.5rem);
overflow: hidden;
padding: 0;
}
.zootech-panel > .card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0.875rem 0.375rem;
margin: 0;
min-height: 0;
line-height: 1.25;
font-size: 0.875rem;
font-weight: 600;
color: var(--text, var(--text-dark));
background: var(--surface, var(--bg-white)) !important;
border-bottom: 1px solid var(--border-light);
border-radius: 0;
}
.zootech-panel > .card-header i {
font-size: 0.8125rem;
color: var(--primary-color);
}
.zootech-panel > .card-header .btn-sm,
.zootech-panel > .card-header .btn {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
line-height: 1.2;
min-height: auto;
}
.zootech-panel > .card-body {
padding: 0.875rem 1rem 1rem;
}
/* Секции components / consumption / reports */
.zootech-app-body .form-section.zt-section,
.zootech-app-body .consumption-block.zt-section {
border-radius: 16px;
overflow: hidden;
padding: var(--zt-space-4, 1.5rem);
margin-bottom: var(--zt-space-4, 1.5rem);
}
/* /reports: выпадающий календарь не должен обрезаться карточкой */
.zootech-app-body.reports-page .form-section.zt-section.reports-controls {
overflow: visible;
}
.zootech-app-body .form-section .form-title,
.zootech-app-body .consumption-block .section-title {
font-size: var(--zt-font-section, 1.125rem);
font-weight: 600;
margin: 0 0 var(--zt-space-3, 1rem);
padding: 0;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.25;
}
.zootech-app-body .consumption-section {
margin: 1.25rem 0 0;
}
/* Карточки: без поднятия, подсветка как на dashboard рецептов */
.zootech-app-body .report-card,
.zootech-app-body .component-card,
.zootech-app-body .dispenser-card.list-item,
.zootech-app-body .zt-card,
.zootech-app-body .zt-section,
.zootech-app-body .list-item,
.zootech-app-body .role-card {
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.zootech-app-body .report-card:hover,
.zootech-app-body .component-card.zt-card--interactive:hover,
.zootech-app-body .component-card.component-card--clickable:hover,
.zootech-app-body .dispenser-card.list-item:hover,
.zootech-app-body .zt-card--interactive:hover,
.zootech-app-body .zt-card:hover,
.zootech-app-body .zt-section:hover,
.zootech-app-body .list-item:hover,
.zootech-app-body .role-card:hover {
transform: none !important;
}
.zootech-app-body .report-card:hover {
box-shadow: var(--shadow-soft);
background-color: rgba(var(--primary-rgb), 0.04);
border-color: rgba(var(--primary-rgb), 0.35);
}
html[data-theme="dark"] .zootech-app-body .report-card:hover {
background-color: rgba(var(--primary-rgb), 0.08);
border-color: rgba(var(--primary-rgb), 0.4);
box-shadow: none;
}
.zootech-app-body .component-card.zt-card--interactive:hover,
.zootech-app-body .component-card.component-card--clickable:hover {
box-shadow: var(--shadow-soft), 0 0 0 1px rgba(var(--primary-rgb, 33, 150, 243), 0.2);
border-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.04);
}
.zootech-app-body .dispenser-card.list-item:hover {
box-shadow: none !important;
border-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.05);
}
/* Кнопки: без translateY на hover */
.zootech-app-body .btn-secondary:hover,
.zootech-app-body .btn-danger:hover,
.zootech-app-body .btn-success:hover,
.zootech-app-body .magic-button:hover,
.zootech-app-body .modern-btn:hover,
.zootech-app-body .nav-link:hover {
transform: none !important;
}
.zootech-app-body .magic-button:hover,
.zootech-app-body .modern-btn.btn-primary-modern:hover {
filter: brightness(1.06);
}
/* Не дублировать page-enter и fade-in / content-reveal */
html.wesp-page-enter-pending .fade-in,
html.wesp-page-enter-ready .fade-in,
html.wesp-page-enter-pending .wesp-content-reveal,
html.wesp-page-enter-ready .wesp-content-reveal {
animation: none !important;
opacity: inherit;
transform: none !important;
}
html.wesp-page-enter-done .fade-in {
animation: zt-reveal 0.45s var(--wesp-enter-ease, ease) both;
}
@media (prefers-reduced-motion: reduce) {
html.wesp-page-enter-done .fade-in {
animation: none !important;
}
}
/* Loading на странице оборудования */
.zootech-app-body .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2, 12px);
min-height: 8rem;
padding: var(--zt-space-4, 24px);
color: var(--text-muted);
font-size: var(--zt-font-body, 0.9375rem);
}
.zootech-app-body .loading-state i {
color: var(--primary-color);
}
/* Dashed add-cards — единая min-height */
.zootech-app-body .add-devices-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--zt-space-3, 16px);
margin-bottom: 1rem;
}
@media (max-width: 992px) {
.zootech-app-body .add-devices-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 576px) {
.zootech-app-body .add-devices-container {
grid-template-columns: 1fr;
}
}
/* Analytics: итоги и сравнение на /reports */
.analytics-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.analytics-kpi-grid {
grid-template-columns: 1fr;
}
}
.analytics-kpi-card {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.125rem;
}
.analytics-kpi-card__title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.analytics-kpi-card__value {
font-size: 1.375rem;
font-weight: 700;
color: var(--text-dark);
}
.analytics-kpi-card__value--warn {
color: #dc3545;
}
.analytics-kpi-card__value--loss {
color: #dc3545;
font-weight: 700;
}
.analytics-kpi-card__value--risk {
color: #d97706;
font-weight: 700;
}
html[data-theme="dark"] .analytics-kpi-card__value--risk {
color: #fbbf24;
}
.analytics-kpi-card__hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.35rem;
}
.analytics-section-title {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.analytics-top-list {
display: grid;
grid-template-columns: minmax(8.5rem, 12rem) minmax(5rem, 1fr) minmax(7.5rem, max-content);
column-gap: 1rem;
row-gap: 0.55rem;
align-items: center;
}
.analytics-top-row {
display: contents;
}
.analytics-top-row__name {
font-size: 0.875rem;
line-height: 1.35;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analytics-top-row__bar {
height: 8px;
min-width: 0;
width: 100%;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.analytics-top-row__bar span {
display: block;
height: 100%;
background: var(--primary-color);
border-radius: 4px;
}
.analytics-pf-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.analytics-pf-card {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.125rem;
cursor: pointer;
}
.analytics-pf-card__head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.analytics-pf-section + .analytics-pf-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
}
.analytics-pf-section__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.analytics-pf-mixer-line {
margin: 0.75rem 0 0;
padding-top: 0.75rem;
border-top: 1px dashed var(--border-light);
font-size: 0.875rem;
color: var(--text-muted);
}
.analytics-pf-mixer-line strong {
color: var(--text-primary, inherit);
font-weight: 600;
}
.analytics-pf-mixer-line--negative strong {
color: #dc3545;
}
.analytics-pf-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.analytics-pf-legend__dot {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 2px;
margin-right: 0.25rem;
vertical-align: middle;
}
.analytics-pf-legend__dot--base { background: #94a3b8; }
.analytics-pf-legend__dot--plan { background: #3b82f6; }
.analytics-pf-legend__dot--actual { background: #10b981; }
.analytics-pf-bars {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.35rem;
}
.analytics-pf-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.analytics-pf-bar span {
display: block;
height: 8px;
border-radius: 4px;
min-width: 2px;
}
.analytics-pf-bar--base span { background: #94a3b8; }
.analytics-pf-bar--plan span { background: #3b82f6; }
.analytics-pf-bar--actual span { background: #10b981; }
.analytics-pf-bar em {
font-style: normal;
min-width: 3rem;
text-align: right;
color: var(--text-muted);
}
.analytics-top-row__value {
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
text-align: right;
justify-self: end;
}
.analytics-top-row__value--loss {
color: #dc3545;
}
.analytics-top-row__value--risk {
color: #d97706;
}
.analytics-top-row__bar--loss span {
background: #dc3545;
}
.analytics-top-row__bar--risk span {
background: #d97706;
}
.analytics-pf-comp--excluded {
opacity: 0.82;
}
.analytics-pf-comp--excluded .analytics-pf-comp__name {
color: #6b7280;
}
html[data-theme="dark"] .analytics-pf-comp--excluded .analytics-pf-comp__name {
color: #9ca3af;
}
.analytics-pf-comp__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.analytics-pf-fault {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 999px;
white-space: nowrap;
}
.analytics-pf-fault--execution {
color: #dc3545;
background: rgba(220, 53, 69, 0.1);
}
.analytics-pf-fault--zootech,
.analytics-pf-fault--excluded {
color: #4b5563;
background: rgba(107, 114, 128, 0.12);
}
.analytics-pf-fault--adjusted {
color: #2563eb;
background: rgba(37, 99, 235, 0.1);
}
html[data-theme="dark"] .analytics-pf-fault--zootech,
html[data-theme="dark"] .analytics-pf-fault--excluded {
color: #d1d5db;
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .analytics-pf-fault--adjusted {
color: #93c5fd;
background: rgba(147, 197, 253, 0.12);
}
.analytics-pf-note--execution {
color: #dc3545;
}
.analytics-pf-note--zootech {
color: #6b7280;
}
html[data-theme="dark"] .analytics-pf-note--zootech {
color: #d1d5db;
}
html[data-theme="dark"] .analytics-pf-note--execution {
color: #f87171;
}
.analytics-pf-notes {
margin: 0.25rem 0 0;
padding-left: 1.1rem;
font-size: 0.8125rem;
list-style: disc;
}
.stock-forecast-banner {
margin: 0 0 1rem;
padding: 0.65rem 0.875rem;
border-radius: 0.75rem;
border: 1px solid rgba(220, 53, 69, 0.35);
background: rgba(220, 53, 69, 0.08);
font-size: 0.875rem;
}
.info-item--warn .info-value {
color: #dc3545;
font-weight: 700;
}
.stock-card-explanation {
grid-column: 1 / -1;
margin-top: 0.25rem;
}
/* Оборудование: отступ от navbar как на /recipes (только margin-bottom у navbar) */
.zootech-app-body.feed-dispensers-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.components-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.reports-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.feed-dispensers-page .add-devices-container {
margin-bottom: 1rem;
}
/* Унифицированный чекбокс (фильтры /reports, настройки и др.) */
.zt-checkbox {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.zt-checkbox__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.zt-checkbox__box {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1.5px solid var(--border-light, #cbd5e1);
border-radius: 4px;
background: var(--surface, var(--bg-white, #fff));
color: #fff;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
pointer-events: none;
}
.zt-checkbox__box::after {
content: "";
display: block;
width: 0.28rem;
height: 0.5rem;
border: solid currentColor;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
margin-top: -0.12rem;
transition: transform 0.12s ease;
}
.zt-checkbox__input:checked + .zt-checkbox__box {
background: var(--primary-color, #2196f3);
border-color: var(--primary-color, #2196f3);
}
.zt-checkbox__input:checked + .zt-checkbox__box::after {
transform: rotate(45deg) scale(1);
}
.zt-checkbox__input:focus-visible + .zt-checkbox__box {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 33, 150, 243), 0.22);
}
.zt-checkbox__input:disabled + .zt-checkbox__box {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="dark"] .zt-checkbox__box {
background: var(--dm-field, #2c3338);
border-color: rgba(255, 255, 255, 0.22);
}
html[data-theme="dark"] .zt-checkbox__input:checked + .zt-checkbox__box {
background: var(--primary-color);
border-color: var(--primary-color);
}
@@ -0,0 +1,658 @@
/**
* Bootstrap shell-модалки + настройки (стиль WespDialog).
* Без Bootstrap .fade — анимация через .wesp-shell-visible / .wesp-shell-closing (JS).
*/
:root {
--wesp-modal-duration: 0.22s;
--wesp-modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
/* Backdrop (Bootstrap создаёт .modal-backdrop без fade, если у modal нет .fade) */
body.modal-open .modal-backdrop.show {
opacity: 0.55;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal.wesp-shell-modal {
--bs-modal-bg: transparent;
}
.modal.wesp-shell-modal.show {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--bs-modal-margin, 0.5rem);
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell {
max-width: 420px;
width: 100%;
margin: 0 auto;
min-height: 0 !important;
height: auto;
flex: 0 0 auto;
display: block;
pointer-events: none;
opacity: 0;
transform: scale(0.97) translateY(8px);
transition:
transform var(--wesp-modal-duration) var(--wesp-modal-ease),
opacity var(--wesp-modal-duration) var(--wesp-modal-ease);
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-wide {
max-width: min(520px, calc(100vw - 1rem));
}
.modal.wesp-shell-modal.wesp-shell-visible .modal-dialog.modal-dialog--shell {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.modal.wesp-shell-modal.wesp-shell-closing .modal-dialog.modal-dialog--shell {
opacity: 0;
transform: scale(0.97) translateY(8px);
pointer-events: none;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
pointer-events: auto;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
width: 100%;
max-width: 100%;
height: auto;
max-height: min(90vh, 560px);
overflow-x: hidden;
overflow-y: auto;
padding: 20px 22px;
font-family: var(--zootech-font, Inter, system-ui, sans-serif);
font-size: var(--zt-font-body, 0.9375rem);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-align: left;
background: #fff;
color: #111827;
border-radius: 8px;
border: 1px solid #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18) !important;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal--settings {
max-height: min(92vh, 720px);
}
.wesp-shell-bsmodal-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 12px;
}
.wesp-shell-bsmodal-title-row .wesp-shell-bsmodal-title {
margin: 0;
flex: 1;
min-width: 0;
}
.wesp-shell-bsmodal-close {
flex-shrink: 0;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: transparent;
color: #64748b;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease, color 0.15s ease;
}
.wesp-shell-bsmodal-close:hover {
background: rgba(15, 23, 42, 0.06);
color: #111827;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
line-height: 1.3;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-body {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: #374151;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-label {
color: #374151;
font-weight: 500;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn {
min-width: 120px;
padding: 10px 18px;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
border: 1px solid #e5e7eb;
background: #fff;
color: #374151;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, filter 0.2s;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn:hover {
filter: brightness(0.97);
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary {
background: var(--primary-color, #2196f3);
border-color: var(--primary-color, #2196f3);
color: #fff;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary:hover {
filter: none;
box-shadow: 0 4px 12px rgba(var(--primary-rgb, 33, 150, 243), 0.25);
}
/* Внутренние блоки модалки настроек */
.wesp-shell-bsmodal-body--settings .settings-user-card,
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-card,
.wesp-shell-bsmodal-body--settings .settings-sync-form,
.wesp-shell-bsmodal-body--settings .settings-theme-card {
background: var(--surface-muted, #f8fafc);
border-radius: 8px;
padding: 0.875rem 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e5e7eb);
}
.wesp-shell-bsmodal-body--settings .settings-theme-card {
min-width: 0;
}
.wesp-shell-bsmodal-body--settings .settings-card-title {
font-size: 0.9rem;
font-weight: 600;
margin: 0;
color: var(--text, #1e293b);
display: flex;
align-items: center;
gap: 0.5rem;
}
.wesp-shell-bsmodal-body--settings .settings-user-card--clickable,
.wesp-shell-bsmodal-body--settings .settings-sync-card--clickable {
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.wesp-shell-bsmodal-body--settings .settings-user-card--clickable:hover,
.wesp-shell-bsmodal-body--settings .settings-sync-card--clickable:hover {
border-color: var(--primary-color, #2196f3);
background: rgba(var(--primary-rgb, 33, 150, 243), 0.04);
}
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-form {
animation: wesp-settings-panel-in 0.22s var(--wesp-modal-ease) both;
}
@keyframes wesp-settings-panel-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 576px) {
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions {
flex-direction: column;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions .wesp-shell-bsmodal-btn {
width: 100%;
}
}
html[data-theme="dark"] .modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
background: var(--dm-surface, #2c3338) !important;
color: var(--dm-text, #f5f5f5) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-title,
html[data-theme="dark"] .wesp-shell-bsmodal-close:hover {
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .wesp-shell-bsmodal-close {
color: var(--dm-muted, #a8b0b8);
}
html[data-theme="dark"] .wesp-shell-bsmodal-close:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body,
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body p,
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body strong {
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-label {
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .text-muted {
color: var(--dm-muted, #a8b0b8) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-control {
background-color: var(--dm-field, #3e444a) !important;
color: var(--dm-text, #f5f5f5) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
}
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-user-card,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-credentials-form,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-sync-card,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-sync-form,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-theme-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-card-title {
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-btn {
background: var(--dm-field, #3e444a) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #fff !important;
}
/* Появление карточек сетки */
@keyframes wesp-grid-item-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
.components-page .component-card.wesp-grid-enter-item,
.reports-page .report-card.wesp-grid-enter-item {
animation: wesp-grid-item-enter var(--wesp-enter-duration, 350ms) var(--wesp-enter-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
animation-delay: calc(var(--wesp-grid-i, 0) * 45ms);
}
html.wesp-page-enter-pending .feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
html.wesp-page-enter-pending .components-page .component-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .components-page .component-card.wesp-grid-enter-item,
html.wesp-page-enter-pending .reports-page .report-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .reports-page .report-card.wesp-grid-enter-item {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.feed-dispensers-page .empty-state.wesp-content-reveal,
.feed-dispensers-page .loading-state.wesp-content-reveal,
.components-page .empty-state.wesp-content-reveal,
.components-page .loading-state.wesp-content-reveal,
.reports-page .empty-state.wesp-content-reveal,
.reports-page .loading-state.wesp-content-reveal {
animation: wesp-enter-fade var(--wesp-enter-duration-fast, 350ms) var(--wesp-enter-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
}
@media (prefers-reduced-motion: reduce) {
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell {
transition: opacity 0.12s ease !important;
transform: none !important;
}
body.modal-open .modal-backdrop.show {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
.components-page .component-card.wesp-grid-enter-item,
.feed-dispensers-page .empty-state.wesp-content-reveal,
.feed-dispensers-page .loading-state.wesp-content-reveal,
.components-page .empty-state.wesp-content-reveal,
.components-page .loading-state.wesp-content-reveal,
.reports-page .report-card.wesp-grid-enter-item,
.reports-page .empty-state.wesp-content-reveal,
.reports-page .loading-state.wesp-content-reveal,
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-form {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/* —— Notification center modal —— */
.zt-notify-center__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.zt-notify-center__title {
margin: 0;
}
.zt-notify-center__read-all {
border: none;
background: transparent;
color: var(--primary-color, #2196f3);
font-size: 0.8125rem;
font-weight: 600;
padding: 0.25rem 0.35rem;
cursor: pointer;
}
.zt-notify-center__body {
min-height: 200px;
max-height: min(52vh, 380px);
overflow: auto;
}
.zt-notify-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.zt-notify-toolbar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.zt-notify-toolbar__filters {
margin-bottom: 0;
flex: 1;
min-width: 0;
}
.zt-notify-toolbar__filters .zt-tab {
padding: 0.35rem 0.55rem;
font-size: 0.75rem;
min-height: auto;
}
.zt-notify-toolbar__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
flex-wrap: wrap;
}
.zt-notify-toolbar__unread {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
margin: 0;
cursor: pointer;
user-select: none;
}
.zt-notify-toolbar__unread input {
margin: 0;
}
.zt-notify-load-more {
padding: 0.5rem 0 0.25rem;
text-align: center;
}
.zt-notify-load-more__hint {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
}
.zt-notify-load-more__spinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted, #64748b);
}
.zt-notify-empty--loading .zt-spinner {
margin-bottom: 0.5rem;
}
.zt-notify-toolbar__sort {
flex-shrink: 0;
margin: 0;
}
.zt-notify-toolbar__sort select {
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 8px;
background: var(--bg-light, #f8fafc);
color: var(--text-dark, #1e293b);
font-size: 0.75rem;
font-weight: 500;
padding: 0.35rem 1.75rem 0.35rem 0.55rem;
cursor: pointer;
max-width: 11rem;
}
.zt-notify-group {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #64748b);
padding: 0.35rem 0.15rem 0.15rem;
}
.zt-notify-group:not(:first-child) {
margin-top: 0.35rem;
}
.zt-notify-item__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.zt-notify-item__category {
font-size: 0.6875rem;
color: var(--text-muted, #64748b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zt-notify-empty {
text-align: center;
color: var(--text-muted, #64748b);
padding: 2.5rem 1rem;
}
.zt-notify-empty i {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.45;
}
.zt-notify-item {
display: flex;
align-items: flex-start;
gap: 0.65rem;
width: 100%;
text-align: left;
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 10px;
background: var(--bg-light, #f8fafc);
padding: 0.65rem 0.75rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.zt-notify-item:hover {
background: #fff;
border-color: rgba(var(--primary-rgb), 0.25);
}
.zt-notify-item--unread {
background: #fff;
border-color: rgba(var(--primary-rgb), 0.35);
}
.zt-notify-item__dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-top: 0.45rem;
background: var(--info-color, #0ea5e9);
}
.zt-notify-item__dot--success {
background: var(--success-color, #10b981);
}
.zt-notify-item__dot--error {
background: var(--danger-color, #ef4444);
}
.zt-notify-item__dot--warning {
background: var(--warning-color, #f59e0b);
}
.zt-notify-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.zt-notify-item__title {
font-size: 0.9375rem;
color: var(--text-dark, #1e293b);
line-height: 1.35;
}
.zt-notify-item--unread .zt-notify-item__title {
font-weight: 600;
}
.zt-notify-item__preview {
font-size: 0.8125rem;
line-height: 1.45;
color: var(--text-muted, #64748b);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.zt-notify-item__time {
font-size: 0.75rem;
color: var(--text-muted, #64748b);
flex-shrink: 0;
}
.zt-notify-detail__back {
border: none;
background: transparent;
color: var(--primary-color, #2196f3);
font-size: 0.875rem;
padding: 0;
margin-bottom: 0.75rem;
cursor: pointer;
}
.zt-notify-detail__kind {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.zt-notify-detail__text {
margin: 0 0 0.75rem;
line-height: 1.55;
color: var(--text-dark, #1e293b);
}
.zt-notify-detail__when {
margin: 0;
font-size: 0.8125rem;
color: var(--text-muted, #64748b);
}
html[data-theme="dark"] .zt-notify-item {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] .zt-notify-item--unread,
html[data-theme="dark"] .zt-notify-item:hover {
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .zt-notify-toolbar__sort select {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
@@ -0,0 +1,644 @@
/**
* Общая оболочка зоотех-страниц: тёмная палитра как у страницы входа,
* футер, модалка настроек, базовые тёмные стили Bootstrap.
* Подключать после bootstrap.min.css.
* Токены и навбар — wesp-zootech-layout.css (подключать после этого файла).
*/
/* Светлая тема: единый фон страницы */
body {
background-color: var(--bg-page, #f5f7fa);
}
/* Клик по логотипу в шапке → страница рецептов */
.nav-brand .nav-logo-link,
.mobile-brand .nav-logo-link {
display: flex;
align-items: center;
line-height: 0;
text-decoration: none;
color: inherit;
}
html[data-theme="dark"] {
--wesp-accent: var(--primary-color, #2196f3);
--dm-bg: var(--bg-page, #12181b);
--dm-surface: var(--surface, #2c3338);
--dm-field: var(--field, #3e444a);
--dm-field-disabled: #353a40;
--dm-field-hover: #4a525a;
--dm-text: var(--text, #f5f5f5);
--dm-muted: var(--muted, #a8b0b8);
--dm-border: var(--border-light, rgba(255, 255, 255, 0.12));
--dm-border-hover: rgba(255, 255, 255, 0.25);
--locked-field-bg: var(--dm-field-disabled);
--locked-field-border: rgba(255, 255, 255, 0.22);
--locked-field-text: var(--dm-text);
--locked-field-stripe: rgba(255, 255, 255, 0.07);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
html[data-theme="dark"] body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .app-footer {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .app-footer .text-muted {
color: var(--dm-muted) !important;
}
/* Bootstrap-модалки и формы в тёмной теме (не shell/settings — у них свои поверхности) */
html[data-theme="dark"] body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-content,
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-content {
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .modal-header,
html[data-theme="dark"] .modal-footer {
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .modal-footer {
background-color: var(--dm-surface) !important;
}
html[data-theme="dark"] .form-control,
html[data-theme="dark"] input:not([readonly]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea {
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .form-control[readonly] {
background-color: var(--dm-field-disabled) !important;
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .text-muted,
html[data-theme="dark"] .modal-body label {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] ::placeholder {
color: rgba(245, 245, 245, 0.65) !important;
}
html[data-theme="dark"] .btn {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .btn-secondary:hover {
background-color: var(--dm-field-hover) !important;
border-color: var(--dm-border-hover) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary:hover {
background-color: var(--dm-field-hover) !important;
border-color: var(--dm-border-hover) !important;
}
html[data-theme="dark"] .btn-primary,
html[data-theme="dark"] .modal-footer .btn-primary {
background: linear-gradient(
135deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
) !important;
border: none !important;
color: white !important;
}
html[data-theme="dark"] .btn-primary:hover,
html[data-theme="dark"] .modal-footer .btn-primary:hover {
background: linear-gradient(
135deg,
var(--primary-dark, #1976d2),
var(--primary-darker, #1565c0)
) !important;
}
/* Модалка настроек */
.settings-modal {
display: none;
position: fixed;
z-index: 3000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.settings-modal-content {
background-color: #fff;
margin: 3vh auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 520px;
max-height: min(92vh, 720px);
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.16), 0 0 0 1px rgba(17, 24, 39, 0.06);
overflow: hidden;
font-family: var(--zootech-font);
font-size: var(--zt-font-body, 0.9375rem);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
}
.settings-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light, #e2e8f0);
background: var(--surface, #ffffff);
flex-shrink: 0;
}
.settings-modal-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--text, #1e293b);
}
.settings-modal-header h3 i {
margin-right: 0.5rem;
color: var(--primary-color, #2196f3);
}
.settings-close {
cursor: pointer;
font-size: 1.8rem;
line-height: 1;
opacity: 0.7;
user-select: none;
color: var(--text, #1e293b);
}
.settings-close:hover {
opacity: 1;
}
.settings-modal-body {
padding: 1rem 1.25rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.settings-field-hint {
margin: 0 0 0.75rem;
line-height: 1.45;
}
.settings-modal-body .form-group {
margin-bottom: 0.75rem;
}
.settings-user-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e2e8f0);
}
.settings-user-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-user-card--clickable:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-user-card--clickable .settings-card-title {
margin: 0;
justify-content: space-between;
}
.settings-chevron {
transition: transform 0.3s ease;
font-size: 0.8rem;
color: var(--muted, #64748b);
}
.settings-user-card--clickable.open .settings-chevron {
transform: rotate(180deg);
}
.settings-credentials-form {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
animation: wespSlideDown 0.3s ease;
}
@keyframes wespSlideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--text, #1e293b);
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-card-title i {
color: var(--primary-color, #2196f3);
}
.settings-theme-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
min-width: 380px;
}
.settings-theme-card .settings-card-title {
margin: 0 0 0.75rem 0;
justify-content: center;
position: relative;
}
.settings-theme-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.theme-toggle-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.theme-label {
font-size: 0.9rem;
color: var(--muted, #64748b);
}
.theme-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
flex-shrink: 0;
}
.theme-switch input {
opacity: 0;
width: 0;
height: 0;
}
.theme-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #e2e8f0;
border-radius: 26px;
transition: 0.3s;
}
.theme-slider::before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.theme-switch input:checked + .theme-slider {
background: var(--primary-color, #2196f3);
}
.theme-switch input:checked + .theme-slider::before {
transform: translateX(22px);
}
.settings-contrast-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light, #e2e8f0);
}
.settings-contrast-row__label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text, #1e293b);
}
.settings-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-light, #e2e8f0);
flex-shrink: 0;
background: var(--surface, #ffffff);
}
.settings-sync-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e2e8f0);
}
.settings-sync-card.settings-sync-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-sync-card.settings-sync-card--clickable:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-sync-card.settings-sync-card--clickable.open .settings-chevron {
transform: rotate(180deg);
}
.settings-sync-card .settings-card-title {
margin: 0;
justify-content: center;
position: relative;
}
.settings-sync-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.settings-sync-card .settings-card-title .settings-chevron {
position: absolute;
right: 0;
}
.settings-sync-form {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
animation: wespSlideDown 0.3s ease;
}
.settings-sync-client-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-light, #e2e8f0);
}
.settings-sync-client-item:last-child {
border-bottom: none;
}
.settings-sync-client-name {
font-weight: 500;
color: var(--text, #1e293b);
}
.settings-sync-client-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.settings-sync-client-actions .btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.settings-sync-edit-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.settings-sync-edit-row select {
min-width: 140px;
}
/* Тёмная тема: модалка настроек и карточки */
html[data-theme="dark"] .settings-user-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--dm-border-hover);
}
html[data-theme="dark"] .settings-credentials-form {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-modal-content {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-header {
background: var(--dm-field) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .settings-modal-header h3 {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-header h3 i {
color: var(--primary-color, #2196f3) !important;
}
html[data-theme="dark"] .settings-card-title i {
color: var(--primary-color, #2196f3);
}
html[data-theme="dark"] .settings-close {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-sync-client-name {
color: #fff !important;
}
html[data-theme="dark"] .settings-sync-card,
html[data-theme="dark"] .settings-sync-form {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-sync-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
html[data-theme="dark"] .settings-user-card,
html[data-theme="dark"] .settings-theme-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-card-title {
color: var(--dm-text);
}
html[data-theme="dark"] .settings-modal-body label,
html[data-theme="dark"] .settings-modal-body .form-label,
html[data-theme="dark"] .settings-credentials-form label,
html[data-theme="dark"] .settings-sync-form label {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-body .text-muted,
html[data-theme="dark"] .settings-chevron {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .theme-label {
color: var(--dm-muted);
}
html[data-theme="dark"] .theme-switch input:checked + .theme-slider {
background: var(--primary-color, #2196f3) !important;
}
html[data-theme="dark"] .settings-modal-footer {
border-color: var(--dm-border);
background: var(--dm-field);
}
html[data-theme="dark"] .settings-modal-footer .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-footer .btn-primary {
background: linear-gradient(
135deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
) !important;
border: none !important;
color: #fff !important;
}
html[data-theme="dark"] .settings-modal-footer .btn-primary:hover {
background: linear-gradient(
135deg,
var(--primary-dark, #1976d2),
var(--primary-darker, #1565c0)
) !important;
filter: brightness(1.05);
}
/* Верхняя панель и мобильное меню: в тёмной теме без яркого синего (как фон/карточки) */
html[data-theme="dark"] .mobile-navbar {
background: linear-gradient(180deg, #2c3338 0%, #1e2529 100%) !important;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
html[data-theme="dark"] .mobile-menu {
background: linear-gradient(180deg, #2c3338 0%, #252b30 100%) !important;
box-shadow: -4px 0 28px rgba(0, 0, 0, 0.55) !important;
}
#wesp-recipes-boot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 900;
background: #f5f7fa;
pointer-events: none;
opacity: 1;
transition: opacity 0.28s ease;
}
html[data-theme="dark"] #wesp-recipes-boot {
background: #12181b;
}
#wesp-recipes-boot.wesp-recipes-boot--hide {
opacity: 0;
}
@@ -0,0 +1,335 @@
/**
* Organic Tech / Earthy Digital — третья тема зоотехника.
* Подключать ПОСЛЕДНИМ в <head> (после page-inline стилей).
*/
/* —— Theme picker (3-way) —— */
.theme-picker {
display: flex;
align-items: stretch;
gap: 0.35rem;
flex-wrap: wrap;
justify-content: center;
}
.theme-picker__btn {
flex: 1 1 auto;
min-width: 5.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 40px;
background: var(--surface);
color: var(--text);
font-family: var(--zootech-font);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
}
.theme-picker__btn:hover {
border-color: var(--primary-color);
}
.theme-picker__btn.is-active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.22);
}
html[data-theme="dark"] .theme-picker__btn.is-active {
color: #fff;
}
/* —— Navbar glassmorphism —— */
html[data-theme="organic"] .mobile-navbar {
background: rgba(246, 246, 244, 0.9) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
will-change: backdrop-filter;
}
@media (max-width: 768px) {
html[data-theme="organic"] .mobile-navbar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* —— Logo (дублируем с !important поверх page-inline) —— */
html[data-theme="organic"] .nav-brand .logo.logo--light-theme,
html[data-theme="organic"] .mobile-brand .logo.logo--light-theme {
filter: var(--organic-logo-filter) !important;
}
html[data-theme="organic"] .nav-logo-link::after {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 30%,
rgba(72, 129, 109, 0.35) 50%,
rgba(255, 255, 255, 0) 70%,
rgba(255, 255, 255, 0) 100%
);
}
/* —— Cards & sections —— */
html[data-theme="organic"] .zt-card:hover,
html[data-theme="organic"] .zt-section:hover {
box-shadow: var(--box-shadow-hover);
}
html[data-theme="organic"] .zt-card,
html[data-theme="organic"] .zt-section {
transition: box-shadow 0.2s ease, border-color var(--transition);
}
/* —— Monospace accents —— */
html[data-theme="organic"] .zt-mono,
html[data-theme="organic"] code,
html[data-theme="organic"] .lab-sandbox-step span,
html[data-theme="organic"] .norm-calc-badge {
font-family: var(--zootech-font-mono);
}
/* —— Primary buttons (pill) —— */
html[data-theme="organic"] .zootech-app-body .btn-primary,
html[data-theme="organic"] .btn-primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
border-radius: 40px;
}
html[data-theme="organic"] .zootech-app-body .btn-primary:hover:not(:disabled),
html[data-theme="organic"] .btn-primary:hover:not(:disabled) {
background-color: var(--primary-dark) !important;
border-color: var(--primary-dark) !important;
}
html[data-theme="organic"] .btn-outline-primary,
html[data-theme="organic"] .btn-outline-secondary {
border-radius: 6px;
}
/* —— Settings cards —— */
html[data-theme="organic"] .settings-theme-card,
html[data-theme="organic"] .settings-user-card,
html[data-theme="organic"] .settings-sync-card {
background: var(--surface-muted);
border-color: var(--border-light);
}
html[data-theme="organic"] .settings-card-title {
color: var(--text);
}
html[data-theme="organic"] .settings-user-card--clickable:hover,
html[data-theme="organic"] .settings-sync-card--clickable:hover,
html[data-theme="organic"] .wesp-shell-bsmodal-body--settings .settings-user-card--clickable:hover,
html[data-theme="organic"] .wesp-shell-bsmodal-body--settings .settings-sync-card--clickable:hover {
border-color: var(--primary-color);
background: rgba(var(--primary-rgb), 0.06);
}
/* —— Login —— */
html[data-theme="organic"] body.zootech-login-body {
background-color: var(--z-login-bg, #f6f6f4);
}
/* —— Bootstrap tokens & ссылки —— */
html[data-theme="organic"] {
--bs-primary: #48816d;
--bs-primary-rgb: 72, 129, 109;
--bs-link-color: #48816d;
--bs-link-hover-color: #3a6b58;
--bs-success: #48816d;
--bs-success-rgb: 72, 129, 109;
}
html[data-theme="organic"] .text-primary,
html[data-theme="organic"] .text-primary i,
html[data-theme="organic"] i.text-primary {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .badge.bg-primary {
background-color: var(--primary-color) !important;
color: #fff !important;
}
html[data-theme="organic"] .btn-outline-primary {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="organic"] .btn-outline-primary:hover:not(:disabled) {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #fff !important;
}
html[data-theme="organic"] .zootech-app-body .btn-outline-success,
html[data-theme="organic"] .paste-recipe-btn.btn-outline-success {
color: var(--primary-color) !important;
border-color: rgba(var(--primary-rgb), 0.45) !important;
}
html[data-theme="organic"] .zootech-app-body .btn-outline-success:hover:not(:disabled),
html[data-theme="organic"] .paste-recipe-btn.btn-outline-success:hover:not(:disabled) {
color: var(--primary-darker) !important;
background: rgba(var(--primary-rgb), 0.1) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="organic"] .btn-link {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
html[data-theme="organic"] .form-control:focus,
html[data-theme="organic"] .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.2);
}
/* —— Дашборд рецептов: иконки в строках (календарь и др.) —— */
html[data-theme="organic"] .dashboard-stat-card > .card-header i,
html[data-theme="organic"] .dashboard-stat-card > .card-header .route-icon,
html[data-theme="organic"] .dashboard-summary-row .list-item-title i,
html[data-theme="organic"] .dashboard-summary-row .list-item-title i.fas,
html[data-theme="organic"] .dashboard-summary-row .list-item-leading i {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .dashboard-summary-row .list-container .list-item.active {
border-color: var(--primary-color) !important;
background-color: rgba(var(--primary-rgb), 0.08) !important;
}
html[data-theme="organic"] .dashboard-summary-row .list-container .list-item:hover {
border-color: var(--primary-color) !important;
background-color: rgba(var(--primary-rgb), 0.05) !important;
}
/* —— Иконки по всему UI —— */
html[data-theme="organic"] .mobile-navbar i.fas:not(.fa-sign-out-alt),
html[data-theme="organic"] .mobile-navbar i.far,
html[data-theme="organic"] .zt-section__title > i,
html[data-theme="organic"] .zt-empty__icon,
html[data-theme="organic"] .page-title > i,
html[data-theme="organic"] .page-header i.fas,
html[data-theme="organic"] .settings-card-title > i:first-child,
html[data-theme="organic"] .lab-sandbox-header .text-primary,
html[data-theme="organic"] .lab-sandbox-header i.fas,
html[data-theme="organic"] .recipe-table-move-group .btn i,
html[data-theme="organic"] .recipe-drag-handle i,
html[data-theme="organic"] .zt-k-hub-card__icon,
html[data-theme="organic"] .zt-k-hub-card__icon i,
html[data-theme="organic"] .info-label i.fas,
html[data-theme="organic"] .dispenser-card .list-item-title i,
html[data-theme="organic"] .alert-info i.fas {
color: var(--primary-color) !important;
}
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn {
color: var(--primary-color) !important;
}
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn:hover,
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn:focus {
color: var(--primary-dark) !important;
background: rgba(var(--primary-rgb), 0.14) !important;
}
html[data-theme="organic"] .z-login-field-icon svg {
stroke: var(--primary-color);
}
html[data-theme="organic"] .zootech-app-body .magic-button,
html[data-theme="organic"] .zootech-app-body .btn-primary-modern {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important;
}
html[data-theme="organic"] .zt-spinner {
border-top-color: var(--primary-color) !important;
}
@media (prefers-contrast: more) {
html:not([data-high-contrast="false"])[data-theme="organic"] .mobile-navbar,
html[data-high-contrast="true"][data-theme="organic"] .mobile-navbar {
background: var(--bg-white) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border-bottom: 1px solid var(--border-light);
}
html:not([data-high-contrast="false"])[data-theme="organic"] .zt-card,
html:not([data-high-contrast="false"])[data-theme="organic"] .zt-section,
html[data-high-contrast="true"][data-theme="organic"] .zt-card,
html[data-high-contrast="true"][data-theme="organic"] .zt-section {
border-color: var(--border-light);
box-shadow: none;
}
html:not([data-high-contrast="false"]) .form-control:focus,
html:not([data-high-contrast="false"]) .form-select:focus,
html[data-high-contrast="true"] .form-control:focus,
html[data-high-contrast="true"] .form-select:focus {
box-shadow: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--primary-color);
}
html:not([data-high-contrast="false"])[data-theme="organic"] .btn-outline-primary,
html:not([data-high-contrast="false"])[data-theme="organic"] .zootech-app-body .btn-outline-success,
html:not([data-high-contrast="false"])[data-theme="organic"] .paste-recipe-btn.btn-outline-success,
html[data-high-contrast="true"][data-theme="organic"] .btn-outline-primary,
html[data-high-contrast="true"][data-theme="organic"] .zootech-app-body .btn-outline-success,
html[data-high-contrast="true"][data-theme="organic"] .paste-recipe-btn.btn-outline-success {
border-width: 2px;
}
html:not([data-high-contrast="false"])[data-theme="organic"] .dashboard-summary-row .list-container .list-item.active,
html[data-high-contrast="true"] .dashboard-summary-row .list-container .list-item.active {
border-left-width: 3px;
border-left-style: solid;
border-left-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.12) !important;
}
}
html[data-high-contrast="true"][data-theme="organic"] .mobile-navbar {
background: var(--bg-white) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border-bottom: 1px solid var(--border-light);
}
html[data-high-contrast="true"] .zt-card,
html[data-high-contrast="true"] .zt-section {
border-color: var(--border-light);
box-shadow: none;
}
html[data-high-contrast="true"] .form-control:focus,
html[data-high-contrast="true"] .form-select:focus {
box-shadow: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--primary-color);
}
html[data-high-contrast="true"] .btn-outline-primary,
html[data-high-contrast="true"] .zootech-app-body .btn-outline-success,
html[data-high-contrast="true"] .paste-recipe-btn.btn-outline-success {
border-width: 2px;
}
html[data-high-contrast="true"] .dashboard-summary-row .list-container .list-item.active {
border-left-width: 3px;
border-left-style: solid;
border-left-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.12) !important;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
export class AppState {
constructor(initialState = {}) {
this._state = { ...initialState };
this._listeners = new Set();
}
get(key) {
return this._state[key];
}
getAll() {
return { ...this._state };
}
set(patch) {
this._state = { ...this._state, ...patch };
for (const listener of this._listeners) {
listener(this.getAll());
}
}
subscribe(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
}
@@ -0,0 +1,27 @@
export class CrossTabSync {
constructor(channelName) {
this._channel = null;
if (typeof BroadcastChannel !== "undefined") {
this._channel = new BroadcastChannel(channelName);
}
}
post(type, payload = {}) {
if (!this._channel) return;
this._channel.postMessage({ type, ...payload });
}
onMessage(handler) {
if (!this._channel) return () => {};
const wrapped = (event) => handler(event.data || {});
this._channel.addEventListener("message", wrapped);
return () => this._channel.removeEventListener("message", wrapped);
}
close() {
if (this._channel) {
this._channel.close();
this._channel = null;
}
}
}
@@ -0,0 +1,185 @@
/**
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatKg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const rounded = Math.round(n * 100) / 100;
return String(rounded).replace(/\.?0+$/, "");
}
function renderComponentPickList(items, emptyText) {
if (!items.length) {
return `<p class="zt-k-hub-plan-replace__empty">${escapeHtml(emptyText)}</p>`;
}
return items
.map(
(item) =>
`<button type="button" class="zt-k-hub-plan-replace__pick" data-pick-component-id="${escapeHtml(item.id)}" ` +
`data-pick-component-name="${escapeHtml(item.name)}">` +
`<span class="zt-k-hub-plan-replace__pick-name">${escapeHtml(item.name)}</span>` +
`<span class="zt-k-hub-plan-replace__pick-meta">` +
`${escapeHtml(item.type || "—")} · СВ ${formatKg(item.dryMatter)}%</span></button>`
)
.join("");
}
async function fetchComponentAlternatives(componentId, query) {
const params = new URLSearchParams({
component_id: componentId,
q: query || "",
limit: "100",
});
const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось загрузить компоненты");
}
return resp.json();
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.componentId
* @param {string} options.label
* @param {HTMLElement} [options.overlayHost]
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
* @param {string} [options.subtitle]
* @param {(replacementComponentId: string, replacementName: string) => void|Promise<void>} options.onPick
* @param {(message: string) => void} [options.onError]
*/
async function openReplaceComponentModal(options) {
const {
componentId,
label,
overlayHost = document.body,
keepOverlays = [],
subtitle = "",
onPick,
onError = () => {},
} = options || {};
if (!componentId || !onPick) return;
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace";
const subtitleHtml = subtitle
? `<p class="zt-k-hub-plan-replace__lead">${escapeHtml(subtitle)}</p>`
: "";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-replace__dialog" role="dialog" aria-modal="true">' +
`<h3 class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-replace__title">Заменить «${escapeHtml(label)}»</h3>` +
subtitleHtml +
'<label class="zt-k-hub-plan-replace__search-label">Поиск по всем компонентам</label>' +
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-replace-search placeholder="Начните вводить название…" autocomplete="off">' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Похожие по параметрам</p>' +
'<div data-replace-similar class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Все компоненты</p>' +
'<div data-replace-results class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-replace__actions">' +
'<button type="button" class="btn btn-secondary" data-replace-cancel>Отмена</button></div></div>';
dismissOverlays(overlayHost, keepOverlays);
overlay.setAttribute("data-daily-plan-overlay", "1");
overlayHost.appendChild(overlay);
const similarEl = overlay.querySelector("[data-replace-similar]");
const resultsEl = overlay.querySelector("[data-replace-results]");
const searchEl = overlay.querySelector("[data-replace-search]");
let catalog = null;
const close = () => {
if (overlay.isConnected) overlay.remove();
};
function filterCatalog(query) {
const q = query.trim().toLowerCase();
if (!q) {
return {
similar: catalog?.similar || [],
items: catalog?.results || [],
q: "",
};
}
const items = (catalog?.results || []).filter((item) =>
String(item.name || "").toLowerCase().includes(q)
);
const similar = items.filter((item) => item.similar);
return { similar, items, q };
}
function paintLists(query = "") {
if (!overlay.isConnected) return;
const { similar, items, q } = filterCatalog(query);
if (similarEl) {
similarEl.innerHTML = renderComponentPickList(
similar,
q ? "Нет похожих по запросу" : "Нет похожих компонентов"
);
}
if (resultsEl) {
resultsEl.innerHTML = renderComponentPickList(
items,
q ? "Ничего не найдено" : "Нет доступных компонентов"
);
}
}
async function loadCatalog() {
try {
catalog = await fetchComponentAlternatives(componentId, "");
paintLists(searchEl?.value.trim() || "");
} catch (err) {
if (!overlay.isConnected) return;
onError(err.message || "Не удалось загрузить компоненты");
}
}
overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close();
});
searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim()));
overlay.addEventListener("click", async (event) => {
const pick = event.target.closest("[data-pick-component-id]");
if (!pick) return;
event.preventDefault();
const replacementComponentId = pick.dataset.pickComponentId;
const replacementName = pick.dataset.pickComponentName || "Компонент";
if (!replacementComponentId) return;
close();
try {
await onPick(replacementComponentId, replacementName);
} catch (err) {
onError(err.message || "Не удалось заменить компонент");
}
});
await loadCatalog();
searchEl?.focus();
}
global.WespDailyPlanReplaceModal = {
open: openReplaceComponentModal,
fetchComponentAlternatives,
};
})(window);
@@ -0,0 +1,124 @@
/**
* Диалог «На какой срок» для skip/replace/adjustment плана на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function weekEndIso(planDate) {
const parts = String(planDate || "").split("-");
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
const jsDay = d.getDay();
const pyWeekday = jsDay === 0 ? 6 : jsDay - 1;
d.setDate(d.getDate() + (6 - pyWeekday));
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.planDate YYYY-MM-DD
* @param {string} [options.title]
* @param {HTMLElement} [options.overlayHost] куда монтировать overlay
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
* @param {function} [options.onError]
* @param {function} [options.beforeAppend] (overlay) => void dismiss other overlays
*/
function promptSkipDuration(options) {
const planDate = options.planDate || todayIso();
const title = options.title || "На какой срок исключить?";
const onError = options.onError || function () {};
const host = options.overlayHost || document.body;
const keepOverlays = options.keepOverlays || [];
return new Promise((resolve) => {
const weekEnd = weekEndIso(planDate);
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-skip-duration";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-skip-duration__dialog" role="dialog" aria-modal="true">' +
`<p class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-skip-duration__title">${escapeHtml(title)}</p>` +
'<div class="zt-k-hub-plan-skip-duration__options">' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
'<input type="radio" name="skip-duration" value="today" checked> Только сегодня</label>' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
`<input type="radio" name="skip-duration" value="week"> До конца недели (до ${escapeHtml(weekEnd)})</label>` +
'<label class="zt-k-hub-plan-skip-duration__option zt-k-hub-plan-skip-duration__option--date">' +
'<input type="radio" name="skip-duration" value="date"> До даты ' +
`<input type="date" class="form-control" data-skip-until-date min="${escapeHtml(planDate)}" value="${escapeHtml(planDate)}">` +
"</label></div>" +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-skip-duration__actions">' +
'<button type="button" class="btn btn-secondary" data-skip-duration-cancel>Отмена</button>' +
'<button type="button" class="btn btn-primary" data-skip-duration-confirm>Применить</button>' +
"</div></div>";
if (typeof options.beforeAppend === "function") {
options.beforeAppend(overlay);
} else if (keepOverlays.length) {
dismissOverlays(host, keepOverlays);
}
overlay.setAttribute("data-daily-plan-overlay", "1");
host.appendChild(overlay);
const dateInput = overlay.querySelector("[data-skip-until-date]");
const finish = (value) => {
if (overlay.isConnected) overlay.remove();
resolve(value);
};
overlay.addEventListener("click", (event) => {
if (event.target === overlay) finish(null);
});
overlay.querySelector("[data-skip-duration-cancel]")?.addEventListener("click", () => finish(null));
overlay.querySelector("[data-skip-duration-confirm]")?.addEventListener("click", () => {
const selected = overlay.querySelector('input[name="skip-duration"]:checked');
const mode = selected?.value || "today";
if (mode === "week") {
finish({ duration: "week" });
return;
}
if (mode === "date") {
const untilDate = dateInput?.value || planDate;
if (untilDate < planDate) {
onError("Дата окончания не может быть раньше начала");
return;
}
finish({ duration: "date", untilDate });
return;
}
finish({ duration: "today" });
});
});
}
function skipDurationBody(planDate, extra, title, options) {
return promptSkipDuration({ planDate, title, ...options }).then((choice) => {
if (!choice) return null;
return { date: planDate, ...choice, ...(extra || {}) };
});
}
global.WespDailyPlanSkipDuration = {
weekEndIso,
todayIso,
promptSkipDuration,
skipDurationBody,
escapeHtml,
};
})(window);
@@ -0,0 +1,209 @@
const KIOSK_SKELETON_MIN_MS = 300;
const KIOSK_SKELETON_EXIT_MS = 160;
const GRID_CARD_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-title"></div>
<div class="kiosk-skeleton-info">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
</div>`;
const GROUP_ITEM_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>`;
export function buildGridSkeletonCards(rowCount = 4) {
return Array.from({ length: rowCount }, () =>
`<div class="dispenser-card kiosk-skeleton-card" aria-hidden="true">${GRID_CARD_INNER}</div>`
).join("");
}
export function buildDispenserGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) {
const items = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-group-item">${GROUP_ITEM_INNER}</div>`
).join("");
return `<div class="kiosk-skeleton-groups" data-skeleton-variant="groups" aria-busy="true" aria-label="Загрузка групп выгрузки">${items}</div>`;
}
export function buildDuplicatePanelSkeletonHtml() {
return `<div class="kiosk-skeleton-panel" data-skeleton-variant="duplicate" aria-busy="true" aria-label="Загрузка данных">
<div class="kiosk-skeleton-recipe-header">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle"></div>
</div>
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) {
const rows = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-table-row">
<div class="dashboard-skeleton-bar"></div>
<div class="dashboard-skeleton-bar"></div>
</div>`
).join("");
return `<div class="kiosk-skeleton-table" data-skeleton-variant="ingredients" aria-busy="true" aria-label="Загрузка ингредиентов">${rows}</div>`;
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
function isGridSkeletonHost(container) {
if (!container) return false;
return (
container.getAttribute("aria-busy") === "true" &&
container.querySelector(":scope > .kiosk-skeleton-card") != null
);
}
function findSkeletonRoot(container) {
if (!container) return null;
if (isGridSkeletonHost(container)) return container;
return container.querySelector(
":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel"
);
}
export function mountKioskSkeleton(container, html, label) {
if (!container) return performance.now();
container.innerHTML = html;
container.classList.remove("kiosk-skeleton-host--exit");
if (container.querySelector(":scope > .kiosk-skeleton-card")) {
container.setAttribute("aria-busy", "true");
if (label) container.setAttribute("aria-label", label);
}
return performance.now();
}
export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) {
const elapsed = performance.now() - startedAt;
const rest = minMs - elapsed;
if (rest > 0) {
await new Promise((resolve) => setTimeout(resolve, rest));
}
}
async function fadeOutSkeleton(skel) {
if (!skel || prefersReducedMotion()) return;
const exitClass = isGridSkeletonHost(skel)
? "kiosk-skeleton-host--exit"
: skel.classList.contains("kiosk-skeleton-groups")
? "kiosk-skeleton-groups--exit"
: skel.classList.contains("kiosk-skeleton-table")
? "kiosk-skeleton-table--exit"
: "kiosk-skeleton-panel--exit";
skel.classList.add(exitClass);
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
skel.removeEventListener("transitionend", onEnd);
resolve();
};
const onEnd = (event) => {
if (event.target === skel && event.propertyName === "opacity") finish();
};
skel.addEventListener("transitionend", onEnd);
globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40);
});
}
function clearSkeletonHostAttrs(container) {
if (!container) return;
container.classList.remove("kiosk-skeleton-host--exit");
container.removeAttribute("aria-busy");
container.removeAttribute("aria-label");
}
export async function replaceKioskContent(container, html, options = {}) {
if (!container) return;
const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options;
const skel = findSkeletonRoot(container);
if (skel) {
await awaitKioskSkeletonMin(startedAt, minMs);
await fadeOutSkeleton(skel);
} else {
await awaitKioskSkeletonMin(startedAt, minMs);
}
container.innerHTML = html;
clearSkeletonHostAttrs(container);
}
export function clearStaleKioskSkeleton(container) {
if (!container) return;
if (findSkeletonRoot(container)) {
container.innerHTML = "";
clearSkeletonHostAttrs(container);
}
}
export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
const html = buildDuplicatePanelSkeletonHtml();
const startedAt = performance.now();
if (recipeInfoEl) {
recipeInfoEl.style.display = "block";
recipeInfoEl.innerHTML = html;
}
if (weightSectionEl) {
weightSectionEl.classList.add("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host) {
host.dataset.kioskSkeletonBackup = host.innerHTML;
host.innerHTML = `<div class="kiosk-skeleton-panel" aria-busy="true" aria-hidden="true">
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
}
return startedAt;
}
export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
if (recipeInfoEl) {
const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel");
if (skel) {
recipeInfoEl.style.display = "none";
recipeInfoEl.innerHTML = `
<h3>Рецепт: <span id="recipeName"></span></h3>
<p>Компонент <span id="currentIndex">0</span> из <span id="totalComponents">0</span></p>`;
}
}
if (weightSectionEl) {
weightSectionEl.classList.remove("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host?.dataset.kioskSkeletonBackup) {
host.innerHTML = host.dataset.kioskSkeletonBackup;
delete host.dataset.kioskSkeletonBackup;
}
}
}
const api = {
buildGridSkeletonCards,
buildDispenserGridSkeletonHtml,
buildPeriodRecipeGridSkeletonHtml,
buildUnloadingGroupsSkeletonHtml,
buildDuplicatePanelSkeletonHtml,
buildRecipeIngredientsSkeletonHtml,
mountKioskSkeleton,
replaceKioskContent,
awaitKioskSkeletonMin,
clearStaleKioskSkeleton,
mountDuplicatePanelSkeleton,
clearDuplicatePanelSkeleton,
};
if (typeof globalThis !== "undefined") {
globalThis.WespKioskSkeleton = api;
}
@@ -0,0 +1,78 @@
export class DispenserSelector {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
const recipeId = actionEl.dataset.recipeId;
const periodId = actionEl.dataset.periodId;
const dispenserId = actionEl.dataset.dispenserId;
const index = Number(actionEl.dataset.index);
switch (action) {
case "select-dispenser":
if (dispenserId) await this.handlers.selectDispenser(dispenserId);
return true;
case "select-recipe":
if (recipeId) await this.handlers.selectRecipe(recipeId);
return true;
case "select-period":
if (periodId) await this.handlers.selectPeriod(periodId);
return true;
case "copy-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.copyRecipe(recipeId);
return true;
case "delete-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.deleteRecipe(recipeId);
return true;
case "paste-recipe":
event.stopPropagation();
if (periodId) await this.handlers.pasteRecipe(periodId);
return true;
case "create-recipe":
event.stopPropagation();
if (periodId) await this.handlers.createNewRecipe(periodId);
return true;
case "move-recipe-up":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeUp(recipeId, index);
}
return true;
case "move-recipe-down":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeDown(recipeId, index);
}
return true;
case "unskip-recipe-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipRecipeToday === "function") {
await this.handlers.unskipRecipeToday(recipeId);
}
return true;
case "unskip-ingredient-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipIngredientPartsToday === "function") {
await this.handlers.unskipIngredientPartsToday(recipeId);
}
return true;
case "unskip-group-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipGroupPartsToday === "function") {
await this.handlers.unskipGroupPartsToday(recipeId);
}
return true;
case "recipe-drag-handle":
event.stopPropagation();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,95 @@
export class RecipeEditor {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "add-ingredient":
this.handlers.addIngredient();
return true;
case "toggle-unloading-link":
this.handlers.toggleUnloadingLink();
return true;
case "add-unloading-group":
this.handlers.addUnloadingGroup();
return true;
case "show-recipe-edit-off":
this.handlers.showRecipeEdit(false);
return true;
case "print-all-recipes":
this.handlers.printAllRecipesInPeriod();
return true;
case "print-recipe":
this.handlers.printRecipe();
return true;
case "save-and-exit":
await this.handlers.saveRecipe(null, true);
return true;
case "move-group-up":
this.handlers.moveGroupUp(actionEl);
return true;
case "move-group-down":
this.handlers.moveGroupDown(actionEl);
return true;
case "remove-unloading-group":
this.handlers.removeUnloadingGroup(actionEl);
return true;
case "move-ingredient-up":
this.handlers.moveIngredientUp(actionEl);
return true;
case "move-ingredient-down":
this.handlers.moveIngredientDown(actionEl);
return true;
case "remove-ingredient":
this.handlers.removeIngredient(actionEl);
return true;
case "open-ingredient-sheet": {
const row = actionEl.closest(".ingredient-row");
this.handlers.openIngredientMobileSheet(row);
return true;
}
case "close-ingredient-sheet":
this.handlers.closeIngredientMobileSheet();
return true;
case "remove-ingredient-sheet":
this.handlers.removeIngredientFromSheet();
return true;
case "open-unloading-group-sheet": {
const row = actionEl.closest(".unloading-group-row");
this.handlers.openUnloadingGroupMobileSheet(row);
return true;
}
case "close-unloading-group-sheet":
this.handlers.closeUnloadingGroupMobileSheet();
return true;
default:
return false;
}
}
handleChange(action, actionEl) {
switch (action) {
case "group-type-change":
this.handlers.handleGroupTypeChange(actionEl);
return true;
case "group-value-change":
this.handlers.validateGroupValue(actionEl);
return true;
case "ingredient-change":
this.handlers.handleIngredientChange(actionEl);
return true;
case "dry-matter-percent-change":
this.handlers.handleDryMatterPercentChange(actionEl);
return true;
case "recalculate-weights":
this.handlers.recalculateWeights();
return true;
case "recalculate-total-weight":
this.handlers.recalculateFromTotalWeight(actionEl);
return true;
default:
return false;
}
}
}
@@ -0,0 +1,875 @@
/**
* Drag-and-drop reorder for period recipe cards (#recipesList).
* Drag starts from .recipe-drag-handle; uses the same API as arrow buttons.
* Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line.
*/
import { cloneTemplateInto } from "./recipe-skeleton.js";
const MOBILE_REORDER_MQ = "(max-width: 768px)";
const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 };
const AUTO_SCROLL_EDGE_PX = 72;
const AUTO_SCROLL_MAX_STEP = 28;
const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking";
function isMobileReorderUi() {
return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false;
}
function activationThresholdPx() {
return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop;
}
function transferBlocksReorder() {
const listRoot = document.getElementById("recipesList");
if (
listRoot?.classList.contains("recipe-list-reorder-active") ||
document.body.classList.contains("recipe-list-reorder-active")
) {
return false;
}
return (
document.body.classList.contains("recipe-mobile-transfer-armed") ||
document.body.classList.contains("recipe-period-transfer-active")
);
}
function removeRecipeDragGhost() {
const existing = document.getElementById("recipe-card-drag-ghost-root");
if (existing?.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function removeInsertIndicator(listRoot) {
listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove();
}
function mountRecipeCardSkeletonInner(container) {
if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) {
container.innerHTML =
'<div class="dashboard-skeleton-card-inner"><div class="dashboard-skeleton-bar"></div></div>';
}
}
function getRecipeListItems(listRoot) {
return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
}
function resolveRawInsertIndexAtY(listRoot, clientY) {
const items = getRecipeListItems(listRoot);
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
return i;
}
}
return items.length;
}
function resolveInsertIndexAtY(listRoot, clientY, draggingItem) {
const items = getRecipeListItems(listRoot);
if (!draggingItem) {
return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length));
}
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return 0;
let toIndex = items.length;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
const inCard = clientY >= rect.top && clientY <= rect.bottom;
if (inCard && fromIndex > i) {
toIndex = i;
break;
}
if (inCard && fromIndex < i) {
toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1;
break;
}
if (clientY < rect.top + rect.height / 2) {
toIndex = i;
break;
}
toIndex = i + 1;
}
if (fromIndex < toIndex) {
toIndex -= 1;
}
return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1)));
}
function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return null;
const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem);
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0 || dropToIndex == null) return null;
const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1)));
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function mountRecipeCardDragGhost(item, dragEvent) {
removeRecipeDragGhost();
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
const w = item.offsetWidth;
ghost.style.cssText = [
"position:fixed",
"left:-10000px",
"top:0",
"width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
void ghost.offsetWidth;
const rect = item.getBoundingClientRect();
const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left));
const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top));
try {
dragEvent.dataTransfer.setDragImage(ghost, ox, oy);
} catch (_) {
/* старые движки без кастомного изображения */
}
}
function sanitizeDragGhostClone(node) {
node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action"));
node.querySelectorAll("button").forEach((btn) => {
btn.disabled = true;
btn.tabIndex = -1;
});
node.classList.remove("active", "recipe-card-drop-target");
}
function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) {
removeRecipeDragGhost();
if (isMobileReorderUi()) {
const ghost = item.cloneNode(true);
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone";
ghost.setAttribute("aria-hidden", "true");
sanitizeDragGhostClone(ghost);
const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24));
ghost.style.cssText = [
"position:fixed",
"left:0",
"top:0",
"width:" + width + "px",
"margin:0",
"transition:none",
"transform:none",
"z-index:1300",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY);
return ghost;
}
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`;
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY);
return ghost;
}
function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) {
if (!ghost) return;
if (grabOffsetX != null && grabOffsetY != null) {
ghost.style.left = `${clientX - grabOffsetX}px`;
ghost.style.top = `${clientY - grabOffsetY}px`;
ghost.style.transform = "none";
return;
}
ghost.style.left = `${clientX}px`;
ghost.style.top = `${clientY}px`;
ghost.style.transform = "translate(-50%, -50%)";
}
function slotPickActive() {
return Boolean(document.querySelector(".recipe-transfer-slot"));
}
function clearDropTargets(listRoot) {
listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => {
el.classList.remove("recipe-card-drop-target");
});
removeInsertIndicator(listRoot);
}
function updateInsertIndicator(listRoot, draggingItem, clientY) {
if (!isMobileReorderUi()) return;
const items = getRecipeListItems(listRoot);
if (!items.length) return;
let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator");
if (!line) {
line = document.createElement("div");
line.className = "recipe-card-insert-indicator";
line.setAttribute("aria-hidden", "true");
listRoot.appendChild(line);
}
const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY);
let yInList;
if (insertIndex >= items.length) {
const last = items[items.length - 1];
yInList = last.offsetTop + last.offsetHeight + 6;
} else {
yInList = items[insertIndex].offsetTop - 4;
}
line.style.top = `${yInList}px`;
line.hidden = false;
}
function highlightDropTarget(listRoot, draggingItem, clientY) {
clearDropTargets(listRoot);
if (isMobileReorderUi()) {
updateInsertIndicator(listRoot, draggingItem, clientY);
return;
}
const items = getRecipeListItems(listRoot);
for (const item of items) {
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
item.classList.add("recipe-card-drop-target");
break;
}
}
}
function configureMobileDragHandles(listRoot) {
if (!isMobileReorderUi()) return;
listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => {
handle.setAttribute("draggable", "false");
});
}
function listCanScroll(listRoot) {
return listRoot.scrollHeight > listRoot.clientHeight + 2;
}
function getPageScrollElement() {
return document.scrollingElement || document.documentElement;
}
function pageCanScrollUp() {
return getPageScrollElement().scrollTop > 0;
}
function pageCanScrollDown() {
const scrollEl = getPageScrollElement();
return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2;
}
function ensureMobileEdgeScrollHints() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return;
const wrap = document.createElement("div");
wrap.id = "recipe-reorder-edge-hints";
wrap.className = "recipe-reorder-edge-hints";
wrap.setAttribute("aria-hidden", "true");
wrap.innerHTML =
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--top">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-up" aria-hidden="true"></i><span>К краю — вверх</span></span>' +
"</div>" +
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--bottom">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-down" aria-hidden="true"></i><span>К краю — вниз</span></span>' +
"</div>";
document.body.appendChild(wrap);
}
function updateMobileEdgeScrollHints(clientY) {
if (!isMobileReorderUi()) return;
const wrap = document.getElementById("recipe-reorder-edge-hints");
if (!wrap) return;
const vh = window.innerHeight;
wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle(
"is-active",
clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp()
);
wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle(
"is-active",
clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown()
);
}
function removeReorderUiChrome() {
document.body.classList.remove(REORDER_TRACKING_BODY_CLASS);
document.getElementById("recipe-reorder-edge-hints")?.remove();
document.getElementById("recipe-reorder-floating-hint")?.remove();
}
function showRecipeReorderHint() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return;
const hint = document.createElement("div");
hint.id = "recipe-reorder-floating-hint";
hint.className = "recipe-reorder-hint recipe-reorder-hint--floating";
hint.setAttribute("role", "status");
hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка.";
document.body.appendChild(hint);
requestAnimationFrame(() => hint.classList.add("is-visible"));
globalThis.setTimeout(() => {
hint.classList.remove("is-visible");
globalThis.setTimeout(() => hint.remove(), 320);
}, 3200);
}
function autoScrollWindowAtEdge(clientY) {
const vh = window.innerHeight;
const scrollEl = getPageScrollElement();
let delta = 0;
if (clientY < AUTO_SCROLL_EDGE_PX) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (clientY > vh - AUTO_SCROLL_EDGE_PX) {
const distBottom = vh - clientY;
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += delta;
return scrollEl.scrollTop !== prev;
}
function autoScrollRecipeList(listRoot, clientY) {
if (!listCanScroll(listRoot)) return false;
const rect = listRoot.getBoundingClientRect();
const distTop = clientY - rect.top;
const distBottom = rect.bottom - clientY;
let delta = 0;
if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
listRoot.scrollTop += delta;
return true;
}
function autoScrollDuringReorder(listRoot, clientY) {
if (isMobileReorderUi()) {
return autoScrollWindowAtEdge(clientY);
}
return autoScrollRecipeList(listRoot, clientY);
}
function findTouchById(touchList, touchId) {
for (let i = 0; i < touchList.length; i++) {
if (touchList[i].identifier === touchId) {
return touchList[i];
}
}
return null;
}
function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) {
let touchDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let touchMoveListener = null;
let touchEndListener = null;
const detachTouchListeners = () => {
if (touchMoveListener) {
document.removeEventListener("touchmove", touchMoveListener, true);
touchMoveListener = null;
}
if (touchEndListener) {
document.removeEventListener("touchend", touchEndListener, true);
document.removeEventListener("touchcancel", touchEndListener, true);
touchEndListener = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!touchDrag?.active) {
stopAutoScroll();
return;
}
const y = touchDrag.lastY;
autoScrollDuringReorder(listRoot, y);
updateMobileEdgeScrollHints(y);
highlightDropTarget(listRoot, touchDrag.item, y);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishTouchDrag = () => {
detachTouchListeners();
stopAutoScroll();
if (!touchDrag) return;
touchDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
touchDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const activateTouchDrag = (touch) => {
if (!touchDrag || touchDrag.active) return;
touchDrag.active = true;
touchDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
document.body.classList.add(REORDER_TRACKING_BODY_CLASS);
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
ensureMobileEdgeScrollHints();
showRecipeReorderHint();
touchDrag.ghost = mountPointerFollowGhost(
touchDrag.item,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
startAutoScroll();
globalThis.navigator?.vibrate?.(8);
};
const handleTouchMove = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.touches, touchDrag.touchId);
if (!touch) return;
e.preventDefault();
if (transferBlocksReorder()) {
finishTouchDrag();
return;
}
touchDrag.lastY = touch.clientY;
const dx = Math.abs(touch.clientX - touchDrag.startX);
const dy = Math.abs(touch.clientY - touchDrag.startY);
if (!touchDrag.active && dx + dy < activationThresholdPx()) return;
if (!touchDrag.active) {
activateTouchDrag(touch);
}
positionPointerFollowGhost(
touchDrag.ghost,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
updateMobileEdgeScrollHints(touch.clientY);
highlightDropTarget(listRoot, touchDrag.item, touch.clientY);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item);
};
const handleTouchEnd = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.changedTouches, touchDrag.touchId);
if (!touch) return;
const { item, active, dropToIndex } = touchDrag;
finishTouchDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, touch, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
listRoot.addEventListener("recipe-transfer-armed", () => {
if (touchDrag?.active) {
finishTouchDrag();
} else if (touchDrag) {
detachTouchListeners();
removeReorderUiChrome();
touchDrag = null;
}
});
if (typeof MutationObserver !== "undefined") {
const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot));
handleObserver.observe(listRoot, { childList: true });
configureMobileDragHandles(listRoot);
}
listRoot.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
const touch = e.touches[0];
const handleRect = handle.getBoundingClientRect();
touchDrag = {
item,
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
lastY: touch.clientY,
grabOffsetX: touch.clientX - handleRect.left,
grabOffsetY: touch.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
e.stopPropagation();
touchMoveListener = handleTouchMove;
touchEndListener = handleTouchEnd;
document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false });
document.addEventListener("touchend", touchEndListener, { capture: true });
document.addEventListener("touchcancel", touchEndListener, { capture: true });
},
{ capture: true, passive: true }
);
}
function attachPointerReorder(listRoot, { moveRecipe, notyf }) {
if (isMobileReorderUi()) {
attachMobileTouchReorder(listRoot, { moveRecipe, notyf });
return;
}
let pointerDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let documentDragMove = null;
let documentDragEnd = null;
const detachDocumentDragListeners = () => {
if (documentDragMove) {
document.removeEventListener("pointermove", documentDragMove, true);
documentDragMove = null;
}
if (documentDragEnd) {
document.removeEventListener("pointerup", documentDragEnd, true);
document.removeEventListener("pointercancel", documentDragEnd, true);
documentDragEnd = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!pointerDrag?.active) {
stopAutoScroll();
return;
}
const y = pointerDrag.lastY;
autoScrollDuringReorder(listRoot, y);
highlightDropTarget(listRoot, pointerDrag.item, y);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishPointerDrag = () => {
detachDocumentDragListeners();
stopAutoScroll();
if (!pointerDrag) return;
pointerDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
pointerDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const isOverTransferHoverTarget = (clientX, clientY) => {
const el = document.elementFromPoint(clientX, clientY);
return !!(
el?.closest("#periodsList .list-item[data-period-id]") ||
el?.closest("#dispensersList .list-item[data-dispenser-id]")
);
};
const handlePointerMove = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
if (transferBlocksReorder()) {
finishPointerDrag();
return;
}
if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) {
pointerDrag.lastY = e.clientY;
return;
}
e.preventDefault();
pointerDrag.lastY = e.clientY;
const dx = Math.abs(e.clientX - pointerDrag.startX);
const dy = Math.abs(e.clientY - pointerDrag.startY);
if (!pointerDrag.active && dx + dy < activationThresholdPx()) return;
if (!pointerDrag.active) {
pointerDrag.active = true;
pointerDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
pointerDrag.ghost = mountPointerFollowGhost(
pointerDrag.item,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
startAutoScroll();
}
positionPointerFollowGhost(
pointerDrag.ghost,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
updateMobileEdgeScrollHints(e.clientY);
highlightDropTarget(listRoot, pointerDrag.item, e.clientY);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item);
};
const onPointerEnd = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
const { item, active, dropToIndex } = pointerDrag;
finishPointerDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, e, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
const attachDocumentDragListeners = () => {
if (documentDragMove) return;
documentDragMove = handlePointerMove;
documentDragEnd = onPointerEnd;
document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false });
document.addEventListener("pointerup", documentDragEnd, { capture: true });
document.addEventListener("pointercancel", documentDragEnd, { capture: true });
};
const cancelPointerDrag = () => {
if (!pointerDrag) return;
if (pointerDrag.active) return;
finishPointerDrag();
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag);
listRoot.addEventListener("recipe-transfer-armed", () => {
if (pointerDrag && !pointerDrag.active) {
detachDocumentDragListeners();
pointerDrag = null;
} else if (pointerDrag?.active) {
finishPointerDrag();
}
});
listRoot.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
const handleRect = handle.getBoundingClientRect();
pointerDrag = {
item,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
lastY: e.clientY,
grabOffsetX: e.clientX - handleRect.left,
grabOffsetY: e.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
attachDocumentDragListeners();
},
{ capture: true, passive: false }
);
}
export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) {
if (!listRoot || typeof moveRecipe !== "function") return;
let draggingItem = null;
let dropPending = false;
listRoot.addEventListener("dragstart", (e) => {
if (isMobileReorderUi()) {
e.preventDefault();
return;
}
if (transferBlocksReorder()) {
e.preventDefault();
return;
}
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
draggingItem = item;
item.classList.add("recipe-card-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.dataset.recipeId || "");
mountRecipeCardDragGhost(item, e);
});
listRoot.addEventListener("dragend", () => {
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
}
draggingItem = null;
dropPending = false;
clearDropTargets(listRoot);
});
listRoot.addEventListener("dragover", (e) => {
if (!draggingItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const target = e.target.closest(".list-item[data-recipe-id]");
clearDropTargets(listRoot);
if (target && target !== draggingItem) {
target.classList.add("recipe-card-drop-target");
}
});
listRoot.addEventListener("dragleave", (e) => {
if (!listRoot.contains(e.relatedTarget)) {
clearDropTargets(listRoot);
}
});
listRoot.addEventListener("drop", async (e) => {
if (!draggingItem || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
e.preventDefault();
e.stopPropagation();
clearDropTargets(listRoot);
const indices = resolveReorderDropIndex(listRoot, e, draggingItem);
if (!indices) return;
void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex);
});
const cancelHtml5Drag = () => {
if (listRoot.classList.contains("recipe-list-reorder-active")) return;
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
draggingItem = null;
}
dropPending = false;
clearDropTargets(listRoot);
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag);
if (!listRoot.dataset.recipeDragCancelBound) {
listRoot.dataset.recipeDragCancelBound = "1";
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
cancelHtml5Drag();
listRoot.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
},
true
);
}
attachPointerReorder(listRoot, { moveRecipe, notyf });
}
@@ -0,0 +1,166 @@
const MOBILE_SHEET_MQ = "(max-width: 768px)";
function getSheetParts(root) {
if (!root) return null;
const panel = root.querySelector(
".recipe-mobile-edit-sheet-panel, .ingredient-mobile-sheet-panel, .recipe-help-modal-panel"
);
const body = root.querySelector(
".recipe-mobile-edit-sheet-body, .ingredient-mobile-sheet-body, .recipe-help-modal-body"
);
const backdrop = root.querySelector(".recipe-mobile-edit-sheet-backdrop");
return panel ? { root, panel, body, backdrop } : null;
}
export function resetRecipeMobileSheetPresentation(root) {
const parts = getSheetParts(root);
if (!parts) return;
const { panel, backdrop } = parts;
panel.style.transform = "";
panel.style.transition = "";
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
if (backdrop) {
backdrop.style.opacity = "";
backdrop.style.transition = "";
}
}
function canStartSheetDrag(target, body) {
if (target.closest(".recipe-mobile-edit-sheet-done")) return false;
if (target.closest("button, input, select, textarea, a, label")) {
return !!target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-title");
}
if (target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-header")) {
return true;
}
if (body?.contains(target) && body.scrollTop <= 0) {
return true;
}
return false;
}
function bindSheetGestures(root, onClose, mobileQuery) {
const parts = getSheetParts(root);
if (!parts || typeof onClose !== "function") return;
const { panel, body, backdrop } = parts;
let dragState = null;
function clearDragState() {
dragState = null;
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
}
function applyDrag(deltaY) {
const offset = Math.max(0, deltaY);
panel.style.transition = "none";
panel.style.transform = `translateY(${offset}px)`;
if (backdrop) {
backdrop.style.transition = "none";
backdrop.style.opacity = String(Math.max(0, 1 - offset / 280));
}
}
function snapBack() {
panel.style.transition = "transform 0.24s ease";
panel.style.transform = "";
if (backdrop) {
backdrop.style.transition = "opacity 0.24s ease";
backdrop.style.opacity = "";
}
window.setTimeout(() => {
panel.style.transition = "";
if (backdrop) backdrop.style.transition = "";
clearDragState();
}, 240);
}
function dismissWithAnimation() {
panel.style.transition = "transform 0.22s ease";
panel.style.transform = "translateY(100%)";
if (backdrop) {
backdrop.style.transition = "opacity 0.22s ease";
backdrop.style.opacity = "0";
}
window.setTimeout(() => {
resetRecipeMobileSheetPresentation(root);
onClose();
}, 220);
}
function onPointerDown(event) {
if (!mobileQuery.matches || root.hidden) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
if (!canStartSheetDrag(event.target, body)) return;
dragState = {
pointerId: event.pointerId,
startY: event.clientY,
fromScrollableBody: !!(body && body.contains(event.target) && !event.target.closest(".recipe-mobile-edit-sheet-header, .recipe-mobile-edit-sheet-grab")),
};
panel.classList.add("recipe-mobile-edit-sheet--dragging");
panel.setPointerCapture(event.pointerId);
}
function onPointerMove(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
if (dragState.fromScrollableBody) {
if (deltaY <= 0 || (body && body.scrollTop > 0)) {
clearDragState();
resetRecipeMobileSheetPresentation(root);
return;
}
if (deltaY > 8) {
dragState.fromScrollableBody = false;
} else {
return;
}
}
if (deltaY > 0) {
event.preventDefault();
applyDrag(deltaY);
}
}
function onPointerEnd(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
const threshold = Math.min(96, panel.offsetHeight * 0.2);
clearDragState();
if (deltaY > threshold) {
dismissWithAnimation();
return;
}
snapBack();
}
panel.addEventListener("pointerdown", onPointerDown);
panel.addEventListener("pointermove", onPointerMove, { passive: false });
panel.addEventListener("pointerup", onPointerEnd);
panel.addEventListener("pointercancel", onPointerEnd);
}
export function initRecipeMobileSheetGestures(handlers = {}) {
const mobileQuery = window.matchMedia(MOBILE_SHEET_MQ);
const sheets = [
{
root: document.getElementById("ingredientMobileSheet"),
onClose: handlers.closeIngredientMobileSheet,
},
{
root: document.getElementById("unloadingGroupMobileSheet"),
onClose: handlers.closeUnloadingGroupMobileSheet,
},
{
root: document.getElementById("recipeHelpModal"),
onClose: handlers.closeRecipeHelpModal,
},
];
sheets.forEach(({ root, onClose }) => bindSheetGestures(root, onClose, mobileQuery));
}
@@ -0,0 +1,655 @@
/**
* Перенос рейса в другой период/оборудование: drag с ручки, удержание 12 с над целью, выбор позиции.
* Отмена: отпустить не над целью / Escape; другой период или оборудование снова удержать над нужной карточкой или заново перетащить с ручки.
*/
const LONG_HOVER_MS = 1400;
const MOBILE_TRANSFER_MQ = "(max-width: 768px)";
const MOBILE_TRANSFER_LONG_PRESS_MS = 750;
const MOBILE_TRANSFER_MOVE_CANCEL_PX = 14;
const TRANSFER_MIME = "application/x-wesp-recipe-transfer";
export function attachRecipePeriodTransfer({
getCurrentDispenserType,
getSelectedDispenser,
getSelectedPeriod,
selectDispenser,
selectPeriod,
loadRecipes,
loadPeriods,
notyf,
}) {
const recipesList = document.getElementById("recipesList");
let session = null;
let slotPick = false;
let transferInFlight = false;
let longHoverState = { kind: null, id: null, since: 0, fired: false };
let rafHoverId = null;
let listMutationTimer = null;
function clearLongHover() {
longHoverState = { kind: null, id: null, since: 0, fired: false };
if (rafHoverId != null) {
cancelAnimationFrame(rafHoverId);
rafHoverId = null;
}
}
function isTransferAllowed() {
return (
typeof getCurrentDispenserType === "function" &&
getCurrentDispenserType() === "dispenser" &&
getSelectedPeriod()
);
}
function clearEligibleHighlights() {
document.querySelectorAll(".recipe-transfer-eligible").forEach((el) => {
el.classList.remove("recipe-transfer-eligible");
});
}
function applyEligibleHighlights() {
clearEligibleHighlights();
if (!session || slotPick) return;
document.querySelectorAll("#dispensersList .list-item[data-dispenser-id]").forEach((el) => {
if (el.dataset.dispenserType === "mill") return;
el.classList.add("recipe-transfer-eligible");
});
document.querySelectorAll("#periodsList .list-item[data-period-id]").forEach((el) => {
const pid = el.dataset.periodId;
if (
String(pid) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
return;
}
el.classList.add("recipe-transfer-eligible");
});
}
function startSession(recipeId, fromDispenserId, fromPeriodId) {
clearSlotPickUi();
session = { recipeId, fromDispenserId, fromPeriodId };
slotPick = false;
/* Подсветку целей не включаем здесь — иначе reorder в том же периоде блокируется с первого mousedown. */
}
function setMobileTransferOverlay(active) {
if (!globalThis.matchMedia?.("(max-width: 768px)")?.matches) return;
document.body.classList.toggle("recipe-transfer-session", !!active);
}
let pointerCaptureEl = null;
let pointerCaptureId = null;
let pendingDragItem = null;
function releaseTransferPointerCapture() {
if (pointerCaptureEl && pointerCaptureId != null) {
try {
pointerCaptureEl.releasePointerCapture(pointerCaptureId);
} catch (_) {
/* ignore */
}
}
pointerCaptureEl = null;
pointerCaptureId = null;
}
function clearTransferSessionState({ emitDragCancel = true } = {}) {
if (listMutationTimer != null) {
clearTimeout(listMutationTimer);
listMutationTimer = null;
}
clearMobilePressState();
releaseTransferPointerCapture();
pendingDragItem = null;
mouseSessionActive = false;
session = null;
slotPick = false;
pointerSessionId = null;
document.body.classList.remove("recipe-period-transfer-active");
document.body.classList.remove("recipe-mobile-transfer-armed");
setMobileTransferOverlay(false);
clearEligibleHighlights();
clearSlotPickUi();
clearLongHover();
if (emitDragCancel) {
recipesList?.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
}
}
function endSession() {
clearTransferSessionState({ emitDragCancel: true });
}
function clearSlotPickUi() {
document.querySelectorAll(".recipe-transfer-slot").forEach((n) => n.remove());
}
function isValidDropTarget(toDispenser, toPeriod) {
if (!toDispenser || !toPeriod || !session) return false;
return !(
String(toDispenser) === String(session.fromDispenserId) &&
String(toPeriod) === String(session.fromPeriodId)
);
}
/** Позиция вставки по Y относительно карточек (без учёта кнопок-слотов). */
function insertIndexFromRecipesListDrop(listRoot, e) {
if (!listRoot) return 0;
const y = e.clientY;
const items = [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
if (y < r.top + r.height / 2) return i;
}
return items.length;
}
async function commitTransfer(toIndex) {
if (!session || transferInFlight) return;
transferInFlight = true;
const sess = session;
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
try {
if (!sess || !toDispenser || !toPeriod || !isValidDropTarget(toDispenser, toPeriod)) {
endSession();
return;
}
const url = `/api/feed_dispensers/${encodeURIComponent(toDispenser)}/periods/${encodeURIComponent(toPeriod)}/recipes/${encodeURIComponent(sess.recipeId)}/transfer`;
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from_dispenser_id: sess.fromDispenserId,
from_period_id: sess.fromPeriodId,
to_index: toIndex,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j.message || "Ошибка переноса";
if (notyf) {
if (r.status === 409) {
if (typeof notyf.warning === "function") notyf.warning(msg);
else notyf.error(msg);
} else {
notyf.error(msg);
}
}
clearSlotPickUi();
slotPick = false;
if (r.status === 409) {
try {
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
} catch (_) {
/* ignore */
}
}
endSession();
return;
}
clearSlotPickUi();
slotPick = false;
if (notyf) notyf.success(j.message || "Рейс перенесён");
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
endSession();
} catch (err) {
console.error(err);
if (notyf) notyf.error(err.message, { fallback: "Ошибка переноса" });
endSession();
} finally {
transferInFlight = false;
}
}
function scheduleHoverCheck() {
if (rafHoverId != null) return;
const tick = () => {
rafHoverId = null;
if (!session || longHoverState.kind == null) return;
const elapsed = performance.now() - longHoverState.since;
if (!longHoverState.fired && elapsed >= LONG_HOVER_MS) {
longHoverState.fired = true;
void fireLongHover(longHoverState.kind, longHoverState.id);
return;
}
if (!longHoverState.fired && session) {
rafHoverId = requestAnimationFrame(tick);
}
};
rafHoverId = requestAnimationFrame(tick);
}
function trackLongHover(kind, id) {
if (!session) return;
if (longHoverState.kind !== kind || longHoverState.id !== id) {
longHoverState = { kind, id, since: performance.now(), fired: false };
applyEligibleHighlights();
}
setMobileTransferOverlay(true);
scheduleHoverCheck();
}
async function fireLongHover(kind, id) {
if (!session) return;
transferHoverCommitPending = true;
try {
await fireLongHoverImpl(kind, id);
} finally {
transferHoverCommitPending = false;
}
}
async function fireLongHoverImpl(kind, id) {
if (!session) return;
if (kind === "dispenser") {
if (String(id) === String(getSelectedDispenser())) return;
await selectDispenser(id);
document.body.classList.add("recipe-period-transfer-active");
applyEligibleHighlights();
return;
}
if (kind === "period") {
if (
String(id) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
if (notyf) notyf.error("Выберите другой период");
return;
}
if (slotPick && String(id) === String(getSelectedPeriod())) return;
await selectPeriod(id);
document.body.classList.add("recipe-period-transfer-active");
enterSlotPickMode();
}
}
function enterSlotPickMode() {
slotPick = true;
clearEligibleHighlights();
clearLongHover();
setMobileTransferOverlay(true);
requestAnimationFrame(() => injectSlots());
}
/** Список рейсов перерисован (loadRecipes, смена периода, перестановка) — убрать «Вставить здесь», если они уже не в DOM. */
function syncSlotPickAfterListMutation() {
if (!session || !slotPick) return;
const list = document.getElementById("recipesList");
if (!list || list.querySelector(".recipe-transfer-slot")) return;
endSession();
}
function scheduleSyncAfterListMutation() {
clearTimeout(listMutationTimer);
listMutationTimer = setTimeout(() => {
listMutationTimer = null;
syncSlotPickAfterListMutation();
}, 80);
}
if (recipesList) {
new MutationObserver(() => scheduleSyncAfterListMutation()).observe(recipesList, {
childList: true,
subtree: false,
});
}
function injectSlots() {
clearSlotPickUi();
const list = document.getElementById("recipesList");
if (!list || !session) return;
const items = [...list.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i <= items.length; i++) {
const slot = document.createElement("button");
slot.type = "button";
slot.className = "recipe-transfer-slot";
slot.dataset.insertIndex = String(i);
slot.innerHTML =
'<span class="recipe-transfer-slot-inner"><span class="recipe-transfer-slot-label">Вставить здесь</span></span>';
slot.setAttribute("aria-label", `Вставить рейс на позицию ${i + 1}`);
slot.addEventListener("click", onSlotClick);
if (i < items.length) {
list.insertBefore(slot, items[i]);
} else {
list.appendChild(slot);
}
}
}
function onSlotClick(e) {
const slot = e.target.closest(".recipe-transfer-slot");
if (!slot || !session) return;
const toIndex = Number(slot.dataset.insertIndex);
if (Number.isNaN(toIndex)) return;
void commitTransfer(toIndex);
}
function tryBeginSessionFromPendingDrag(clientX, clientY) {
if (session || !pendingDragItem) return false;
const target = document.elementFromPoint(clientX, clientY);
if (target?.closest("#recipesList .list-item[data-recipe-id]")) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (!dItem && !pItem) return false;
if (dItem?.dataset.dispenserType === "mill") return false;
beginHandleSession(pendingDragItem);
return true;
}
function onDocumentDragOver(e) {
if (!session) {
if (tryBeginSessionFromPendingDrag(e.clientX, e.clientY)) {
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
return;
}
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
function handleSessionHoverAtPoint(clientX, clientY, dragEvent) {
if (!session) return;
if (slotPick && recipesList && recipesList.contains(document.elementFromPoint(clientX, clientY))) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
return;
}
if (slotPick) return;
const target = document.elementFromPoint(clientX, clientY);
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem) {
if (dItem.dataset.dispenserType === "mill") {
clearLongHover();
return;
}
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("dispenser", dItem.dataset.dispenserId);
return;
}
if (pItem) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("period", pItem.dataset.periodId);
return;
}
clearLongHover();
}
function handleSessionDropAtPoint(clientX, clientY, dragEvent) {
if (!session) return false;
const target = document.elementFromPoint(clientX, clientY);
if (slotPick && recipesList && target && recipesList.contains(target)) {
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
if (!isValidDropTarget(toDispenser, toPeriod)) {
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
endSession();
return true;
}
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
const toIndex = insertIndexFromRecipesListDrop(recipesList, { clientY });
void commitTransfer(toIndex);
return true;
}
if (slotPick) return false;
const recipeDrop = target?.closest("#recipesList .list-item[data-recipe-id]");
if (recipeDrop) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem || pItem) {
dragEvent?.preventDefault?.();
return true;
}
dragEvent?.preventDefault?.();
endSession();
return true;
}
function onDocumentDropCapture(e) {
if (!session) return;
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function beginHandleSession(item) {
if (!item || item.dataset.index === undefined) return;
if (!isTransferAllowed()) return;
const recipeId = item.dataset.recipeId;
const fromDispenserId = getSelectedDispenser();
const fromPeriodId = getSelectedPeriod();
startSession(recipeId, fromDispenserId, fromPeriodId);
}
function onDragStartCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
/* Сессию transfer не открываем сразу — иначе ломается повторный reorder в том же списке. */
pendingDragItem = item;
try {
e.dataTransfer.setData(
TRANSFER_MIME,
JSON.stringify({
recipeId: item.dataset.recipeId,
fromDispenserId: getSelectedDispenser(),
fromPeriodId: getSelectedPeriod(),
})
);
} catch (_) {
/* ignore */
}
}
function onDragEndCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
if (slotPick && session) return;
pendingDragItem = null;
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
if (recipesList.classList.contains("recipe-list-reorder-active")) {
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
return;
}
if (session) clearTransferSessionState({ emitDragCancel: true });
}
let pointerSessionId = null;
let mouseSessionActive = false;
let transferHoverCommitPending = false;
let mobilePressState = null;
function isMobileTransferUi() {
return globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches ?? false;
}
function clearMobilePressState() {
if (mobilePressState?.longPressTimer) {
clearTimeout(mobilePressState.longPressTimer);
}
mobilePressState = null;
}
function cancelMobilePressForReorder() {
clearMobilePressState();
}
function armMobileTransferSession(item) {
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
document.body.classList.add("recipe-mobile-transfer-armed");
recipesList?.dispatchEvent(new CustomEvent("recipe-transfer-armed", { bubbles: true }));
globalThis.navigator?.vibrate?.(12);
if (notyf) {
notyf.success("Перетащите рейс на период или оборудование");
}
}
function onPointerDownCapture(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
if (!isTransferAllowed()) return;
if (isMobileTransferUi()) {
clearMobilePressState();
mobilePressState = {
pointerId: e.pointerId,
item,
startX: e.clientX,
startY: e.clientY,
longPressTimer: globalThis.setTimeout(() => {
if (!mobilePressState) return;
if (recipesList?.classList.contains("recipe-list-reorder-active")) {
clearMobilePressState();
return;
}
mobilePressState.armed = true;
pointerSessionId = mobilePressState.pointerId;
armMobileTransferSession(mobilePressState.item);
}, MOBILE_TRANSFER_LONG_PRESS_MS),
};
return;
}
/* Desktop: reorder владеет ручкой через pointer/HTML5; transfer — через dragstart + hover. */
}
function onPointerMoveCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId && !mobilePressState.armed) {
const dx = Math.abs(e.clientX - mobilePressState.startX);
const dy = Math.abs(e.clientY - mobilePressState.startY);
if (dx + dy >= MOBILE_TRANSFER_MOVE_CANCEL_PX) {
clearMobilePressState();
}
}
if (!session || pointerSessionId == null || e.pointerId !== pointerSessionId) return;
if (globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches && e.clientY < window.innerHeight * 0.38) {
setMobileTransferOverlay(true);
}
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onPointerUpCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
const wasArmed = mobilePressState.armed;
clearMobilePressState();
if (!wasArmed) return;
}
const ownsPointer = pointerSessionId != null && e.pointerId === pointerSessionId;
if (ownsPointer) {
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
}
if (!session) return;
if (!ownsPointer) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
clearTransferSessionState({ emitDragCancel: true });
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function onPointerCancelCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
clearMobilePressState();
}
if (pointerSessionId == null || e.pointerId !== pointerSessionId) return;
pointerSessionId = null;
if (slotPick && session) return;
endSession();
}
recipesList?.addEventListener("dragstart", onDragStartCapture, true);
recipesList?.addEventListener("dragend", onDragEndCapture, true);
recipesList?.addEventListener("recipe-reorder-active", () => {
cancelMobilePressForReorder();
pendingDragItem = null;
/* Не шлём recipe-list-drag-cancel — иначе reorder обрывается в pointer-path. */
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
});
function onMouseDownCapture(e) {
/* Desktop mousedown-сессия ломала повторный reorder; transfer на десктопе — dragstart + dragover. */
if (!isMobileTransferUi()) return;
if (e.button !== 0 || pointerSessionId != null) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
mouseSessionActive = true;
}
function onMouseMoveCapture(e) {
if (!session || !mouseSessionActive || slotPick) return;
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onMouseUpCapture(e) {
if (!mouseSessionActive) return;
mouseSessionActive = false;
if (!session) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
endSession();
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
recipesList?.addEventListener("pointerdown", onPointerDownCapture, true);
recipesList?.addEventListener("mousedown", onMouseDownCapture, true);
document.addEventListener("mousemove", onMouseMoveCapture, true);
document.addEventListener("mouseup", onMouseUpCapture, true);
document.addEventListener("pointermove", onPointerMoveCapture, true);
document.addEventListener("pointerup", onPointerUpCapture, true);
document.addEventListener("pointercancel", onPointerCancelCapture, true);
document.addEventListener("dragover", onDocumentDragOver, true);
document.addEventListener("drop", onDocumentDropCapture, true);
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !session) return;
endSession();
});
return { endSession };
}
@@ -0,0 +1,35 @@
const LIST_LABELS = {
dispensers: "Загрузка кормораздатчиков",
periods: "Загрузка периодов",
recipes: "Загрузка рейсов",
mill: "Загрузка рецептов",
period: "Загрузка рейсов",
};
/**
* Structural loading один spinner на список (не 4 копии).
*/
export function buildRecipeRowsSkeletonHtml(variant = "period") {
const label = LIST_LABELS[variant] || LIST_LABELS.period;
return `<div class="zt-loading-list dashboard-list-skeleton" data-skeleton-variant="${variant}" aria-busy="true" aria-label="${label}">
<div class="zt-loading-placeholder">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">${label}</p>
</div>
</div>`;
}
export function cloneTemplateOuterHtml(templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el) return "";
return el.outerHTML;
}
export function cloneTemplateInto(container, templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el || !container) return false;
container.appendChild(el.cloneNode(true));
return true;
}
@@ -0,0 +1,42 @@
export class SyncPanel {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "open-settings":
event.preventDefault();
await this.handlers.openSettings(event);
return true;
case "close-settings":
this.handlers.closeSettings();
return true;
case "toggle-credentials-form":
this.handlers.toggleCredentialsForm();
return true;
case "toggle-sync-form":
this.handlers.toggleSyncForm();
return true;
case "change-credentials":
await this.handlers.changeCredentials();
return true;
case "sync-edit-client":
event.stopPropagation();
this.handlers.editSyncClientSettings(actionEl);
return true;
case "sync-delete-client":
event.stopPropagation();
this.handlers.deleteSyncClientSettings(actionEl);
return true;
case "sync-save-client":
this.handlers.saveSyncClientDisplayName(actionEl.dataset.nodeId || "");
return true;
case "sync-cancel-edit":
await this.handlers.loadSyncClientsSettings();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,40 @@
export class ReconnectingEventSource {
constructor(url, { retryDelayMs = 2000, maxRetryDelayMs = 15000 } = {}) {
this.url = url;
this.retryDelayMs = retryDelayMs;
this.maxRetryDelayMs = maxRetryDelayMs;
this.onmessage = null;
this.onerror = null;
this._closed = false;
this._es = null;
this._currentDelay = retryDelayMs;
this._connect();
}
_connect() {
if (this._closed) return;
this._es = new EventSource(this.url);
this._es.onmessage = (event) => {
this._currentDelay = this.retryDelayMs;
if (this.onmessage) this.onmessage(event);
};
this._es.onerror = (event) => {
if (this.onerror) this.onerror(event);
this._scheduleReconnect();
};
}
_scheduleReconnect() {
if (this._closed) return;
if (this._es) this._es.close();
const delay = this._currentDelay;
this._currentDelay = Math.min(this._currentDelay * 2, this.maxRetryDelayMs);
setTimeout(() => this._connect(), delay);
}
close() {
this._closed = true;
if (this._es) this._es.close();
this._es = null;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,367 @@
/**
* Редактор серверной SQLite: дерево bind таблицы, сетка с сохранением через /api/admin/db/*.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function parseInputValue(raw, sampleOriginal) {
const t = raw.trim();
if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null;
if (t === "") return "";
if (/^-?\d+$/.test(t)) return Number(t);
if (/^-?\d+\.\d+$/.test(t)) return Number(t);
if (t === "true") return 1;
if (t === "false") return 0;
return raw;
}
function closeModal() {
const m = document.getElementById("adminSqliteModal");
if (m) m.hidden = true;
document.body.style.overflow = "";
}
let browseState = {
bind: "recipes",
table: null,
limit: 50,
offset: 0,
page: null,
};
/** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */
let dbTreePickCallback = null;
async function fetchJson(url, options) {
const r = await fetch(url, options);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(data.message || `HTTP ${r.status}`);
}
return data;
}
function renderTree(binds, preferredBind, onPickTable) {
dbTreePickCallback = onPickTable;
const root = document.getElementById("adminDbTree");
if (!root) return;
root.innerHTML = binds
.map((b) => {
const open = b.bind === preferredBind ? "is-open" : "";
const rows = (b.tables || [])
.map(
(t) =>
`<li class="wesp-db-tree-leaf"><button type="button" class="wesp-db-tree-table ant-btn ant-btn-sm" data-bind="${escHtml(b.bind)}" data-table="${escHtml(t.name)}">${escHtml(t.name)}</button></li>`,
)
.join("");
return `<li class="wesp-db-tree-node ${open}">
<button type="button" class="wesp-db-tree-caret" aria-expanded="${b.bind === preferredBind ? "true" : "false"}"><span class="wesp-db-tree-caret-icon"></span></button>
<span class="wesp-db-tree-folder">${escHtml(b.bind)}</span>
<ul class="wesp-db-tree-children">${rows}</ul>
</li>`;
})
.join("");
root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => {
btn.addEventListener("click", () => {
const li = btn.closest(".wesp-db-tree-node");
if (!li) return;
const open = li.classList.toggle("is-open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
});
root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
btn.addEventListener("click", () => {
const bind = btn.getAttribute("data-bind");
const table = btn.getAttribute("data-table");
root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
onPickTable(bind, table);
});
});
}
function renderGrid(data, setStatus) {
const meta = document.getElementById("adminDbGridMeta");
const wrap = document.getElementById("adminDbGridWrap");
const pager = document.getElementById("adminDbPager");
if (!meta || !wrap || !pager) return;
browseState.page = data;
const { columns, rows, total, has_rowid: hasRowid } = data;
meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`;
const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c]));
const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid");
let thead =
"<thead><tr>" +
(hasRowid ? "<th>rowid</th>" : "") +
displayCols.map((n) => `<th title="${escHtml(colMetaByName[n]?.type || "")}">${escHtml(n)}</th>`).join("") +
"<th class=\"wesp-db-grid-actions-col\">Действия</th></tr></thead>";
const body = rows
.map((row) => {
const rowKey = {};
if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid;
(data.pk_names || []).forEach((pk) => {
if (pk in row) rowKey[pk] = row[pk];
});
const cells = displayCols
.map((name) => {
const cm = colMetaByName[name];
const val = row[name];
if (cm && !cm.editable) {
return `<td class="wesp-db-cell-readonly">(BLOB)</td>`;
}
const v = val == null ? "" : String(val);
return `<td><input type="text" class="ant-input wesp-db-cell" data-col="${escHtml(name)}" value="${escHtml(v)}"/></td>`;
})
.join("");
const ridCell = hasRowid
? `<td class="wesp-db-cell-readonly">${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}</td>`
: "";
const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey));
const rowJsonEnc = encodeURIComponent(JSON.stringify(row));
return `<tr data-row-key="${rowKeyEnc}" data-row-json="${rowJsonEnc}">
${ridCell}${cells}
<td class="wesp-db-row-actions">
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary wesp-db-row-save"><span>Сохранить</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger wesp-db-row-delete"><span>Удалить</span></button>
</td>
</tr>`;
})
.join("");
wrap.innerHTML = `<table class="ant-table wesp-db-grid-table">${thead}<tbody>${body}</tbody></table>`;
wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
btn.disabled = true;
try {
const prev = await fetchJson("/api/admin/db/row/delete-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`;
if (prev.truncated) msg += "\n\n… (в списке не все строки)";
msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?";
if (!window.confirm(msg)) {
setStatus("Удаление отменено");
return;
}
await fetchJson("/api/admin/db/row/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
setStatus(`Удалено строк: ${prev.total}`);
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка удаления");
} finally {
btn.disabled = false;
}
});
});
wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
let original;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
const changes = {};
tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => {
const col = inp.getAttribute("data-col");
if (!col || inp.readOnly) return;
const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined;
const newVal = parseInputValue(inp.value, oldVal);
const same =
(oldVal == null && (newVal === "" || newVal === null)) ||
JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal);
if (!same) {
changes[col] = newVal === "" && oldVal == null ? null : newVal;
}
});
if (!Object.keys(changes).length) {
setStatus("Нет изменений в строке");
return;
}
btn.disabled = true;
try {
await fetchJson("/api/admin/db/row", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
changes,
}),
});
setStatus("Строка сохранена на сервере");
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка сохранения");
} finally {
btn.disabled = false;
}
});
});
const prev = browseState.offset > 0;
const next = browseState.offset + rows.length < total;
pager.innerHTML = `
<button type="button" class="ant-btn ant-btn-sm" id="adminDbPrev" ${prev ? "" : "disabled"}><span>Назад</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="adminDbNext" ${next ? "" : "disabled"}><span>Вперёд</span></button>
`;
document.getElementById("adminDbPrev")?.addEventListener("click", async () => {
browseState.offset = Math.max(0, browseState.offset - browseState.limit);
await loadTablePage(setStatus);
});
document.getElementById("adminDbNext")?.addEventListener("click", async () => {
browseState.offset += browseState.limit;
await loadTablePage(setStatus);
});
}
async function loadTablePage(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (errEl) errEl.textContent = "";
if (!browseState.table) return;
const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`;
const data = await fetchJson(u);
renderGrid(data, setStatus);
}
export async function refreshAdminDbTree(setStatus) {
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || modal.hidden) return;
try {
const tree = await fetchJson("/api/admin/db/tree");
const pref = browseState.bind || "recipes";
const cb = dbTreePickCallback;
if (typeof cb !== "function") {
if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается");
return;
}
renderTree(tree.binds || [], pref, cb);
document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
if (
btn.getAttribute("data-bind") === browseState.bind &&
btn.getAttribute("data-table") === browseState.table
) {
document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
}
});
if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function refreshAdminDbGrid(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (!browseState.table) {
if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве");
return;
}
if (errEl) errEl.textContent = "";
try {
await loadTablePage(setStatus);
if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function openAdminDbBrowser(options = {}) {
const { preferredBind = "recipes", setStatus } = options;
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || !errEl) {
if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true);
return;
}
browseState = {
bind: preferredBind,
table: null,
limit: 50,
offset: 0,
page: null,
};
modal.hidden = false;
document.body.style.overflow = "hidden";
errEl.textContent = "";
document.getElementById("adminDbGridMeta").textContent = "";
document.getElementById("adminDbGridWrap").innerHTML =
'<p class="wesp-db-browser__muted">Выберите таблицу в дереве слева.</p>';
document.getElementById("adminDbPager").innerHTML = "";
try {
const tree = await fetchJson("/api/admin/db/tree");
renderTree(tree.binds || [], preferredBind, async (bind, table) => {
browseState.bind = bind;
browseState.table = table;
browseState.offset = 0;
errEl.textContent = "";
try {
await loadTablePage(setStatus);
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
});
if (typeof setStatus === "function") setStatus("Дерево БД загружено");
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export function wireAdminDbBrowserModal() {
document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal);
document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
/**
* Единый словарь подписей analytics для зоотехника.
*/
(function (global) {
global.WespAnalyticsCopy = {
tabs: {
summary: "Итоги",
comparison: "Сравнение",
journal: "Подробно",
reports: "Отчёты",
alerts: "Отклонения",
},
summary: {
overloadTitle: "Перерасход",
overloadHint: "Насыпали больше, чем в плане",
overloadHintZero: "Перерасхода нет — загрузка в пределах плана",
underloadTitle: "Недогруз",
underloadHint: "Риск для надоя: коровы могли недополучить корм",
underloadHintZero: "Недогруза нет — план выполнен",
netTitle: "Итого по деньгам",
netHintOverload: "Преобладает перерасход — прямой убыток бюджета",
netHintUnderload: "Преобладает недогруз — экономия сейчас, риск падения надоев",
netHintBalanced: "Перерасход и недогруз за период уравновешены",
topTitle: "Где больше всего потеряли",
allAlertsLink: "Все отклонения →",
openStockLink: "Открыть склад →",
empty: "За выбранный период отчётов нет",
exportExcel: "Excel",
},
comparison: {
sectionLoading: "Загрузка",
sectionUnloading: "Выгрузка",
legendBase: "По рецепту",
legendPlan: "На сегодня",
legendActual: "Сделали",
noteExecution: "Ошибка при загрузке",
noteUnloadingExecution: "Ошибка при выгрузке",
mixerRemainderLabel: "Остаток в миксере",
noteExcluded: "Исключено из плана",
noteAdjusted: "План скорректирован",
empty: "За этот период рейсов с отчётами нет",
},
stock: {
bannerTitle: "Обратите внимание:",
daysRecipe: "Хватит по рецепту",
daysAdjusted: "Хватит (с учётом плана)",
},
formatRub(value) {
const n = Number(value) || 0;
return `${Math.round(n).toLocaleString("ru-RU")}`;
},
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,196 @@
/**
* Вкладка «Сравнение» на /reports.
*/
(function (global) {
const COPY = () => global.WespAnalyticsCopy || {};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getDateParams() {
const from = document.getElementById("date-from")?.value || "";
const to = document.getElementById("date-to")?.value || "";
return { date_from: from, date_to: to };
}
function buildUrl() {
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
return `/api/analytics/plan-fact?${params.toString()}`;
}
function barWidth(value, max) {
if (!max || max <= 0) return 0;
return Math.min(100, (Number(value) / max) * 100);
}
function faultBadge(fault, executionLabel) {
const c = COPY().comparison || {};
if (fault === "execution") {
return `<span class="analytics-pf-fault analytics-pf-fault--execution">${escapeHtml(
executionLabel || c.noteExecution || ""
)}</span>`;
}
if (fault === "excluded" || fault === "zootech_ok") {
return `<span class="analytics-pf-fault analytics-pf-fault--excluded">${escapeHtml(c.noteExcluded || "")}</span>`;
}
if (fault === "adjusted" || fault === "zootech") {
return `<span class="analytics-pf-fault analytics-pf-fault--adjusted">${escapeHtml(c.noteAdjusted || "")}</span>`;
}
return "";
}
function legendHtml() {
const c = COPY().comparison || {};
return (
`<div class="analytics-pf-legend">` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--base"></i>${escapeHtml(c.legendBase)}</span>` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--plan"></i>${escapeHtml(c.legendPlan)}</span>` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--actual"></i>${escapeHtml(c.legendActual)}</span>` +
`</div>`
);
}
function renderMetricRows(rows, maxKg, options) {
const executionLabel = options?.executionLabel;
return rows
.map((c) => {
const notes = (c.notes || [])
.map((n) => {
const note = typeof n === "string" ? { text: n, kind: "execution" } : n;
const kind = note.kind === "zootech" ? "zootech" : "execution";
const cls =
kind === "zootech"
? "analytics-pf-note analytics-pf-note--zootech"
: "analytics-pf-note analytics-pf-note--execution";
return `<li class="${cls}">${escapeHtml(note.text)}</li>`;
})
.join("");
const faultBadgeHtml = faultBadge(c.fault, executionLabel);
const compCls =
c.skippedToday && (c.fault === "excluded" || c.fault === "zootech_ok")
? "analytics-pf-comp analytics-pf-comp--excluded"
: options?.rowClass || "analytics-pf-comp";
return (
`<div class="${compCls}">` +
`<div class="analytics-pf-comp__head">` +
`<div class="analytics-pf-comp__name">${escapeHtml(c.name)}</div>` +
faultBadgeHtml +
`</div>` +
`<div class="analytics-pf-bars">` +
`<div class="analytics-pf-bar analytics-pf-bar--base" title="${escapeHtml(COPY().comparison?.legendBase)}"><span style="width:${barWidth(c.baseKg, maxKg)}%"></span><em>${Number(c.baseKg).toFixed(1)}</em></div>` +
`<div class="analytics-pf-bar analytics-pf-bar--plan" title="${escapeHtml(COPY().comparison?.legendPlan)}"><span style="width:${barWidth(c.planTodayKg, maxKg)}%"></span><em>${Number(c.planTodayKg).toFixed(1)}</em></div>` +
`<div class="analytics-pf-bar analytics-pf-bar--actual" title="${escapeHtml(COPY().comparison?.legendActual)}"><span style="width:${barWidth(c.actualKg, maxKg)}%"></span><em>${Number(c.actualKg).toFixed(1)}</em></div>` +
`</div>` +
(notes ? `<ul class="analytics-pf-notes">${notes}</ul>` : "") +
`</div>`
);
})
.join("");
}
function sectionHtml(title, body) {
if (!body) return "";
return (
`<section class="analytics-pf-section">` +
`<h3 class="analytics-pf-section__title">${escapeHtml(title)}</h3>` +
body +
`</section>`
);
}
function renderItem(item) {
const comps = item.components || [];
const groups = item.unloadingGroups || [];
const mixer = item.mixerRemainder || null;
const compMax = Math.max(...comps.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
const unloadMax = Math.max(...groups.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
const compHtml = renderMetricRows(comps, compMax, {
executionLabel: COPY().comparison?.noteExecution,
});
const unloadHtml = renderMetricRows(groups, unloadMax, {
executionLabel: COPY().comparison?.noteUnloadingExecution,
});
const mixerHtml = mixer
? (() => {
const kg = Number(mixer.actualKg);
const sign = kg > 0 ? "+" : "";
const cls =
kg < 0
? "analytics-pf-mixer-line analytics-pf-mixer-line--negative"
: "analytics-pf-mixer-line";
return (
`<p class="${cls}">${escapeHtml(
COPY().comparison?.mixerRemainderLabel || "Остаток в миксере"
)}: <strong>${sign}${kg.toFixed(1)}</strong> кг</p>`
);
})()
: "";
const when = item.startTime ? escapeHtml(item.startTime.slice(0, 16).replace("T", " ")) : "";
const unloadingSection =
groups.length || mixer
? sectionHtml(
COPY().comparison?.sectionUnloading || "Выгрузка",
legendHtml() + unloadHtml + mixerHtml
)
: "";
return (
`<article class="analytics-pf-card" data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
`<header class="analytics-pf-card__head">` +
`<strong>${escapeHtml(item.recipeName)}</strong>` +
`<span class="text-muted small">${when}</span>` +
`</header>` +
sectionHtml(
COPY().comparison?.sectionLoading || "Загрузка",
legendHtml() + compHtml
) +
unloadingSection +
`</article>`
);
}
function render(data) {
const el = document.getElementById("analyticsPlanFactList");
if (!el) return;
const items = data.items || [];
if (!items.length) {
el.innerHTML = `<div class="empty-state zt-empty wesp-content-reveal"><p class="zt-empty__hint">${escapeHtml(COPY().comparison?.empty || "")}</p></div>`;
return;
}
el.innerHTML = `<div class="analytics-pf-list">${items.map(renderItem).join("")}</div>`;
el.querySelectorAll("[data-loading-report-id]").forEach((card) => {
card.addEventListener("click", () => {
const id = card.getAttribute("data-loading-report-id");
if (id) global.WespFeedAlerts?.openReportFromAlert?.(id);
});
});
global.WespZootechModals?.revealContent(el.querySelector(".analytics-pf-list"));
}
async function load() {
const el = document.getElementById("analyticsPlanFactList");
if (!el) return;
el.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка сравнения…</p></div>';
try {
const resp = await fetch(buildUrl());
if (!resp.ok) throw new Error("Не удалось загрузить сравнение");
render(await resp.json());
} catch (err) {
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function onFiltersApplied() {
if (global.WespFeedAlerts?.getCurrentView?.() === "comparison") load();
}
global.WespAnalyticsPlanFact = { load, onFiltersApplied };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,151 @@
/**
* Вкладка «Итоги» на /reports.
*/
(function (global) {
const COPY = () => global.WespAnalyticsCopy || {};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getDateParams() {
const from = document.getElementById("date-from")?.value || "";
const to = document.getElementById("date-to")?.value || "";
return { date_from: from, date_to: to };
}
function buildUrl() {
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
return `/api/analytics/finance?${params.toString()}`;
}
function valueToneClass(kind) {
if (kind === "loss") return "analytics-kpi-card__value--loss";
if (kind === "risk") return "analytics-kpi-card__value--risk";
return "";
}
function netHint(data) {
const c = COPY().summary || {};
if (data.dominantIssue === "overload") return c.netHintOverload || "";
if (data.dominantIssue === "underload") return c.netHintUnderload || "";
return c.netHintBalanced || "";
}
function overloadHint(data) {
const c = COPY().summary || {};
if (Number(data.overloadRub) > 0) return c.overloadHint || "";
return c.overloadHintZero || c.overloadHint || "";
}
function underloadHint(data) {
const c = COPY().summary || {};
if (Number(data.underloadRub) > 0) return c.underloadHint || "";
return c.underloadHintZero || c.underloadHint || "";
}
function netTone(data) {
if (data.dominantIssue === "overload") return "loss";
if (data.dominantIssue === "underload") return "risk";
return "";
}
function renderTopBar(data) {
const top = data.topComponents || [];
if (!top.length) {
return `<p class="text-muted small mb-0">${escapeHtml(COPY().summary?.empty || "")}</p>`;
}
const maxVal = Math.max(
...top.map((row) => Math.max(row.overloadRub, row.underloadRub)),
1
);
const rows = top
.map((row) => {
const isOverload = row.overloadRub > row.underloadRub;
const main = isOverload
? `${COPY().formatRub(row.overloadRub)} перерасход`
: `${COPY().formatRub(row.underloadRub)} недогруз`;
const tone = isOverload ? "analytics-top-row__value--loss" : "analytics-top-row__value--risk";
const widthPct = Math.round(
Math.min(100, (Math.max(row.overloadRub, row.underloadRub) / maxVal) * 1000)
) / 10;
return (
`<div class="analytics-top-row">` +
`<span class="analytics-top-row__name">${escapeHtml(row.name)}</span>` +
`<span class="analytics-top-row__bar analytics-top-row__bar--${isOverload ? "loss" : "risk"}">` +
`<span style="width:${widthPct}%"></span></span>` +
`<span class="analytics-top-row__value ${tone}">${escapeHtml(main)}</span>` +
`</div>`
);
})
.join("");
return `<div class="analytics-top-list">${rows}</div>`;
}
function render(data) {
const el = document.getElementById("analyticsSummaryList");
if (!el) return;
const c = COPY().summary || {};
const overloadTone = Number(data.overloadRub) > 0 ? "loss" : "";
const underloadTone = Number(data.underloadRub) > 0 ? "risk" : "";
const netT = netTone(data);
el.innerHTML =
`<div class="analytics-kpi-grid wesp-content-reveal">` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.overloadTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(overloadTone)}">${escapeHtml(COPY().formatRub(data.overloadRub))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(overloadHint(data))}</div>` +
`</div>` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.underloadTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(underloadTone)}">${escapeHtml(COPY().formatRub(data.underloadRub))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(underloadHint(data))}</div>` +
`</div>` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.netTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(netT)}">${escapeHtml(COPY().formatRub(Math.abs(data.netRub)))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(netHint(data))}</div>` +
`</div>` +
`</div>` +
`<div class="analytics-top-block zt-section wesp-content-reveal">` +
`<h3 class="analytics-section-title">${escapeHtml(c.topTitle)}</h3>` +
renderTopBar(data) +
`</div>` +
`<div class="analytics-links wesp-content-reveal">` +
`<button type="button" class="btn btn-link btn-sm p-0" data-analytics-goto-alerts>${escapeHtml(c.allAlertsLink)}</button>` +
`</div>`;
el.querySelector("[data-analytics-goto-alerts]")?.addEventListener("click", () => {
global.WespFeedAlerts?.openJournalAlerts?.();
});
global.WespZootechModals?.revealContent(el);
}
async function load() {
const el = document.getElementById("analyticsSummaryList");
if (!el) return;
el.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка итогов…</p></div>';
try {
const resp = await fetch(buildUrl());
if (!resp.ok) throw new Error("Не удалось загрузить итоги");
render(await resp.json());
} catch (err) {
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function onFiltersApplied() {
if (global.WespFeedAlerts?.getCurrentView?.() === "summary") load();
}
function init() {
/* экспорт — WespReportsExport */
}
global.WespAnalyticsSummary = { init, load, onFiltersApplied };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,665 @@
/**
* Коррекция СВ% компонентов в плане overlay в стиле K-hub.
* Редактируется только СВ%; превью через /api/recipes/calculate.
*/
(function (global) {
function todayIso() {
return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10);
}
function escapeHtml(value) {
return (
global.WespDailyPlanSkipDuration?.escapeHtml?.(value) ||
String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;")
);
}
function fmtNum(value, digits) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
return n.toFixed(digits).replace(/\.?0+$/, "");
}
function roundWphNum(value) {
const n = Number(value);
if (!Number.isFinite(n)) return NaN;
if (n > 0 && n < 0.01) return 0.01;
return Math.round(n * 100) / 100;
}
function fmtRange(values, digits) {
const nums = values.filter((n) => Number.isFinite(n));
if (!nums.length) return "—";
const min = Math.min(...nums);
const max = Math.max(...nums);
const a = fmtNum(min, digits);
const b = fmtNum(max, digits);
return a === b ? a : `${a}${b}`;
}
function usageEntries(item) {
const usages = Array.isArray(item.usages) ? item.usages : [];
if (usages.length) {
return usages.map((u) => ({
wph: Number(u.masterWeightPerHead),
dmPh: Number(u.masterDryMatterPerHead),
dryMatterLocked: !!u.dryMatterLocked,
}));
}
return [
{
wph: Number(item.masterWeightPerHead),
dmPh: Number(item.masterDryMatterPerHead),
dryMatterLocked: false,
},
];
}
const PHYS_WEIGHT_HINT = "Этот компонент выдается строго по физическому весу";
function itemIsDmEditable(item) {
return itemHasLockedUsage(item);
}
function componentCellHtml(item) {
const editable = itemIsDmEditable(item);
const lockedCount = lockedUsageEntries(item).length;
const trips = Number(item.usageCount);
let meta = "";
if (editable && lockedCount > 0) {
meta = `<div class="zt-k-hub-plan-norms__meta">${lockedCount} рец. с замком СВ</div>`;
} else if (!editable) {
meta = `<div class="zt-k-hub-plan-norms__meta zt-k-hub-plan-norms__meta--weight">${escapeHtml(PHYS_WEIGHT_HINT)}</div>`;
} else if (Number.isFinite(trips) && trips > 1) {
meta = `<div class="zt-k-hub-plan-norms__meta">${trips} рейсов в плане</div>`;
}
const lockIcon = editable
? ""
: `<i class="fas fa-lock zt-k-hub-plan-norms__weight-lock" title="${escapeHtml(PHYS_WEIGHT_HINT)}" aria-hidden="true"></i>`;
return (
`<div class="zt-k-hub-plan-norms__component">` +
`<div class="zt-k-hub-plan-norms__name-row">` +
lockIcon +
`<div class="zt-k-hub-plan-norms__name">${escapeHtml(item.componentName)}</div>` +
`</div>` +
meta +
"</div>"
);
}
function flashWeightLockedHint(input) {
const cell = input?.closest(".zt-k-hub-plan-norms__dm-cell");
if (!cell) return;
let hint = cell.querySelector("[data-norms-weight-hint]");
if (!hint) {
hint = document.createElement("span");
hint.className = "zt-k-hub-plan-norms__weight-hint";
hint.dataset.normsWeightHint = "1";
hint.textContent = PHYS_WEIGHT_HINT;
cell.appendChild(hint);
}
hint.classList.add("is-visible");
clearTimeout(hint._hideTimer);
hint._hideTimer = setTimeout(() => hint.classList.remove("is-visible"), 2800);
}
function notify(msg, type) {
const n = global.WespZootechNotify?.createAdapter?.();
if (!n) return;
if (type === "error") n.error(msg, { skipRecord: true });
else n.success(msg, { skipRecord: true });
}
function overlayHost() {
return document.getElementById("zootechNotificationCenterModal") || document.body;
}
let overlayEl = null;
let items = [];
const rowPreviewTimers = new Map();
let recalcSeq = 0;
let lockedOnlyFilter = true;
function itemHasLockedUsage(item) {
return usageEntries(item).some((entry) => entry.dryMatterLocked);
}
function lockedUsageEntries(item) {
return usageEntries(item).filter((entry) => entry.dryMatterLocked);
}
function clearRowPreviewTimers() {
rowPreviewTimers.forEach((timer) => clearTimeout(timer));
rowPreviewTimers.clear();
}
function closeModal() {
clearRowPreviewTimers();
recalcSeq += 1;
if (overlayEl?.isConnected) overlayEl.remove();
overlayEl = null;
}
function actionBtn(action, attrs, title, icon) {
return (
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
`data-${action}="1" ${attrs} title="${escapeHtml(title)}">` +
`<i class="fas ${icon}" aria-hidden="true"></i></button>`
);
}
function masterPreviewForItem(item) {
const wphs = [];
const dmPhs = [];
for (const entry of usageEntries(item)) {
if (Number.isFinite(entry.wph)) wphs.push(roundWphNum(entry.wph));
if (Number.isFinite(entry.dmPh)) dmPhs.push(entry.dmPh);
}
return {
dmPh: fmtRange(dmPhs, 4),
wph: fmtRange(wphs, 2),
};
}
function previewCompareHtml(before, after) {
const was = before || "—";
const now = after || "—";
if (was === "—" && now === "—") return "—";
if (was === now) {
return `<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>`;
}
return (
`<span class="zt-k-hub-plan-norms-preview zt-k-hub-plan-norms-preview--compare">` +
`<span class="zt-k-hub-plan-norms-preview__was">${escapeHtml(was)}</span>` +
`<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>` +
`</span>`
);
}
async function previewForItem(item, planPct) {
const pct = Number(planPct);
if (!Number.isFinite(pct) || pct < 0) return { dmPh: "—", wph: "—" };
const lockedEntries = lockedUsageEntries(item);
const wphs = [];
const dmPhs = [];
await Promise.all(
lockedEntries.map(async (entry) => {
if (!Number.isFinite(entry.dmPh)) return;
try {
const response = await fetch("/api/recipes/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ingredients: [
{
component_id: item.componentId,
dryMatter: pct,
dryMatterPerHead: entry.dmPh,
},
],
headsCount: 1,
tripPercent: 100,
calculateFromDryMatter: true,
}),
});
if (!response.ok) return;
const result = await response.json();
const calc = result.ingredients?.[0];
if (!calc) return;
dmPhs.push(Number(entry.dmPh));
const rawWph = calc.weightPerHead !== undefined ? calc.weightPerHead : calc.weight_per_head;
if (rawWph != null) wphs.push(roundWphNum(rawWph));
} catch (_) {
/* ignore */
}
})
);
for (const entry of usageEntries(item)) {
if (entry.dryMatterLocked) continue;
if (!Number.isFinite(entry.wph)) continue;
wphs.push(roundWphNum(entry.wph));
if (pct >= 0) dmPhs.push(entry.wph * (pct / 100));
}
return {
dmPh: fmtRange(dmPhs, 4),
wph: fmtRange(wphs, 2),
};
}
function planEditValue(item) {
if (item.planDryMatterPct != null) return item.planDryMatterPct;
return item.masterDryMatterPct ?? item.dryMatterPct;
}
function masterBaseDryMatterLabel(item) {
const catalog = Number(item.dryMatterPct);
if (Number.isFinite(catalog)) {
return `${fmtNum(catalog, 2)}%`;
}
const min = Number(item.masterDryMatterPctMin);
const max = Number(item.masterDryMatterPctMax);
if (Number.isFinite(min) && Number.isFinite(max)) {
const a = fmtNum(min, 2);
const b = fmtNum(max, 2);
return a === b ? `${a}%` : `${a}${b}%`;
}
const master = Number(item.masterDryMatterPct);
if (Number.isFinite(master)) return `${fmtNum(master, 2)}%`;
return null;
}
function baseDryMatterHintHtml(item) {
const label = masterBaseDryMatterLabel(item);
if (!label) return "";
return (
`<span class="zt-k-hub-plan-norms__base" data-norms-base title="СВ% в справочнике компонентов">` +
`База: ${escapeHtml(label)}</span>`
);
}
function setRowPreviewLoading(row) {
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
const wphEl = row.querySelector("[data-norms-preview-wph]");
if (dmPhEl) {
dmPhEl.textContent = "…";
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
}
if (wphEl) {
wphEl.textContent = "…";
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
}
}
function setPreviewCell(el, before, after) {
if (!el) return;
const was = before || "—";
const now = after || "—";
if (was === now) {
el.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
el.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(now)}</span>`;
return;
}
el.classList.add("zt-k-hub-plan-norms-preview-cell--compare");
el.innerHTML = previewCompareHtml(was, now);
}
async function updateRowPreview(row, seq) {
const componentId = row.dataset.componentId;
const item = items.find((x) => String(x.componentId) === String(componentId));
if (!item) return;
const input = row.querySelector("[data-norms-input]");
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
const wphEl = row.querySelector("[data-norms-preview-wph]");
const masterPrev = masterPreviewForItem(item);
if (row.dataset.normsEditable !== "1") {
if (dmPhEl) {
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
dmPhEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.dmPh)}</span>`;
}
if (wphEl) {
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
wphEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.wph)}</span>`;
}
return;
}
const raw = input?.value;
if (raw === "" || raw == null) {
setPreviewCell(dmPhEl, null, null);
setPreviewCell(wphEl, null, null);
return;
}
setRowPreviewLoading(row);
const next = await previewForItem(item, raw);
if (seq !== recalcSeq || !overlayEl?.contains(row)) return;
setPreviewCell(dmPhEl, masterPrev.dmPh, next.dmPh);
setPreviewCell(wphEl, masterPrev.wph, next.wph);
}
function scheduleRowPreview(row, debounceMs) {
if (!row) return;
const componentId = row.dataset.componentId;
if (!componentId) return;
const existing = rowPreviewTimers.get(componentId);
if (existing) clearTimeout(existing);
const seq = recalcSeq;
const delay = debounceMs == null ? 300 : debounceMs;
if (delay <= 0) {
rowPreviewTimers.delete(componentId);
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
return;
}
rowPreviewTimers.set(
componentId,
setTimeout(() => {
rowPreviewTimers.delete(componentId);
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
}, delay)
);
}
function updateAllPreviews() {
overlayEl?.querySelectorAll("[data-norms-row]").forEach((row) => scheduleRowPreview(row, 0));
}
function buildRowHtml(item) {
const editable = itemIsDmEditable(item);
const val = planEditValue(item);
const inputVal = val != null && Number.isFinite(Number(val)) ? Number(val) : "";
const undo = item.adjustedToday
? actionBtn("norms-undo", "", "Вернуть как в рецепте", "fa-undo")
: "";
const replaceBtn = actionBtn(
"norms-replace",
"",
"Заменить компонент во всех рецептах",
"fa-exchange-alt"
);
const applyBtn = editable ? actionBtn("norms-apply", "", "Сохранить для плана", "fa-check") : "";
const rowMods = [
item.adjustedToday ? "ingredient-row--adjusted" : "",
editable ? "" : "ingredient-row--weight-locked",
]
.filter(Boolean)
.join(" ");
const inputClass = editable
? "form-control zt-k-hub-plan-norms-input dry-matter-percent-input"
: "form-control zt-k-hub-plan-norms-input zt-k-hub-plan-norms-input--locked dry-matter-percent-input";
const inputAttrs = editable
? `data-norms-input aria-label="СВ% на план"`
: `readonly tabindex="-1" data-norms-input data-norms-input-locked aria-label="СВ% — только чтение" aria-disabled="true" title="${escapeHtml(PHYS_WEIGHT_HINT)}"`;
return (
`<div class="zt-data-grid__row ingredient-row${rowMods ? " " + rowMods : ""}" ` +
`data-norms-row data-component-id="${escapeHtml(item.componentId)}" ` +
`data-norms-editable="${editable ? "1" : "0"}" role="row">` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-k-hub-plan-norms__component-cell" data-label="Компонент" role="gridcell">` +
componentCellHtml(item) +
"</div>" +
`<div class="zt-data-grid__cell ingredient-detail-cell dry-matter-percent zt-data-grid__cell--num" data-label="СВ, %" role="gridcell">` +
`<div class="zt-k-hub-plan-norms__dm-cell">` +
`<input type="number" class="${inputClass}" step="0.01" min="0" max="100" ` +
`value="${inputVal === "" ? "" : escapeHtml(String(inputVal))}" inputmode="decimal" ${inputAttrs}>` +
baseDryMatterHintHtml(item) +
`</div></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="СВ/гол, кг" role="gridcell">` +
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-dmph>…</span></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="Вес/гол, кг" role="gridcell">` +
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-wph>…</span></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--actions recipe-table-actions-cell" data-label="Действия" role="gridcell">` +
`<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions" role="group">` +
applyBtn +
replaceBtn +
undo +
"</div></div></div>"
);
}
function ensureOverlay() {
closeModal();
overlayEl = document.createElement("div");
overlayEl.className = "zt-k-hub-plan-overlay zt-k-hub-plan-norms";
overlayEl.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-norms__dialog" role="dialog" aria-modal="true">' +
'<div class="zt-k-hub-plan-subdialog__header">' +
'<h3 class="zt-k-hub-plan-subdialog__title">Коррекция СВ% в плане</h3>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-norms-close aria-label="Закрыть">×</button>' +
"</div>" +
'<p class="zt-k-hub-plan-norms__lead">Только на выбранный срок. Рецепты в справочнике не меняются. Пересчёт кг/гол — только где в рецепте включён замок СВ.</p>' +
'<label class="zt-k-hub-plan-replace__search-label">Поиск компонента</label>' +
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-norms-search placeholder="Начните вводить название…" autocomplete="off">' +
'<label class="zt-k-hub-plan-norms__filter">' +
'<span class="zt-checkbox">' +
'<input type="checkbox" class="zt-checkbox__input" data-norms-locked-only checked>' +
'<span class="zt-checkbox__box" aria-hidden="true"></span>' +
"</span>" +
'<span class="zt-k-hub-plan-norms__filter-text">Только компоненты в рецептах с замком СВ</span></label>' +
'<div class="zt-k-hub-plan-norms__table-wrap">' +
'<div class="zt-data-grid zt-data-grid--norms" id="planNormsGrid" role="grid" aria-label="Коррекция норм">' +
'<div class="zt-data-grid__head" role="row">' +
'<div class="zt-data-grid__cell zt-data-grid__cell--head" role="columnheader">Компонент</div>' +
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ, %</div>' +
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ/гол, кг</div>' +
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Вес/гол, кг</div>' +
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--actions" role="columnheader">Действия</div>' +
"</div>" +
'<div class="zt-data-grid__body" data-norms-body role="rowgroup"></div>' +
"</div></div></div>";
overlayEl.querySelector("[data-norms-close]")?.addEventListener("click", closeModal);
overlayEl.addEventListener("click", (e) => {
if (e.target === overlayEl) closeModal();
});
overlayEl.querySelector("[data-norms-search]")?.addEventListener("input", renderRows);
overlayEl.querySelector("[data-norms-locked-only]")?.addEventListener("change", (event) => {
lockedOnlyFilter = !!event.target.checked;
renderRows();
});
overlayEl.querySelector("[data-norms-body]")?.addEventListener("input", (event) => {
const row = event.target.closest("[data-norms-row]");
if (row && event.target.matches("[data-norms-input]:not([data-norms-input-locked])")) {
scheduleRowPreview(row);
}
});
overlayEl.querySelector("[data-norms-body]")?.addEventListener("click", onTableClick);
overlayEl.setAttribute("data-daily-plan-overlay", "1");
const host = overlayHost();
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (el !== overlayEl) el.remove();
});
host.appendChild(overlayEl);
return overlayEl;
}
function filteredItems() {
let list = items;
if (lockedOnlyFilter) {
list = list.filter(itemHasLockedUsage);
}
const q = (overlayEl?.querySelector("[data-norms-search]")?.value || "").trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
if ((item.componentName || "").toLowerCase().includes(q)) return true;
return (item.recipes || []).some((name) => String(name).toLowerCase().includes(q));
});
}
function renderRows() {
const body = overlayEl?.querySelector("[data-norms-body]");
if (!body) return;
const rows = filteredItems();
if (!rows.length) {
body.innerHTML =
'<div class="zt-k-hub-plan-replace__empty zt-k-hub-plan-norms__empty">' +
(lockedOnlyFilter
? "Нет компонентов в рецептах с замком СВ на эту дату"
: "Нет компонентов в плане на эту дату") +
"</div>";
return;
}
body.innerHTML = rows.map((item) => buildRowHtml(item)).join("");
updateAllPreviews();
}
async function loadItems() {
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
if (!dispenserId) {
notify("Сначала выберите кормораздатчик в плане", "error");
return false;
}
const date = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const r = await fetch(
"/api/daily-plan/component-norms?dispenser_id=" +
encodeURIComponent(dispenserId) +
"&date=" +
encodeURIComponent(date),
{ credentials: "same-origin" }
);
if (!r.ok) {
notify("Не удалось загрузить компоненты плана", "error");
return false;
}
const data = await r.json();
items = Array.isArray(data.items) ? data.items : [];
return true;
}
async function applyRow(row) {
if (row.dataset.normsEditable !== "1") {
flashWeightLockedHint(row.querySelector("[data-norms-input-locked]"));
return;
}
const componentId = row.dataset.componentId;
const input = row.querySelector("[data-norms-input]");
const raw = input?.value;
if (raw === "" || raw == null) {
notify("Введите СВ%", "error");
return;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0 || value > 100) {
notify("СВ% должно быть от 0 до 100", "error");
return;
}
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const payload = await skip.skipDurationBody(
planDate,
{ componentId, dryMatter: value },
"На какой срок сохранить коррекцию?",
{
overlayHost: overlayHost(),
keepOverlays: [".zt-k-hub-plan-norms"],
onError: (msg) => notify(msg, "error"),
}
);
if (!payload) return;
const r = await fetch("/api/daily-plan/adjustments/components", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
notify(data.message || "Ошибка сохранения", "error");
return;
}
notify("Коррекция сохранена");
await refreshAfterChange();
}
async function undoRow(row) {
const componentId = row.dataset.componentId;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const r = await fetch(
"/api/daily-plan/adjustments/components?component_id=" +
encodeURIComponent(componentId) +
"&date=" +
encodeURIComponent(planDate),
{ method: "DELETE", credentials: "same-origin" }
);
if (!r.ok) {
const data = await r.json().catch(() => ({}));
notify(data.message || "Не удалось сбросить", "error");
return;
}
notify("СВ% как в рецепте");
await refreshAfterChange();
}
async function replaceRow(row) {
const componentId = row.dataset.componentId;
const item = items.find((x) => String(x.componentId) === String(componentId));
const label = item?.componentName || "Компонент";
const replaceModal = global.WespDailyPlanReplaceModal;
const skip = global.WespDailyPlanSkipDuration;
if (!replaceModal || !skip) return;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
if (!dispenserId) {
notify("Сначала выберите кормораздатчик в плане", "error");
return;
}
const durationHost = overlayHost();
await replaceModal.open({
componentId,
label,
overlayHost: overlayHost(),
keepOverlays: [".zt-k-hub-plan-norms"],
subtitle: "Замена применится во всех рецептах плана, где используется этот компонент.",
onError: (msg) => notify(msg, "error"),
onPick: async (replacementComponentId, replacementName) => {
const body = await skip.skipDurationBody(
planDate,
{ componentId, replacementComponentId, dispenserId },
"На какой срок заменить?",
{
overlayHost: durationHost,
keepOverlays: [".zt-k-hub-plan-norms"],
onError: (msg) => notify(msg, "error"),
}
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/components", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.message || "Не удалось заменить компонент");
}
const count = Number(data.count) || 0;
notify(
count > 1
? `Компонент заменён на «${replacementName}» в ${count} рецептах`
: `Компонент заменён на «${replacementName}»`
);
await refreshAfterChange();
},
});
}
async function refreshAfterChange() {
await loadItems();
renderRows();
global.WespDailyPlanPanel?.reload?.();
if (typeof global.reloadAfterSkipRestore === "function") global.reloadAfterSkipRestore();
}
function onTableClick(event) {
const lockedInput = event.target.closest("[data-norms-input-locked]");
if (lockedInput) {
event.preventDefault();
flashWeightLockedHint(lockedInput);
return;
}
const row = event.target.closest("[data-norms-row]");
if (!row) return;
if (event.target.closest("[data-norms-apply]")) {
event.preventDefault();
applyRow(row);
} else if (event.target.closest("[data-norms-replace]")) {
event.preventDefault();
replaceRow(row);
} else if (event.target.closest("[data-norms-undo]")) {
event.preventDefault();
undoRow(row);
}
}
async function openModal() {
lockedOnlyFilter = true;
ensureOverlay();
const ok = await loadItems();
if (ok) renderRows();
}
global.openDailyPlanComponentNormsModal = openModal;
global.closeDailyPlanComponentNormsModal = closeModal;
})(window);
@@ -0,0 +1,930 @@
/**
* Панель «План на день» внутри hub-модалки «К».
*/
(function (global) {
const DAILY_PLAN_DISPENSER_KEY = "wesp-daily-plan-dispenser";
const ALL_DISPENSERS_ID = "__all_dispensers__";
const ALL_MILLS_ID = "__all_mills__";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatDistribution(group) {
if (group?.distributionLabel) return group.distributionLabel;
const type = group?.distributionType || "percent";
const value = group?.value ?? "";
if (type === "heads") return `${value} гол.`;
return `${value}%`;
}
function todayIso() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function createDailyPlanPanel() {
let rootEl = null;
let dateEl = null;
let dispenserEl = null;
let contentEl = null;
let dispensers = [];
let undoToastTimer = null;
function getSelectedDispenserId() {
return dispenserEl?.value || "";
}
function getSelectedPlanDate() {
return planDateValue();
}
function notifySuccess(message) {
try {
global.WespZootechNotify?.createAdapter?.()?.success?.(message, { skipRecord: true });
} catch {
/* ignore */
}
}
function notifyError(message, fallback) {
try {
global.WespZootechNotify?.createAdapter?.()?.error?.(message, {
fallback: fallback || message,
skipRecord: true,
});
} catch {
/* ignore */
}
}
function renderToolbar() {
return (
'<div class="zt-k-hub-plan__toolbar">' +
'<div class="zt-k-hub-plan__field">' +
'<label class="form-label">Дата</label>' +
'<input type="date" class="form-control form-control-sm" data-daily-plan-date>' +
"</div>" +
'<div class="zt-k-hub-plan__field">' +
'<label class="form-label">Кормораздатчик</label>' +
'<select class="form-control form-control-sm" data-daily-plan-dispenser>' +
'<option value="">Загрузка…</option></select>' +
"</div>" +
'<button type="button" class="btn btn-primary btn-sm zt-k-hub-plan__pdf" data-action="daily-plan-pdf">' +
'<i class="fas fa-file-pdf"></i> Скачать PDF</button>' +
"</div>"
);
}
function mount(container) {
rootEl = container;
rootEl.innerHTML =
'<div class="zt-k-hub-plan">' +
renderToolbar() +
'<div class="zt-k-hub-plan__content" data-daily-plan-content>' +
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>' +
"</div></div>";
dateEl = rootEl.querySelector("[data-daily-plan-date]");
dispenserEl = rootEl.querySelector("[data-daily-plan-dispenser]");
contentEl = rootEl.querySelector("[data-daily-plan-content]");
if (dateEl) dateEl.value = todayIso();
dateEl?.addEventListener("change", () => loadPlan());
dispenserEl?.addEventListener("change", () => {
if (dispenserEl.value) {
try {
localStorage.setItem(DAILY_PLAN_DISPENSER_KEY, dispenserEl.value);
} catch {
/* ignore */
}
}
loadPlan();
});
rootEl.querySelector("[data-action='daily-plan-pdf']")?.addEventListener("click", downloadPdf);
rootEl.addEventListener("click", onPanelClick);
loadDispensers();
}
function planDateValue() {
return dateEl?.value || todayIso();
}
async function onPanelClick(event) {
const btn = event.target.closest("[data-action]");
if (!btn || !rootEl?.contains(btn)) return;
const action = btn.dataset.action;
if (action === "daily-plan-skip-trip") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const recipeName = btn.dataset.recipeName || "Рейс";
if (recipeId) await skipTrip(recipeId, recipeName);
return;
}
if (action === "daily-plan-unskip-trip") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
if (recipeId) await unskipTrip(recipeId);
return;
}
if (action === "daily-plan-skip-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
const label = btn.dataset.label || "Компонент";
if (recipeId && ingredientId) await skipIngredient(recipeId, ingredientId, label);
return;
}
if (action === "daily-plan-unskip-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
if (recipeId && ingredientId) await unskipIngredient(recipeId, ingredientId);
return;
}
if (action === "daily-plan-skip-unloading-group") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const groupId = btn.dataset.groupId;
const label = btn.dataset.label || "Группа";
if (recipeId && groupId) await skipUnloadingGroup(recipeId, groupId, label);
return;
}
if (action === "daily-plan-unskip-unloading-group") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const groupId = btn.dataset.groupId;
if (recipeId && groupId) await unskipUnloadingGroup(recipeId, groupId);
return;
}
if (action === "daily-plan-replace-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
const componentId = btn.dataset.componentId;
const label = btn.dataset.label || "Компонент";
if (recipeId && ingredientId && componentId) {
await openReplaceIngredientModal(recipeId, ingredientId, componentId, label);
}
return;
}
if (action === "daily-plan-undo-replace-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
if (recipeId && ingredientId) await undoReplaceIngredient(recipeId, ingredientId);
return;
}
if (action === "daily-plan-undo-adjust-norm") {
event.preventDefault();
const componentId = btn.dataset.componentId;
if (componentId) await undoComponentNormAdjustment(componentId);
}
}
function formatKg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
if (Number.isInteger(n)) return String(n);
return n.toFixed(2).replace(/\.?0+$/, "");
}
function replacementRowTitle(ing) {
if (!ing?.replacedToday) return "";
if (ing.recalculationMode === "dry_matter") {
return (
`Пересчёт по СВ: ${formatKg(ing.originalDryMatterPerHead)} кг/гол сохранено, ` +
`вес ${formatKg(ing.originalWeightPerHead)}${formatKg(ing.weightPerHead)} кг/гол ` +
`(СВ ${formatKg(ing.originalDryMatterPct)}% → ${formatKg(ing.dryMatterPct)}%)`
);
}
return (
`Пересчёт по весу: ${formatKg(ing.weightPerHead)} кг/гол сохранено, ` +
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)}${formatKg(ing.dryMatterPerHead)} кг/гол`
);
}
function adjustmentRowTitle(ing) {
if (!ing?.adjustedToday) return "";
return (
`Норма в плане: ${formatKg(ing.originalWeightPerHead)}${formatKg(ing.weightPerHead)} кг/гол, ` +
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)}${formatKg(ing.dryMatterPerHead)}`
);
}
function skippedIngredientHint(ing) {
const wph = Number(ing?.baselineWeightPerHead ?? ing?.weightPerHead);
const total = Number(ing?.baselineTotalKg);
if (!Number.isFinite(wph) || !Number.isFinite(total)) return "";
return `Было: ${formatKg(wph)} кг/гол (${formatKg(total)} кг)`;
}
function renderSkippedValueCell(hint) {
if (!hint) {
return '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>';
}
return (
`<td class="zt-k-hub-plan-row--skipped text-muted zt-k-hub-plan-cell--hint" ` +
`data-hint="${escapeHtml(hint)}" title="${escapeHtml(hint)}" aria-label="${escapeHtml(hint)}">—</td>`
);
}
function renderPlanDot(title) {
return (
`<span class="recipe-skip-badge__dot zt-k-hub-plan-row__skip-dot" ` +
`title="${escapeHtml(title)}" aria-hidden="true"></span>`
);
}
function renderSkipDot() {
return renderPlanDot("Исключён из плана на этот день");
}
function renderReplaceDot(title) {
return renderPlanDot(title || "Сегодня заменили компонент в плане");
}
function renderAdjustDot(title = "Сегодня изменили норму в плане") {
return `<span class="zt-k-hub-plan-row__adjust-dot" title="${escapeHtml(title)}" aria-hidden="true"></span>`;
}
function renderRowActionBtn(action, attrs, title, iconClass) {
return (
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
`data-action="${action}" ${attrs} title="${escapeHtml(title)}">` +
`<i class="fas ${iconClass}"></i></button>`
);
}
function renderSkipActionBtn(action, attrs, title) {
return renderRowActionBtn(action, attrs, title, "fa-times");
}
function hubModalEl() {
return (
rootEl?.closest(".modal.wesp-shell-modal") ||
document.getElementById("zootechNotificationCenterModal")
);
}
function planOverlayHost() {
return hubModalEl() || rootEl?.closest(".modal-content") || rootEl;
}
function dismissAllPlanOverlays() {
hubModalEl()
?.querySelectorAll(".zt-k-hub-plan-overlay")
.forEach((el) => el.remove());
}
function appendPlanOverlay(overlay) {
dismissAllPlanOverlays();
overlay.setAttribute("data-daily-plan-overlay", "1");
planOverlayHost()?.appendChild(overlay);
return overlay;
}
function promptSkipDuration(planDate, title = "На какой срок исключить?") {
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return Promise.resolve(null);
return skip.promptSkipDuration({
planDate,
title,
overlayHost: planOverlayHost() || document.body,
beforeAppend: dismissAllPlanOverlays,
onError: notifyError,
});
}
function skipDurationBody(planDate, extra = {}, title) {
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return Promise.resolve(null);
return skip.skipDurationBody(planDate, extra, title);
}
async function openReplaceIngredientModal(recipeId, ingredientId, componentId, label) {
const planDate = planDateValue();
const replaceModal = global.WespDailyPlanReplaceModal;
if (!replaceModal) return;
await replaceModal.open({
componentId,
label,
overlayHost: planOverlayHost() || document.body,
onError: notifyError,
onPick: async (replacementComponentId, replacementName) => {
const body = await skipDurationBody(
planDate,
{ recipeId, ingredientId, replacementComponentId },
"На какой срок заменить?"
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось заменить компонент");
}
await loadPlan();
notifySuccess(`Компонент заменён на «${replacementName}»`);
},
});
}
async function undoReplaceIngredient(recipeId, ingredientId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
ingredient_id: ingredientId,
date: planDate,
});
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось отменить замену");
}
await loadPlan();
notifySuccess("Замена отменена");
} catch (err) {
notifyError(err.message, "Не удалось отменить замену");
}
}
async function undoComponentNormAdjustment(componentId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
component_id: componentId,
date: planDate,
});
const resp = await fetch(`/api/daily-plan/adjustments/components?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть норму");
}
await loadPlan();
notifySuccess("Норма возвращена к мастеру");
if (typeof global.reloadAfterSkipRestore === "function") {
global.reloadAfterSkipRestore();
}
} catch (err) {
notifyError(err.message, "Не удалось вернуть норму");
}
}
async function skipTrip(recipeId, recipeName) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось исключить рейс");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-trip",
`data-recipe-id="${escapeHtml(recipeId)}"`,
`Рейс «${escapeHtml(recipeName)}» убран из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось исключить рейс");
}
}
async function unskipTrip(recipeId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({ recipe_id: recipeId, date: planDate });
const resp = await fetch(`/api/daily-plan/skips?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть рейс");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Рейс снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть рейс");
}
}
async function skipIngredient(recipeId, ingredientId, label) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId, ingredientId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать компонент");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-ingredient",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`,
`Компонент «${escapeHtml(label)}» убран из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось убрать компонент");
}
}
async function unskipIngredient(recipeId, ingredientId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
ingredient_id: ingredientId,
date: planDate,
});
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть компонент");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Компонент снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть компонент");
}
}
async function skipUnloadingGroup(recipeId, groupId, label) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId, unloadingGroupId: groupId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips/unloading-groups", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать группу");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-unloading-group",
`data-recipe-id="${escapeHtml(recipeId)}" data-group-id="${escapeHtml(groupId)}"`,
`Группа «${escapeHtml(label)}» убрана из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось убрать группу");
}
}
async function unskipUnloadingGroup(recipeId, groupId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
unloading_group_id: groupId,
date: planDate,
});
const resp = await fetch(
`/api/daily-plan/skips/unloading-groups?${params.toString()}`,
{ method: "DELETE", headers: { Accept: "application/json" } }
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть группу");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Группа снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть группу");
}
}
function dismissUndoToast() {
if (undoToastTimer) {
clearTimeout(undoToastTimer);
undoToastTimer = null;
}
rootEl?.querySelector("[data-daily-plan-undo]")?.remove();
}
function showUndoToast(unskipAction, dataAttrs, message) {
dismissUndoToast();
const toast = document.createElement("div");
toast.className = "zt-k-hub-plan__undo-toast";
toast.dataset.dailyPlanUndo = "1";
toast.innerHTML =
`<span>${message}</span>` +
`<button type="button" class="btn btn-sm btn-link zt-k-hub-plan__undo-btn" ` +
`data-action="${unskipAction}" ${dataAttrs}>Отменить</button>`;
rootEl?.querySelector(".zt-k-hub-plan")?.appendChild(toast);
undoToastTimer = setTimeout(() => dismissUndoToast(), 8000);
}
async function loadDispensers() {
try {
const resp = await fetch("/api/feed_dispensers?limit=200");
if (!resp.ok) throw new Error("dispensers");
const data = await resp.json();
dispensers = Array.isArray(data) ? data : [];
if (!dispenserEl) return;
if (!dispensers.length) {
dispenserEl.innerHTML = '<option value="">Нет кормораздатчиков</option>';
renderEmpty("Добавьте кормораздатчик в разделе «Кормораздатчики».");
return;
}
const saved = (() => {
try {
return localStorage.getItem(DAILY_PLAN_DISPENSER_KEY) || "";
} catch {
return "";
}
})();
const dispenserItems = dispensers.filter((d) => d.type !== "mill");
const millItems = dispensers.filter((d) => d.type === "mill");
let options =
`<option value="${ALL_DISPENSERS_ID}"${saved === ALL_DISPENSERS_ID ? " selected" : ""}>Все кормораздатчики</option>`;
if (dispenserItems.length) {
options += '<optgroup label="Кормораздатчики">';
options += dispenserItems
.map((d) => {
const id = escapeHtml(d.id);
const name = escapeHtml(d.name || d.id);
const selected = d.id === saved ? " selected" : "";
return `<option value="${id}"${selected}>${name}</option>`;
})
.join("");
options += "</optgroup>";
}
options += `<option value="${ALL_MILLS_ID}"${saved === ALL_MILLS_ID ? " selected" : ""}>Все кормоцеха</option>`;
if (millItems.length) {
options += '<optgroup label="Кормоцеха">';
options += millItems
.map((d) => {
const id = escapeHtml(d.id);
const name = escapeHtml(d.name || d.id);
const selected = d.id === saved ? " selected" : "";
return `<option value="${id}"${selected}>${name}</option>`;
})
.join("");
options += "</optgroup>";
}
dispenserEl.innerHTML = options;
if (!dispenserEl.value) {
dispenserEl.value = saved || ALL_DISPENSERS_ID;
}
loadPlan();
} catch {
if (dispenserEl) {
dispenserEl.innerHTML = '<option value="">Ошибка загрузки</option>';
}
renderEmpty("Не удалось загрузить список кормораздатчиков.");
}
}
function buildPlanUrl(pdf) {
const params = new URLSearchParams();
if (dateEl?.value) params.set("date", dateEl.value);
if (dispenserEl?.value) params.set("dispenser_id", dispenserEl.value);
const base = pdf ? "/api/daily-plan/pdf" : "/api/daily-plan";
return `${base}?${params.toString()}`;
}
async function loadPlan() {
if (!contentEl) return;
if (!dispenserEl?.value) {
renderEmpty("Выберите кормораздатчик.");
return;
}
contentEl.innerHTML =
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>';
try {
const resp = await fetch(buildPlanUrl(false));
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Ошибка загрузки");
}
const plan = await resp.json();
renderPlan(plan);
} catch (err) {
renderEmpty(global.WespUserMessages?.safe?.(err.message, "Не удалось загрузить план.") || "Не удалось загрузить план.");
}
}
function renderEmpty(message) {
if (!contentEl) return;
contentEl.innerHTML =
'<div class="zt-k-hub-plan__empty">' +
'<i class="fas fa-calendar-day"></i>' +
`<p>${escapeHtml(message)}</p></div>`;
}
function renderReplaceBtn(recipeId, ing) {
if (!ing.componentId) return "";
const label = ing.originalName || ing.name || "Компонент";
return renderRowActionBtn(
"daily-plan-replace-ingredient",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ing.id)}" ` +
`data-component-id="${escapeHtml(ing.componentId)}" data-label="${escapeHtml(label)}"`,
"Заменить компонент",
"fa-exchange-alt"
);
}
function renderTotalsColumn(totals, grandTotalKg) {
if (!totals.length) {
return (
'<aside class="zt-k-hub-plan__totals-col">' +
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
'<p class="zt-k-hub-plan__col-empty text-muted">Нет данных</p></aside>'
);
}
let rows = "";
totals.forEach((row) => {
rows += `<tr><td>${escapeHtml(row.name)}</td><td>${escapeHtml(formatKg(row.totalKg))}</td></tr>`;
});
const grand =
grandTotalKg ??
totals.reduce((sum, row) => sum + Number(row.totalKg || 0), 0);
return (
'<aside class="zt-k-hub-plan__totals-col">' +
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
'<div class="zt-k-hub-plan__col-scroll">' +
'<table class="zt-k-hub-plan-table zt-k-hub-plan-table--totals">' +
"<thead><tr><th>Компонент</th><th>Всего, кг</th></tr></thead>" +
`<tbody>${rows}</tbody>` +
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><th>Итого</th><th>${escapeHtml(formatKg(grand))}</th></tr></tfoot>` +
"</table></div></aside>"
);
}
function renderSkippedSection(plan) {
const skippedTrips = plan.skippedTrips || [];
if (!skippedTrips.length) return "";
let rows = "";
skippedTrips.forEach((trip) => {
rows +=
'<li class="zt-k-hub-plan-skipped__item">' +
`<span>${renderSkipDot()}<span class="text-muted">Рейс:</span> ${escapeHtml(trip.recipeName)}</span>` +
'<button type="button" class="btn btn-sm btn-outline-success" ' +
'data-action="daily-plan-unskip-trip" ' +
`data-recipe-id="${escapeHtml(trip.recipeId)}" title="Вернуть в план">` +
'<i class="fas fa-undo"></i> Вернуть</button></li>';
});
return (
'<section class="zt-k-hub-plan-skipped">' +
'<h3 class="zt-k-hub-plan-skipped__title">Исключено из плана</h3>' +
`<ul class="zt-k-hub-plan-skipped__list">${rows}</ul></section>`
);
}
function renderTripsColumn(periods) {
let html = '<div class="zt-k-hub-plan__trips-col"><div class="zt-k-hub-plan__col-scroll">';
periods.forEach((period) => {
const trips = period.trips || [];
if (!trips.length) return;
html +=
`<section class="zt-k-hub-plan__period"><h3 class="zt-k-hub-plan__period-title">${escapeHtml(period.name)}</h3><div class="zt-k-hub-plan__trips">`;
trips.forEach((trip) => {
const recipeId = escapeHtml(trip.recipeId);
const hasAdjustedNorm = (trip.ingredients || []).some((ing) => ing.adjustedToday);
const hasReplacedIngredient = (trip.ingredients || []).some((ing) => ing.replacedToday);
const adjustBadge = hasAdjustedNorm
? ' <span class="recipe-adjust-badge" title="Сегодня изменили норму в плане">' +
'<span class="zt-k-hub-plan-row__adjust-dot" aria-hidden="true"></span>сегодня изменили норму</span>'
: "";
const replaceBadge = hasReplacedIngredient
? ' <span class="recipe-skip-badge" title="Сегодня заменили компонент в плане">' +
'<span class="recipe-skip-badge__dot" aria-hidden="true"></span>заменён компонент</span>'
: "";
const ings = (trip.ingredients || [])
.map((ing) => {
const ingId = escapeHtml(ing.id);
const name = escapeHtml(ing.name);
const skipped = Boolean(ing.skippedToday);
const replaced = Boolean(ing.replacedToday);
const adjusted = Boolean(ing.adjustedToday);
const rowClass = skipped
? "zt-k-hub-plan-row--skipped"
: replaced
? "zt-k-hub-plan-row--replaced"
: adjusted
? "zt-k-hub-plan-row--adjusted"
: "";
const replaceTitle = replacementRowTitle(ing);
const adjustTitle = adjustmentRowTitle(ing);
const skipHint = skipped ? skippedIngredientHint(ing) : "";
const nameCell = skipped
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
: replaced
? `<td class="zt-k-hub-plan-row--replaced" title="${escapeHtml(replaceTitle)}">${renderReplaceDot(replaceTitle)}${name}</td>`
: adjusted
? `<td class="zt-k-hub-plan-row--adjusted" title="${escapeHtml(adjustTitle)}">${renderAdjustDot(adjustTitle)}${name}</td>`
: `<td>${name}</td>`;
const weightCell = skipped
? renderSkippedValueCell(skipHint)
: `<td>${escapeHtml(formatKg(ing.weightPerHead))}</td>`;
const totalCell = skipped
? renderSkippedValueCell(skipHint)
: `<td>${escapeHtml(formatKg(ing.totalKg))}</td>`;
let actionCell = "";
if (ing.id) {
if (skipped) {
actionCell = renderRowActionBtn(
"daily-plan-unskip-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
"Вернуть в план",
"fa-undo"
);
} else {
if (replaced) {
actionCell += renderRowActionBtn(
"daily-plan-undo-replace-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
"Отменить замену",
"fa-undo"
);
} else if (adjusted && ing.componentId) {
actionCell += renderRowActionBtn(
"daily-plan-undo-adjust-norm",
`data-component-id="${escapeHtml(ing.componentId)}"`,
"Вернуть норму",
"fa-undo"
);
} else {
actionCell += renderReplaceBtn(recipeId, ing);
}
actionCell += renderSkipActionBtn(
"daily-plan-skip-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}" data-label="${escapeHtml(ing.originalName || ing.name)}"`,
"Убрать компонент из плана на этот день"
);
}
}
return (
`<tr class="${rowClass}">` +
`${nameCell}${weightCell}${totalCell}` +
`<td class="zt-k-hub-plan-table__actions zt-k-hub-plan-table__actions--multi">${actionCell}</td></tr>`
);
})
.join("");
const groups = (trip.unloadingGroups || [])
.map((g) => {
const groupId = escapeHtml(g.id);
const name = escapeHtml(g.name);
const skipped = Boolean(g.skippedToday);
const nameCell = skipped
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
: `<td>${name}</td>`;
const weightCell = skipped
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
: `<td>${escapeHtml(g.weightKg)}</td>`;
const distCell = skipped
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
: `<td>${escapeHtml(formatDistribution(g))}</td>`;
let actionCell = "";
if (g.id) {
if (skipped) {
actionCell = renderRowActionBtn(
"daily-plan-unskip-unloading-group",
`data-recipe-id="${recipeId}" data-group-id="${groupId}"`,
"Вернуть в план",
"fa-undo"
);
} else {
actionCell = renderSkipActionBtn(
"daily-plan-skip-unloading-group",
`data-recipe-id="${recipeId}" data-group-id="${groupId}" data-label="${name}"`,
"Убрать группу из плана на этот день"
);
}
}
return (
`<tr class="${skipped ? "zt-k-hub-plan-row--skipped" : ""}">` +
`${nameCell}${weightCell}${distCell}` +
`<td class="zt-k-hub-plan-table__actions">${actionCell}</td></tr>`
);
})
.join("");
html +=
'<article class="zt-k-hub-plan-trip zt-card">' +
'<div class="zt-k-hub-plan-trip__head">' +
`<h4 class="zt-k-hub-plan-trip__title">${escapeHtml(trip.recipeName)}${replaceBadge}${adjustBadge}` +
` <span class="text-muted">(${escapeHtml(trip.headsPerTrip)} гол., ${escapeHtml(trip.mixingTimeSec)} с)</span></h4>` +
'<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-trip__skip" ' +
'data-action="daily-plan-skip-trip" ' +
`data-recipe-id="${recipeId}" ` +
`data-recipe-name="${escapeHtml(trip.recipeName)}" ` +
'title="Убрать из плана на этот день">' +
'<i class="fas fa-trash"></i></button></div>' +
'<table class="zt-k-hub-plan-table"><thead><tr><th>Компонент</th><th>кг/гол</th><th>Всего, кг</th><th></th></tr></thead>' +
`<tbody>${ings || '<tr><td colspan="4" class="text-muted">—</td></tr>'}</tbody>` +
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><td>Итого по рейсу</td><td></td><td>${escapeHtml(formatKg(trip.totalWeightKg))}</td><td></td></tr></tfoot></table>`;
if (groups) {
html +=
'<p class="zt-k-hub-plan-trip__sub">Выгрузка</p>' +
'<table class="zt-k-hub-plan-table"><thead><tr><th>Группа</th><th>кг</th><th>Распределение</th><th></th></tr></thead>' +
`<tbody>${groups}</tbody></table>`;
}
html += "</article>";
});
html += "</div></section>";
});
html += "</div></div>";
return html;
}
function renderPlan(plan) {
if (!contentEl) return;
const periods = plan.periods || [];
const skippedTrips = plan.skippedTrips || [];
const hasTrips = periods.some((p) => (p.trips || []).length);
const hasSkipped = skippedTrips.length;
if (!hasTrips && !hasSkipped) {
renderEmpty("Нет периодов или рейсов для выбранного кормораздатчика.");
return;
}
contentEl.innerHTML =
'<div class="zt-k-hub-plan__split">' +
renderTotalsColumn(plan.ingredientTotals || [], plan.ingredientGrandTotalKg) +
renderTripsColumn(periods) +
"</div>" +
renderSkippedSection(plan);
}
function downloadPdf() {
if (!dispenserEl?.value) return;
global.open(buildPlanUrl(true), "_blank", "noopener");
}
function destroy() {
dismissUndoToast();
dismissAllPlanOverlays();
if (rootEl) {
rootEl.removeEventListener("click", onPanelClick);
rootEl.innerHTML = "";
}
rootEl = null;
dateEl = null;
dispenserEl = null;
contentEl = null;
}
return {
mount,
destroy,
dismissAllPlanOverlays,
reload: loadPlan,
getSelectedDispenserId,
getSelectedPlanDate,
};
}
let activePanel = null;
global.WespDailyPlanPanel = {
createDailyPlanPanel,
_setActivePanel(panel) {
activePanel = panel || null;
},
getSelectedDispenserId() {
return activePanel?.getSelectedDispenserId?.() || "";
},
getSelectedPlanDate() {
return activePanel?.getSelectedPlanDate?.() || todayIso();
},
reload() {
return activePanel?.reload?.();
},
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,377 @@
/**
* Контроль отклонений лента событий (не дубль отчётов).
*/
(function (global) {
const EVENT_LABELS = {
OVERLOAD: "Перегруз",
UNDERLOAD: "Недогруз",
LOADING_TIME: "Долгая загрузка",
LOADING_FAST: "Быстрая загрузка",
MIX_TIME: "Смешивание",
LEFT_IN_MIXER: "Остаток в миксере",
};
const EVENT_ICONS = {
OVERLOAD: "fa-arrow-trend-up",
UNDERLOAD: "fa-arrow-trend-down",
LOADING_TIME: "fa-hourglass-half",
LOADING_FAST: "fa-forward",
MIX_TIME: "fa-clock",
LEFT_IN_MIXER: "fa-triangle-exclamation",
};
let currentView = "summary";
let journalSubView = "reports";
let alertsSeverityFilter = "";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pluralDeviations(count) {
const n = Math.abs(Number(count) || 0);
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return `${n} отклонение`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${n} отклонения`;
return `${n} отклонений`;
}
function getDateParams() {
return global.WespReportsFilters?.getDateParams?.() || {
date_from: document.getElementById("date-from")?.value || "",
date_to: document.getElementById("date-to")?.value || "",
};
}
function getDispenserId() {
return global.WespReportsFilters?.getDispenserId?.() || "";
}
function buildAlertsUrl() {
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
if (alertsSeverityFilter) params.set("severity", alertsSeverityFilter);
return `/api/feed-quality/alerts?${params.toString()}`;
}
function buildSummaryUrl() {
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
return `/api/feed-quality/alerts/summary?${params.toString()}`;
}
function deviationClass(devKg, eventType) {
if (eventType === "MIX_TIME" || eventType === "LOADING_TIME" || eventType === "LOADING_FAST") {
return "deviation-significant";
}
if (devKg == null || Number.isNaN(Number(devKg))) return "";
const v = Number(devKg);
if (v > 0) return "deviation-positive";
if (v < 0) return "deviation-negative";
return "";
}
function formatDeltaPct(item) {
if (
item.eventType === "MIX_TIME" ||
item.eventType === "LOADING_TIME" ||
item.eventType === "LOADING_FAST"
) {
if (item.deviationKg == null) return "—";
const sec = Number(item.deviationKg);
return `${sec > 0 ? "+" : ""}${Math.round(sec)} с`;
}
if (item.deviationPct == null) return "—";
const pct = Number(item.deviationPct);
return `${pct > 0 ? "+" : ""}${pct.toFixed(1)}%`;
}
function formatMixingClock(sec) {
const total = Math.max(0, Math.round(Number(sec) || 0));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function objectLabel(item) {
if (item.componentName) return item.componentName;
if (item.groupName) return `группа «${item.groupName}»`;
if (item.eventType === "MIX_TIME") return "смешивание";
if (item.eventType === "LEFT_IN_MIXER") return "миксер";
return "рейс";
}
function buildContextLine(item) {
const parts = [];
const target = item.targetKg;
const actual = item.actualKg;
if (item.eventType === "MIX_TIME") {
if (actual != null && target != null) {
parts.push(`факт ${formatMixingClock(actual)} при плане ${formatMixingClock(target)}`);
}
} else if (item.eventType === "LEFT_IN_MIXER") {
if (actual != null) parts.push(`осталось ${Number(actual).toFixed(1)} кг`);
} else if (item.groupName) {
if (actual != null && target != null) {
parts.push(`выгружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
} else if (actual != null) {
parts.push(`выгружено ${Number(actual).toFixed(1)} кг`);
}
if (item.durationSec != null && Number(item.durationSec) > 0) {
const sec = Number(item.durationSec);
parts.push(sec >= 60 ? `выгрузка ${(sec / 60).toFixed(1)} мин` : `выгрузка ${sec.toFixed(1)} с`);
}
} else if (item.componentName) {
if (actual != null && target != null) {
parts.push(`загружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
} else if (actual != null) {
parts.push(`загружено ${Number(actual).toFixed(1)} кг`);
}
if (item.durationSec != null && Number(item.durationSec) > 0) {
parts.push(`загрузка ${Number(item.durationSec).toFixed(1)} с`);
}
}
if (item.costDeviationRub != null && item.costDeviationRub > 0) {
parts.push(`+${Number(item.costDeviationRub).toFixed(0)}`);
}
return parts.length ? parts.join(" · ") : (item.detail || "").split(":").pop()?.trim() || "";
}
function sortItems(items) {
return [...items].sort((a, b) => {
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return tb - ta;
});
}
async function refreshSummary() {
const chip = document.getElementById("feedAlertsSummaryChip");
if (!chip) return;
try {
const resp = await fetch(buildSummaryUrl());
if (!resp.ok) return;
const data = await resp.json();
const total = Number(data.total) || 0;
if (total <= 0) {
chip.hidden = true;
chip.textContent = "";
return;
}
chip.hidden = false;
const err = Number(data.error) || 0;
chip.textContent =
err > 0
? `${pluralDeviations(total)} (${err} критич.)`
: pluralDeviations(total) + " за период";
} catch {
chip.hidden = true;
}
}
function renderAlerts(items) {
const listEl = document.getElementById("alertsList");
if (!listEl) return;
if (!items || !items.length) {
listEl.innerHTML =
'<div class="empty-state wesp-content-reveal zt-empty">' +
'<div class="empty-icon"><i class="fas fa-check-circle"></i></div>' +
'<h3 class="zt-empty__title">Отклонений нет</h3>' +
'<p class="zt-empty__hint">За выбранный период отклонений не зафиксировано</p></div>';
global.WespZootechModals?.revealContent(listEl.querySelector(".empty-state"));
return;
}
const sorted = sortItems(items);
const html = sorted
.map((item) => {
const sev = item.severity || "warning";
const typeLabel = EVENT_LABELS[item.eventType] || "Отклонение";
const icon = EVENT_ICONS[item.eventType] || "fa-circle-exclamation";
const devClass = deviationClass(item.deviationKg, item.eventType);
const delta = formatDeltaPct(item);
const context = buildContextLine(item);
const when = item.createdAt
? new Date(item.createdAt).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "";
const trip = item.recipeName || "Рейс";
return (
`<button type="button" class="deviation-alert-card list-item zt-card zt-card--interactive deviation-feed-item deviation-feed-item--${escapeHtml(sev)}" ` +
`data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
'<div class="deviation-alert-card__top">' +
`<span class="deviation-feed-item__dot deviation-feed-item__dot--${escapeHtml(sev)}" aria-hidden="true"></span>` +
'<div class="deviation-alert-card__head">' +
`<h3 class="deviation-alert-card__title">` +
`<i class="fas ${icon}" aria-hidden="true"></i>` +
`${escapeHtml(typeLabel)} · ${escapeHtml(objectLabel(item))}` +
"</h3>" +
`<span class="deviation-type-badge deviation-type-badge--${escapeHtml(sev)}">${escapeHtml(sev === "error" ? "Критично" : "Предупр.")}</span>` +
"</div>" +
`<span class="deviation-feed-item__delta ${escapeHtml(devClass)}">${escapeHtml(delta)}</span>` +
"</div>" +
(context ? `<div class="deviation-alert-card__info"><span class="deviation-alert-card__context">${escapeHtml(context)}</span></div>` : "") +
`<div class="deviation-alert-card__meta">${escapeHtml(trip)}${when ? ` · ${escapeHtml(when)}` : ""}</div>` +
'<span class="deviation-feed-item__chevron" aria-hidden="true"><i class="fas fa-chevron-right"></i></span>' +
"</button>"
);
})
.join("");
listEl.innerHTML = `<div class="deviation-feed">${html}</div>`;
listEl.querySelectorAll("[data-loading-report-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-loading-report-id");
if (id) openReportFromAlert(id);
});
});
global.WespZootechModals?.revealContent(listEl.querySelector(".deviation-feed"));
}
async function loadAlerts() {
const listEl = document.getElementById("alertsList");
if (!listEl) return;
listEl.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка отклонений…</p></div>';
try {
const resp = await fetch(buildAlertsUrl());
if (!resp.ok) throw new Error("Не удалось загрузить отклонения");
const data = await resp.json();
renderAlerts(data.items || []);
await refreshSummary();
} catch (err) {
listEl.innerHTML =
`<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function openReportFromAlert(loadingReportId) {
switchView("journal");
setJournalSubView("reports");
global.__wespHighlightReportId = loadingReportId;
if (typeof global.filterAndDisplayReports === "function") {
global.filterAndDisplayReports();
} else if (typeof global.loadReports === "function") {
global.loadReports();
}
}
function openJournalAlerts() {
switchView("journal");
setJournalSubView("alerts");
}
function setJournalSubView(sub) {
journalSubView = sub === "alerts" ? "alerts" : "reports";
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-journal-view-tab") === journalSubView);
});
const alertsContainer = document.querySelector(".reports-container--alerts");
const reportsContainer = document.querySelector(".reports-container--reports");
const severityBlock = document.getElementById("alertsSeverityBlock");
if (reportsContainer) reportsContainer.hidden = journalSubView !== "reports";
if (alertsContainer) alertsContainer.hidden = journalSubView !== "alerts";
if (severityBlock) severityBlock.hidden = journalSubView !== "alerts";
if (journalSubView === "alerts") loadAlerts();
else if (typeof global.loadReports === "function") global.loadReports();
}
function updateToolbarVisibility() {
const journalBlock = document.getElementById("journalSubTabsBlock");
if (journalBlock) journalBlock.hidden = currentView !== "journal";
const severityBlock = document.getElementById("alertsSeverityBlock");
if (severityBlock) {
severityBlock.hidden = currentView !== "journal" || journalSubView !== "alerts";
}
}
function switchView(view) {
const allowed = ["summary", "comparison", "journal"];
currentView = allowed.includes(view) ? view : "summary";
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-reports-view-tab") === currentView);
});
const summaryEl = document.querySelector(".reports-container--summary");
const comparisonEl = document.querySelector(".reports-container--comparison");
const reportsContainer = document.querySelector(".reports-container--reports");
const alertsContainer = document.querySelector(".reports-container--alerts");
if (summaryEl) summaryEl.hidden = currentView !== "summary";
if (comparisonEl) comparisonEl.hidden = currentView !== "comparison";
if (reportsContainer) reportsContainer.hidden = currentView !== "journal" || journalSubView !== "reports";
if (alertsContainer) alertsContainer.hidden = currentView !== "journal" || journalSubView !== "alerts";
updateToolbarVisibility();
if (currentView === "summary") {
global.WespAnalyticsSummary?.load?.();
} else if (currentView === "comparison") {
global.WespAnalyticsPlanFact?.load?.();
} else if (currentView === "journal") {
setJournalSubView(journalSubView);
} else {
refreshSummary();
}
}
function setSeverityFilter(value) {
alertsSeverityFilter = value || "";
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-alerts-severity") === alertsSeverityFilter);
});
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
}
function init() {
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
switchView(btn.getAttribute("data-reports-view-tab"));
});
});
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
if (currentView !== "journal") switchView("journal");
setJournalSubView(btn.getAttribute("data-journal-view-tab"));
});
});
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
btn.addEventListener("click", () => {
setSeverityFilter(btn.getAttribute("data-alerts-severity") || "");
});
});
global.WespAnalyticsSummary?.init?.();
switchView("summary");
refreshSummary();
}
function onFiltersApplied() {
refreshSummary();
global.WespAnalyticsSummary?.onFiltersApplied?.();
global.WespAnalyticsPlanFact?.onFiltersApplied?.();
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
}
global.WespFeedAlerts = {
init,
loadAlerts,
refreshSummary,
switchView,
openReportFromAlert,
openJournalAlerts,
onFiltersApplied,
getCurrentView: () => currentView,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,255 @@
/**
* Модалка настроек контроля отклонений (глобальные пороги и уведомления).
*/
const MODAL_ID = "feedQualitySettingsModal";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function sectionHtml(id, title, fieldsHtml, checksHtml) {
return (
`<section class="fq-settings-section" data-fq-section="${escapeHtml(id)}">` +
'<div class="fq-settings-section__head">' +
`<h4 class="fq-settings-section__title">${escapeHtml(title)}</h4>` +
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="enabled" data-fq-section="${escapeHtml(id)}" checked>` +
" Учитывать</label></div>" +
`<div class="fq-settings-grid">${fieldsHtml}</div>` +
`<div class="fq-settings-checks">${checksHtml}</div></section>`
);
}
function fieldHtml(section, key, label, value, step) {
return (
'<div class="fq-settings-field">' +
`<label>${escapeHtml(label)}</label>` +
`<input type="number" class="form-control form-control-sm" data-fq-field="${escapeHtml(key)}" ` +
`data-fq-section="${escapeHtml(section)}" value="${escapeHtml(value)}" step="${escapeHtml(step)}" min="0">` +
"</div>"
);
}
function notifyChecks(section) {
return (
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="notify_warning" data-fq-section="${escapeHtml(section)}" checked>` +
" Уведомление: предупреждение</label>" +
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="notify_critical" data-fq-section="${escapeHtml(section)}" checked>` +
" Уведомление: критичное</label>"
);
}
function modalTemplate() {
const loading = sectionHtml(
"loading",
"Загрузка компонентов",
fieldHtml("loading", "warning_pct", "Порог предупреждения, %", 10, 0.1) +
fieldHtml("loading", "critical_pct", "Порог критичного, %", 15, 0.1) +
fieldHtml("loading", "warning_min_sec", "Слишком быстро, предупр., с", 3, 1) +
fieldHtml("loading", "critical_min_sec", "Слишком быстро, критично, с", 1, 1) +
fieldHtml("loading", "warning_max_sec", "Слишком долго, предупр., с", 180, 1) +
fieldHtml("loading", "critical_max_sec", "Слишком долго, критично, с", 600, 1),
notifyChecks("loading")
);
const unloading = sectionHtml(
"unloading",
"Выгрузка по группам",
fieldHtml("unloading", "warning_pct", "Порог предупреждения, %", 5, 0.1) +
fieldHtml("unloading", "critical_pct", "Порог критичного, %", 10, 0.1),
notifyChecks("unloading")
);
const mix = sectionHtml(
"mix_time",
"Время смешивания",
fieldHtml("mix_time", "warning_delta_sec", "Предупреждение, с", 30, 1) +
fieldHtml("mix_time", "critical_delta_sec", "Критично, с", 60, 1),
notifyChecks("mix_time")
);
const mixer = sectionHtml(
"left_in_mixer",
"Остаток в миксере",
fieldHtml("left_in_mixer", "warning_min_kg", "Предупреждение, кг", 5, 0.1) +
fieldHtml("left_in_mixer", "warning_min_pct", "Предупреждение, % от партии", 2, 0.1) +
fieldHtml("left_in_mixer", "critical_min_kg", "Критично, кг", 15, 0.1),
notifyChecks("left_in_mixer")
);
return (
`<div class="modal wesp-shell-modal fq-settings-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide modal-dialog--shell-fq">' +
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none fq-settings-modal__content">' +
'<div class="wesp-shell-bsmodal-title-row">' +
'<h2 class="wesp-shell-bsmodal-title">Контроль отклонений</h2>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
"</div>" +
'<div class="wesp-shell-bsmodal-body">' +
`<form id="feedQualitySettingsForm">${loading}${unloading}${mix}${mixer}</form>` +
"</div>" +
'<div class="wesp-shell-bsmodal-actions">' +
'<button type="button" class="wesp-shell-bsmodal-btn" data-bs-dismiss="modal">Отмена</button>' +
'<button type="button" class="wesp-shell-bsmodal-btn wesp-shell-bsmodal-btn-primary" data-action="save-feed-quality-settings">' +
"Сохранить</button>" +
"</div></div></div></div>"
);
}
let modalEl = null;
let notyfAdapter = null;
let currentSettings = null;
let modalStackWired = false;
/** Настройки зоотехника — z-index 3000; shell-модалка Bootstrap по умолчанию ниже. */
const FQ_MODAL_Z = 3100;
const FQ_BACKDROP_Z = 3099;
function isSettingsModalOpen() {
const settings = document.getElementById("settingsModal");
return Boolean(settings && settings.style.display === "block");
}
function raiseModalAboveSettings() {
if (!modalEl) return;
modalEl.style.zIndex = String(FQ_MODAL_Z);
const backdrops = document.querySelectorAll(".modal-backdrop");
const backdrop = backdrops[backdrops.length - 1];
if (backdrop) backdrop.style.zIndex = String(FQ_BACKDROP_Z);
}
function restoreBodyAfterClose() {
const anyBootstrapModalOpen = document.querySelector(".modal.show");
if (!anyBootstrapModalOpen) {
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
document.body.classList.remove("modal-open");
document.body.style.removeProperty("padding-right");
document.body.style.removeProperty("overflow");
}
if (isSettingsModalOpen()) {
document.body.style.overflow = "hidden";
}
}
function wireModalStackHandlers() {
if (!modalEl || modalStackWired) return;
modalStackWired = true;
modalEl.addEventListener("shown.bs.modal", raiseModalAboveSettings);
modalEl.addEventListener("hidden.bs.modal", restoreBodyAfterClose);
}
function ensureModal() {
if (modalEl && modalEl.isConnected) return modalEl;
modalEl = document.getElementById(MODAL_ID);
if (!modalEl) {
document.body.insertAdjacentHTML("beforeend", modalTemplate());
modalEl = document.getElementById(MODAL_ID);
}
globalThis.WespZootechModals?.wireAllShellModals?.();
wireModalStackHandlers();
modalEl.querySelector("[data-action='save-feed-quality-settings']")?.addEventListener("click", saveSettings);
return modalEl;
}
function applySettingsToForm(settings) {
const form = document.getElementById("feedQualitySettingsForm");
if (!form || !settings) return;
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
const section = el.getAttribute("data-fq-section");
const field = el.getAttribute("data-fq-field");
const block = settings[section];
if (!block || field == null) return;
const val = block[field];
if (el.type === "checkbox") {
el.checked = Boolean(val);
} else if (val != null) {
el.value = String(val);
}
});
}
function collectSettingsFromForm() {
const form = document.getElementById("feedQualitySettingsForm");
const out = { loading: {}, unloading: {}, mix_time: {}, left_in_mixer: {} };
if (!form) return out;
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
const section = el.getAttribute("data-fq-section");
const field = el.getAttribute("data-fq-field");
if (!out[section]) out[section] = {};
if (el.type === "checkbox") {
out[section][field] = el.checked;
} else {
out[section][field] = el.value;
}
});
return out;
}
async function loadSettings() {
const resp = await fetch("/api/feed-quality/settings");
if (!resp.ok) throw new Error("Не удалось загрузить настройки");
const data = await resp.json();
currentSettings = data.settings || data;
applySettingsToForm(currentSettings);
}
async function openModal() {
ensureModal();
try {
await loadSettings();
} catch (err) {
notyfAdapter?.error?.(err.message || "Ошибка загрузки");
return;
}
const inst = globalThis.bootstrap?.Modal?.getOrCreateInstance(modalEl);
inst?.show();
requestAnimationFrame(() => {
requestAnimationFrame(raiseModalAboveSettings);
});
}
async function saveSettings() {
const payload = collectSettingsFromForm();
try {
const resp = await fetch("/api/feed-quality/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: payload }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка сохранения");
notyfAdapter?.success?.(
data.reevaluatedReports != null
? `Настройки сохранены (переоценено ${data.reevaluatedReports} рейсов)`
: "Настройки сохранены"
);
globalThis.WespFeedAlerts?.refreshSummary?.();
if (globalThis.WespFeedAlerts?.getCurrentView?.() === "alerts") {
globalThis.WespFeedAlerts.loadAlerts();
}
globalThis.bootstrap?.Modal?.getInstance(modalEl)?.hide();
} catch (err) {
notyfAdapter?.error?.(err.message || "Ошибка сохранения");
}
}
export function initFeedQualitySettingsModal(notyf) {
notyfAdapter = notyf;
ensureModal();
document.addEventListener("click", (event) => {
const el = event.target.closest('[data-action="open-feed-quality-settings"]');
if (!el) return;
event.preventDefault();
openModal();
});
}
if (typeof globalThis !== "undefined") {
globalThis.WespFeedQualitySettings = {
init: initFeedQualitySettingsModal,
open: openModal,
};
}
@@ -0,0 +1,676 @@
(function (global) {
"use strict";
const CREATE_NEW = "__create_new__";
const ACCEPT_EXT = [".xml", ".pdf", ".xlsx", ".xls"];
let parseResult = null;
let components = [];
let selectedFile = null;
const WARN_LABELS = {
omd_from_tdn: "ВРХ через TDN — не lab-grade",
omd_missing: "нет ВРХ — подставим дефолт",
dm_missing: "нет СВ — chemistry on hold",
};
function $(id) {
return document.getElementById(id);
}
function showEl(el) {
if (!el) return;
el.hidden = false;
el.classList.remove("lab-agrostar-reveal");
requestAnimationFrame(() => el.classList.add("lab-agrostar-reveal"));
}
function hideEl(el) {
if (!el) return;
el.hidden = true;
el.classList.remove("lab-agrostar-reveal");
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function importKind() {
return parseResult?.kind || "lab_samples";
}
function isLabSamples() {
return importKind() === "lab_samples";
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error) {
throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
}
return data;
}
async function loadComponents(force) {
if (components.length && !force) return components;
const rows = await api("/api/components?limit=2000");
components = Array.isArray(rows) ? rows : [];
return components;
}
function getSampleByNo(sampleNo) {
return (parseResult?.samples || []).find((s) => s.sampleNo === sampleNo);
}
function buildComponentOptions(matchSource, selectedId) {
const suggested = matchSource?.suggestedComponents || [];
const seen = new Set();
const opts = [
'<option value="">— какой реагент? —</option>',
`<option value="${CREATE_NEW}">+ добавить новый реагент</option>`,
];
for (const m of suggested) {
if (!m.componentId || seen.has(m.componentId)) continue;
seen.add(m.componentId);
const sel = m.componentId === selectedId ? " selected" : "";
opts.push(
`<option value="${escapeHtml(m.componentId)}"${sel}>${escapeHtml(m.name)} (${Math.round(m.score * 100)}%)</option>`,
);
}
for (const c of components) {
if (!c.id || seen.has(c.id)) continue;
const sel = c.id === selectedId ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
}
return opts.join("");
}
function selectComponentForSample(sampleNo, componentId) {
const sel = document.querySelector(
`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`,
);
const sample = getSampleByNo(sampleNo);
if (!sel || !sample) return;
sel.innerHTML = buildComponentOptions(sample, componentId);
sel.value = componentId;
}
function formatUnsupportedNotice(storage) {
if (!storage?.unsupportedCount) return "";
return storage.unsupportedMessage || `${storage.unsupportedCount} показат. AgroStar без полей в WESP`;
}
function notifyCatalogChanged() {
try {
global.dispatchEvent(new CustomEvent("wesp:components-catalog-changed"));
} catch {
/* ignore */
}
}
async function createComponentFromSample(sampleNo, sel) {
const sample = getSampleByNo(sampleNo);
if (!sample || !sel) return;
const preview = sample.preview || {};
const name = (preview.suggestedName || sample.label || "").trim();
const type = preview.suggestedType || "Сочные корма";
if (!name) {
sel.value = "";
setResult("Нет названия для нового реагента", true);
return;
}
sel.disabled = true;
setResult(`Создаём «${name}»…`);
try {
const data = await api("/api/components", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
type,
dry_matter: sample.dryMatterPct || 0,
nutrients: sample.nutrients || {},
}),
});
await loadComponents(true);
selectComponentForSample(sampleNo, data.id);
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
setResult(
`Реагент «${name}» создан — СВ и ${storage.nutrientCount || Object.keys(sample.nutrients || {}).length} показат.` +
(skipNote ? ` ${skipNote}` : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
sel.value = "";
setResult(err.message || "Не удалось создать реагент", true);
} finally {
sel.disabled = false;
}
}
function onComponentSelectChange(e) {
const sel = e.target;
if (!sel.classList?.contains("lab-agrostar-component")) return;
if (sel.value !== CREATE_NEW) return;
const sampleNo = sel.getAttribute("data-sample-no") || "";
createComponentFromSample(sampleNo, sel);
}
function prime(reason) {
global.WespLabHeisenbergPrime?.error?.(reason, "agrostar");
}
function setStatus(text, isError) {
const el = $("labAgrostarStatus");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 mb-0" + (isError ? " lab-agrostar-result--err" : " text-muted");
if (isError && text) prime(text);
}
function setResult(text, isError) {
const el = $("labAgrostarResult");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 " + (isError ? "lab-agrostar-result--err" : "lab-agrostar-result--ok");
if (isError && text) prime(text);
}
function formatWarnings(warnings) {
if (!warnings?.length) return "—";
return warnings
.map((w) => {
const key = String(w).split("=")[0];
const label = WARN_LABELS[key] || w;
return `<span class="lab-agrostar-warn">${escapeHtml(label)}</span>`;
})
.join("");
}
function getSelectedComponentLabel(sampleNo) {
const sel = document.querySelector(`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`);
if (!sel?.value || sel.value === CREATE_NEW) return null;
const opt = sel.options[sel.selectedIndex];
return opt ? opt.text.replace(/\s*\(\d+%\)\s*$/, "").trim() : null;
}
function renderMetaLine(extra) {
const meta = $("labAgrostarMeta");
if (!meta || !parseResult) return;
showEl(meta);
const label = parseResult.sourceLabel || parseResult.labName || "Импорт";
meta.innerHTML =
`<strong>${escapeHtml(label)}</strong>${extra || ""}` +
(parseResult.warnings?.length ? ` · ${escapeHtml(parseResult.warnings.join("; "))}` : "");
}
function setActionButtons() {
const previewBtn = $("labAgrostarPreview");
const applyBtn = $("labAgrostarApply");
const clearBtn = $("labAgrostarClear");
if (previewBtn) previewBtn.disabled = false;
if (clearBtn) clearBtn.disabled = false;
if (applyBtn) {
applyBtn.disabled = !isLabSamples();
applyBtn.title = isLabSamples() ? "" : "Запись в каталог только для лабораторных проб AgroStar";
}
}
function renderLabSamplesTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
if (!wrap || !body || !parseResult) return;
const samples = parseResult.samples || [];
if (!samples.length) {
hideEl(wrap);
return;
}
if (title) title.textContent = "Кому в каталог";
if (head) {
head.innerHTML = `<tr>
<th>Образец</th><th>СВ, %</th><th>Реагент</th><th>Косяки</th>
</tr>`;
}
renderMetaLine(` · образцов: ${samples.length}`);
body.innerHTML = samples
.map((sample) => {
const sid = escapeHtml(sample.sampleNo);
const defaultId = sample.suggestedComponents?.[0]?.componentId || "";
return `<tr data-sample-no="${sid}">
<td>
<div class="fw-semibold">${escapeHtml(sample.label || sample.desc1 || sample.sampleNo)}</div>
<div class="text-muted small">${sid} · ${escapeHtml(sample.datePrinted || "")}</div>
</td>
<td>${sample.dryMatterPct != null ? escapeHtml(sample.dryMatterPct) : "—"}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component" data-sample-no="${sid}">
${buildComponentOptions(sample, defaultId)}
</select>
</td>
<td>${formatWarnings(sample.warnings)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationCompositionTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const lines = parseResult?.feedLines || [];
if (!wrap || !body || !lines.length) return;
if (title) title.textContent = "Состав рациона";
if (head) {
head.innerHTML = `<tr>
<th>Корм</th><th>кг/сут</th><th>руб.</th><th>Реагент WESP</th>
</tr>`;
}
const meta = parseResult.meta || {};
const extra = [
meta.group ? escapeHtml(meta.group) : "",
meta.rationDate ? escapeHtml(meta.rationDate) : "",
meta.totalCostRub ? `Σ ${escapeHtml(meta.totalCostRub)} руб.` : "",
]
.filter(Boolean)
.join(" · ");
renderMetaLine(` · ${lines.length} корм(ов)${extra ? " · " + extra : ""}`);
body.innerHTML = lines
.map((line, idx) => {
const defaultId = line.suggestedComponents?.[0]?.componentId || "";
return `<tr>
<td>${escapeHtml(line.feedName)}</td>
<td>${escapeHtml(line.dailyKg)}</td>
<td>${escapeHtml(line.costRub)}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component lab-agrostar-component--readonly" data-feed-idx="${idx}" disabled>
${buildComponentOptions(line, defaultId)}
</select>
</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationIndicatorsTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const rows = parseResult?.indicators || [];
if (!wrap || !body || !rows.length) return;
if (title) title.textContent = "Показатели рациона";
if (head) {
head.innerHTML = `<tr>
<th>Показатель</th><th>Норма</th><th>Текущий</th><th>Δ</th>
</tr>`;
}
const meta = parseResult.meta || {};
renderMetaLine(
` · ${rows.length} показат.${meta.group ? " · " + escapeHtml(meta.group) : ""}${meta.calcDate ? " · " + escapeHtml(meta.calcDate) : ""}`,
);
body.innerHTML = rows
.map((row) => {
const delta =
row.norm != null && row.current != null ? (row.current - row.norm).toFixed(2) : "—";
return `<tr>
<td>${escapeHtml(row.name)}</td>
<td>${row.norm != null ? escapeHtml(row.norm) : "—"}</td>
<td>${row.current != null ? escapeHtml(row.current) : "—"}</td>
<td>${escapeHtml(delta)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderAfterParse() {
hideSniffPreview();
hideEl($("labAgrostarTableWrap"));
const kind = importKind();
if (kind === "lab_samples") renderLabSamplesTable();
else if (kind === "ration_composition") renderRationCompositionTable();
else if (kind === "ration_indicators") renderRationIndicatorsTable();
}
function renderSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
if (!panel || !body || !parseResult) {
setResult("Нечего нюхать — сначала кинь файл", true);
return;
}
const kind = importKind();
if (kind === "ration_composition") {
renderSniffRationComposition(panel, body);
return;
}
if (kind === "ration_indicators") {
renderSniffRationIndicators(panel, body);
return;
}
renderSniffLabSamples(panel, body);
}
function renderSniffRationComposition(panel, body) {
const lines = parseResult.feedLines || [];
const meta = parseResult.meta || {};
const metaRows = Object.entries(meta)
.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`)
.join("");
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР состав рациона</h3>
<span class="badge text-bg-secondary">${lines.length} корм(ов)</span>
</header>
<p class="small text-muted">Превью · запись рациона в WESP отдельным шагом</p>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-2">
<thead><tr><th>Корм</th><th class="text-end">кг</th><th class="text-end">руб.</th></tr></thead>
<tbody>${lines
.map(
(l) =>
`<tr><td>${escapeHtml(l.feedName)}</td><td class="text-end">${escapeHtml(l.dailyKg)}</td><td class="text-end">${escapeHtml(l.costRub)}</td></tr>`,
)
.join("")}</tbody>
</table>
${metaRows ? `<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>` : ""}
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${lines.length} строк`, false);
}
function renderSniffRationIndicators(panel, body) {
const rows = parseResult.indicators || [];
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР зоопоказатели</h3>
<span class="badge text-bg-secondary">${rows.length} показат.</span>
</header>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Норма</th><th class="text-end">Текущий</th></tr></thead>
<tbody>${rows
.map(
(r) =>
`<tr><td>${escapeHtml(r.name)}</td><td class="text-end">${escapeHtml(r.norm)}</td><td class="text-end fw-semibold">${escapeHtml(r.current)}</td></tr>`,
)
.join("")}</tbody>
</table>
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${rows.length} показат.`, false);
}
function renderSniffLabSamples(panel, body) {
if (!parseResult?.samples?.length) {
setResult("Нечего нюхать — нет проб", true);
return;
}
const labName = parseResult.labName || parseResult.sourceLabel || "AgroStar";
const cards = parseResult.samples.map((sample) => {
const preview = sample.preview;
if (!preview) return "";
const selectedName = getSelectedComponentLabel(sample.sampleNo);
const targetLine = selectedName
? `<p class="lab-agrostar-sniff__target mb-2"><span class="text-muted">Куда запишем:</span> <strong>${escapeHtml(selectedName)}</strong></p>`
: `<p class="lab-agrostar-sniff__target lab-agrostar-sniff__target--warn mb-2">Реагент не выбран — «+ добавить новый реагент»</p>`;
const metaRows = (preview.meta || [])
.map((row) => `<tr><th scope="row">${escapeHtml(row.label)}</th><td>${escapeHtml(row.value)}</td></tr>`)
.join("");
const nutrientRows = (preview.willWrite || [])
.map((row) => {
const sourceCell =
row.sourceValue != null
? `<td class="text-end">${escapeHtml(row.sourceValue)} <span class="text-muted small">${escapeHtml(row.sourceUnit || "")}</span></td>`
: `<td class="text-end text-muted">—</td>`;
return `<tr><td>${escapeHtml(row.label)}</td>${sourceCell}<td class="text-end fw-semibold">${escapeHtml(row.value)}</td><td class="text-muted small">${escapeHtml(row.unit)}</td></tr>`;
})
.join("");
const notes = (preview.notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
const notesBlock = notes
? `<div class="lab-agrostar-sniff__notes mt-3"><div class="small text-muted mb-1">Замечания</div><ul class="small mb-0">${notes}</ul></div>`
: "";
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
const storageBlock = skipNote
? `<p class="small lab-agrostar-sniff__skip mt-3 mb-0">${escapeHtml(skipNote)}</p>`
: `<p class="small text-muted mt-3 mb-0">В каталог: СВ + ${storage.nutrientCount || 0} показат.</p>`;
return `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">${escapeHtml(preview.title || sample.label)}</h3>
<span class="badge text-bg-secondary">${escapeHtml(preview.feedTypeRu || "—")}</span>
</header>
${targetLine}
<div class="row g-3">
<div class="col-md-6"><div class="small text-muted mb-1">Из файла</div>
<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>
</div>
<div class="col-md-6"><div class="small text-muted mb-1">WESP · ${preview.recognizedCount || 0} показат. · % из документа г/кг СВ (×10)</div>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Документ</th><th class="text-end">WESP</th><th></th></tr></thead>
<tbody>${nutrientRows}</tbody>
</table>
</div>
</div>
${notesBlock}${storageBlock}
</article>`;
});
body.innerHTML = cards.join("");
showEl(panel);
setResult(`Понюхали · ${labName} · ${parseResult.samples.length} образц(ов)`, false);
}
function hideSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
hideEl(panel);
if (body) body.innerHTML = "";
}
function collectAssignments() {
if (!parseResult?.samples?.length) return [];
const byNo = new Map(parseResult.samples.map((s) => [s.sampleNo, s]));
const assignments = [];
document.querySelectorAll(".lab-agrostar-component[data-sample-no]").forEach((sel) => {
const sampleNo = sel.getAttribute("data-sample-no") || "";
const sample = byNo.get(sampleNo);
if (!sample) return;
const componentId = sel.value && sel.value !== CREATE_NEW ? sel.value : null;
assignments.push({
sampleNo,
componentId,
dryMatterPct: sample.dryMatterPct,
nutrients: sample.nutrients || {},
warnings: sample.warnings || [],
});
});
return assignments;
}
function fileAllowed(name) {
const lower = (name || "").toLowerCase();
return ACCEPT_EXT.some((ext) => lower.endsWith(ext));
}
async function uploadFile(file) {
if (!file) return;
if (!fileAllowed(file.name)) {
setStatus("Формат не тот — xml, pdf или xlsx", true);
return;
}
selectedFile = file;
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
showEl(fileNameEl);
fileNameEl.textContent = file.name;
}
setStatus("Разбираем поставку…");
setResult("");
hideSniffPreview();
const fd = new FormData();
fd.append("file", file);
try {
parseResult = await api("/api/lab/import/parse", { method: "POST", body: fd });
await loadComponents();
renderAfterParse();
const kind = importKind();
if (kind === "lab_samples") {
setStatus(`На столе: ${parseResult.sampleCount || 0} образц(ов) · ${parseResult.sourceLabel || ""}`);
} else if (kind === "ration_composition") {
setStatus(`Рацион: ${parseResult.feedCount || 0} корм(ов) · ${parseResult.sourceLabel || ""}`);
} else {
setStatus(`Показатели: ${parseResult.indicatorCount || 0} · ${parseResult.sourceLabel || ""}`);
}
} catch (err) {
parseResult = null;
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
setStatus(err.message || "Файл не разобрался", true);
}
}
async function runApply() {
if (!isLabSamples()) {
setResult("Запись в каталог — только для лабораторных проб AgroStar", true);
return;
}
const assignments = collectAssignments();
if (!assignments.length) {
setResult("Нечего готовить — сначала кинь файл", true);
return;
}
const missing = assignments.filter((a) => !a.componentId);
if (missing.length) {
setResult(`Укажи реагент для ${missing.length} образц(ов) — Jesse бы не забыл`, true);
return;
}
setResult("Пишем в каталог…");
try {
const data = await api("/api/lab/import/agrostar-xml/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assignments, dryRun: false }),
});
const lines = (data.results || []).map(
(r) => `${r.sampleNo}: ${r.skipped ? "мимо" : r.componentName || "—"} (${r.nutrientCount} показат.)`,
);
setResult(
`Запомнили · в деле ${data.applied}, мимо ${data.skipped}` +
(lines.length ? "\n" + lines.join("\n") : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
setResult(err.message || "Наука не сработала — импорт сгорел", true);
}
}
function clearAll() {
parseResult = null;
selectedFile = null;
const input = $("labAgrostarFile");
if (input) input.value = "";
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
hideEl(fileNameEl);
fileNameEl.textContent = "";
}
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
hideSniffPreview();
$("labAgrostarPreview").disabled = true;
$("labAgrostarApply").disabled = true;
$("labAgrostarClear").disabled = true;
setStatus("");
setResult("");
}
function bindDropZone() {
const drop = $("labAgrostarDrop");
const input = $("labAgrostarFile");
if (!drop || !input) return;
drop.addEventListener("click", () => input.click());
drop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
input.addEventListener("change", () => {
const file = input.files?.[0];
if (file) uploadFile(file);
});
["dragenter", "dragover"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.add("lab-agrostar-drop--active");
});
});
["dragleave", "drop"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove("lab-agrostar-drop--active");
});
});
drop.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (file) uploadFile(file);
});
}
function init() {
if (!$("labAgrostarDrop")) return;
bindDropZone();
$("labAgrostarTableWrap")?.addEventListener("change", onComponentSelectChange);
$("labAgrostarPreview")?.addEventListener("click", renderSniffPreview);
$("labAgrostarApply")?.addEventListener("click", runApply);
$("labAgrostarClear")?.addEventListener("click", clearAll);
loadComponents().catch(() => {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
@@ -0,0 +1,104 @@
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function openLabAnimalProfilesModal() {
const resp = await fetch("/api/lab/animal-profiles");
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
global.WespZootechNotify?.createAdapter?.()?.error?.(data.message || "Ошибка загрузки профилей");
return;
}
const profiles = data.profiles || [];
const options = profiles
.map(
(p) =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
)
.join("");
const html =
`<div class="lab-profiles-editor">` +
`<p class="small text-muted mb-2">Профили норм стада (зоотех). Выберите для просмотра или создайте новый.</p>` +
`<label class="form-label">Существующие</label>` +
`<select class="form-select mb-2" id="labProfilePick"><option value="">—</option>${options}</select>` +
`<div id="labProfileView" class="small mb-3 text-muted" hidden></div>` +
`<hr>` +
`<label class="form-label">Новый / редактирование</label>` +
`<input class="form-control form-control-sm mb-1" id="labProfileKey" placeholder="Ключ (beef_grow)">` +
`<input class="form-control form-control-sm mb-1" id="labProfileLabel" placeholder="Название">` +
`<select class="form-select form-select-sm mb-1" id="labProfileType">` +
`<option value="BEEF">Мясное</option><option value="DAIRY">Дойное</option></select>` +
`<textarea class="form-control form-control-sm" id="labProfileNorms" rows="4" placeholder='{"dry_matter":{"min":4000,"max":5000}}'></textarea>` +
`</div>`;
const ok = await global.WespDialog?.confirm?.(html, {
title: "Профили норм стада",
html: true,
confirmText: "Сохранить",
cancelText: "Закрыть",
});
if (!ok) return;
const pick = document.getElementById("labProfilePick")?.value;
const key = document.getElementById("labProfileKey")?.value?.trim();
const label = document.getElementById("labProfileLabel")?.value?.trim();
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
let normsData = {};
try {
const raw = document.getElementById("labProfileNorms")?.value?.trim();
if (raw) normsData = JSON.parse(raw);
} catch (_) {
global.WespZootechNotify?.createAdapter?.()?.error?.("Некорректный JSON норм");
return;
}
if (!key || !label) {
global.WespZootechNotify?.createAdapter?.()?.error?.("Укажите ключ и название");
return;
}
const method = pick ? "PUT" : "POST";
const url = pick
? `/api/lab/animal-profiles/${encodeURIComponent(pick)}`
: "/api/lab/animal-profiles";
try {
const saveResp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profileKey: key, label, rationType, normsData }),
});
const saveData = await saveResp.json();
if (!saveResp.ok) throw new Error(saveData.message || "Ошибка сохранения");
global.WespZootechNotify?.createAdapter?.()?.success?.("Профиль сохранён");
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось сохранить профиль" });
}
}
document.addEventListener("change", (ev) => {
if (ev.target?.id !== "labProfilePick") return;
const id = ev.target.value;
const view = document.getElementById("labProfileView");
if (!id || !view) return;
fetch(`/api/lab/animal-profiles/${encodeURIComponent(id)}`)
.then((r) => r.json())
.then((p) => {
document.getElementById("labProfileKey").value = p.profileKey || "";
document.getElementById("labProfileLabel").value = p.label || "";
document.getElementById("labProfileType").value = p.rationType || "BEEF";
document.getElementById("labProfileNorms").value = JSON.stringify(p.normsData || {}, null, 2);
view.hidden = false;
view.textContent = `${p.profileKey} · ${p.rationType}`;
})
.catch(() => {});
});
global.openLabAnimalProfilesModal = openLabAnimalProfilesModal;
})(window);
@@ -0,0 +1,330 @@
(function (global) {
"use strict";
/** Показатели для зоотех-расчёта рациона (weighted average). */
const NUTRIENT_ROWS = [
{
label: "Осн. корм",
unit: "г/кг СВ",
keys: ["Осн.Корм"],
hint: "СВ этого сырья, учитываемое как объёмный (основной) корм в норме dm_main. 0 — концентрат/добавка.",
},
{ label: "Сыр. протеин", unit: "г/кг", keys: ["Сыр. Протеин"] },
{ label: "уСП", unit: "г/кг", keys: ["уСП"] },
{ label: "RNB", unit: "г/кг", keys: ["RNB", "БРА", " БРА "] },
{ label: "ЧЭЛ — КРС", unit: "МДж/кг", keys: ["ЧЭЛ- КРС", " ЧЭЛ- КРС"] },
{ label: "ОЭ — КРС", unit: "МДж/кг", keys: ["ОЭ-КРС", " ОЭ-КРС"] },
{ label: "Сырая клетчатка", unit: "г/кг", keys: ["Сырая клетч", "Сырая клетчатка"] },
{ label: "Структур. клетч.", unit: "г/кг", keys: ["Структур клетч", "Структур. клетч", "Структур. клетчатка"] },
{ label: "Сырой жир", unit: "г/кг", keys: ["Сырой жир"] },
];
/** Лаб. ввод для native derive (см. LAB_FORMULAS.md). СВ — только component.dry_matter (%). */
const LAB_INPUT_ROWS = [
{ label: "Сырая зола", unit: "г/кг", keys: ["Сырая зола"] },
{
label: "ВРХ орг. вещ.",
unit: "%",
keys: ["ВРХ Орг Вещ"],
hint: "Обязательно для грубых и сочных кормов. Без значения подставится дефолт WESP (65% / 72%).",
},
{
label: "уСП в ОР",
unit: "г/кг",
keys: ["уСП в ОР", "уСП в ОВ"],
hint: "Лабораторное уСП: при значении > 1 заменяет расчёт по формуле Lebzien.",
},
{ label: "КРС протеин", unit: "%", keys: ["КРС Протеин"] },
{ label: "КРС сырой жир", unit: "%", keys: ["КРС Сырой жир"] },
{ label: "КРС сырая клетч.", unit: "%", keys: ["КРС Сырая клетч"] },
{ label: "КРС БЭВ", unit: "%", keys: ["КРС БЭВ"] },
{ label: "НДК", unit: "г/кг", keys: ["НДК", "НДК Общ"] },
{ label: "NFC", unit: "г/кг", keys: ["NFC", "БЕР"] },
];
/** Минералы, аминокислоты и прочий лаб. ввод из AgroStar / импорта. */
const EXTRA_LAB_ROWS = [
{ label: "КДК (ADF)", unit: "г/кг", keys: ["КДК", "КДК общ"] },
{ label: "Ca", unit: "г/кг", keys: ["Ca"] },
{ label: "P", unit: "г/кг", keys: ["P"] },
{ label: "Mg", unit: "г/кг", keys: ["Mg"] },
{ label: "K", unit: "г/кг", keys: ["K"] },
{ label: "S", unit: "г/кг", keys: ["S"] },
{ label: "Cl", unit: "г/кг", keys: ["CL", "Cl"] },
{ label: "Сахар", unit: "г/кг", keys: ["Сахар"] },
{ label: "Крахмал", unit: "г/кг", keys: ["Крахмал"] },
{ label: "Нераств. протеин", unit: "%", keys: ["% нераствор протеин"] },
{ label: "Лизин", unit: "г/кг", keys: ["Лизин"] },
{ label: "Метионин", unit: "г/кг", keys: ["Метионин"] },
{ label: "Лейцин", unit: "г/кг", keys: ["Лейцин"] },
{ label: "Изолейцин", unit: "г/кг", keys: ["Изолейцин"] },
{ label: "Валин", unit: "г/кг", keys: ["Валин"] },
];
const ALL_FORM_ROWS = [...NUTRIENT_ROWS, ...LAB_INPUT_ROWS, ...EXTRA_LAB_ROWS];
const CARD_INFO_COLUMNS = [
{
label: "Сухое вещество",
value(component) {
const dm = component.dryMatter ?? component.dry_matter;
return dm == null || dm === "" ? "—" : `${dm}%`;
},
},
{
label: "№ справочника",
value(component) {
const n = component.externalNo ?? component.external_no;
return n == null || n === "" ? "—" : String(n);
},
},
{
label: "Цена",
value(component) {
const price = component.price;
return price == null || price === "" ? "—" : `${price} руб/кг`;
},
},
{
label: "Осн. корм",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["Осн.Корм"]);
return val == null ? "—" : `${val} г/кг`;
},
},
{
label: "Сыр. протеин",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["Сыр. Протеин"]);
return val == null ? "—" : `${val} г/кг`;
},
},
{
label: "ОЭ — КРС",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["ОЭ-КРС", " ОЭ-КРС"]);
return val == null ? "—" : `${val} МДж/кг`;
},
},
{
label: "СВ (расч.)",
value(component) {
const dm = component.dryMatter ?? component.dry_matter;
if (dm == null || dm === "") return "—";
const n = Number(dm);
if (Number.isNaN(n)) return "—";
return `${(n * 10).toFixed(1)} г/кг`;
},
},
{
label: "Показателей",
value(_component, nutrients) {
return String(Object.keys(nutrients || {}).length);
},
},
];
function normalizeKey(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function parseNutrients(raw) {
if (!raw) return {};
if (typeof raw === "object") return { ...raw };
try {
return JSON.parse(raw) || {};
} catch (_) {
return {};
}
}
function readNutrientValue(nutrients, keys) {
const map = nutrients || {};
for (const search of keys) {
const target = normalizeKey(search);
for (const [k, v] of Object.entries(map)) {
if (normalizeKey(k) !== target) continue;
const n = Number(v);
if (!Number.isNaN(n)) return n;
}
}
return null;
}
function canonicalKey(keys) {
return keys[0];
}
function buildNutrientsPayload(root) {
const payload = {};
if (!root) return payload;
root.querySelectorAll("[data-nutrient-key]").forEach((input) => {
const key = input.getAttribute("data-nutrient-key");
const val = input.value.trim();
if (key && val !== "") payload[key] = Number(val);
});
return payload;
}
function rowFieldsHtml(rows, nutrients) {
return rows
.map((row) => {
const key = canonicalKey(row.keys);
const val = readNutrientValue(nutrients, row.keys);
const hint = row.hint ? ` title="${row.hint}"` : "";
return (
`<div class="lab-nutrient-field"${hint}>` +
`<label class="form-label small mb-0 lab-nutrient-field__label">${row.label}` +
`<span class="text-muted"> (${row.unit})</span></label>` +
`<input type="number" step="0.01" class="form-control form-control-sm" ` +
`data-nutrient-key="${key}" value="${val == null ? "" : val}" placeholder="—">` +
`</div>`
);
})
.join("");
}
const _OMD_FEED_TYPES = new Set(["грубые корма", "сочные корма", "объемные корма", "объёмные корма"]);
function feedGroupNeedsOmd(component) {
if (!component || !component.type) return false;
return _OMD_FEED_TYPES.has(normalizeKey(component.type));
}
function omdWarningHtml(component, nutrients) {
if (!feedGroupNeedsOmd(component)) return "";
if (readNutrientValue(nutrients, ["ВРХ Орг Вещ", "КРС Орг Вещ"]) != null) return "";
return (
`<div class="alert alert-warning py-2 px-3 small mb-2" role="status">` +
`Для грубых и сочных кормов укажите <strong>ВРХ орг. вещ.</strong> — ` +
`без анализа расчёт энергии и уСП будет ненадёжным.` +
`</div>`
);
}
function nutrientsFormHtml(nutrients, component) {
const calcRows = rowFieldsHtml(NUTRIENT_ROWS, nutrients);
const labRows = rowFieldsHtml(LAB_INPUT_ROWS, nutrients);
const extraRows = rowFieldsHtml(EXTRA_LAB_ROWS, nutrients);
const warn = omdWarningHtml(component, nutrients);
return (
warn +
`<div class="lab-nutrients-block" data-lab-nutrients-block>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-flask text-primary me-1"></i>` +
`<span class="lab-nutrients-block__title">Показатели для расчёта рациона</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--calc">${calcRows}</div>` +
`</div>` +
`<div class="lab-nutrients-block lab-nutrients-block--lab-input mt-3" data-lab-nutrients-lab>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-vial text-secondary me-1"></i>` +
`<span class="lab-nutrients-block__title">Лабораторный ввод (derive)</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--lab">${labRows}</div>` +
`</div>` +
`<div class="lab-nutrients-block lab-nutrients-block--extra mt-3" data-lab-nutrients-extra>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-atom text-secondary me-1"></i>` +
`<span class="lab-nutrients-block__title">Минералы и аминокислоты</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--extra">${extraRows}</div>` +
`</div>`
);
}
function mountCreateForm() {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function mountEditForm() {
const mount = document.getElementById("labNutrientsEditMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function resetCreateForm() {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function fillCreateFromComponent(component) {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount || !component) return;
const nutrients = parseNutrients(component.nutrients);
mount.innerHTML = nutrientsFormHtml(nutrients, component);
mount.dataset.mounted = "1";
}
function fillEditNutrients(component) {
const mount = document.getElementById("labNutrientsEditMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml(parseNutrients(component.nutrients), component);
mount.dataset.mounted = "1";
}
function formatCardItems(component) {
const nutrients = parseNutrients(component.nutrients);
return CARD_INFO_COLUMNS.map((col) => {
const value = col.value(component, nutrients);
return {
label: col.label,
value,
empty: value === "—",
};
});
}
function renderCardInfoHtml(component) {
if (!global.WESP_CAN_LAB) return "";
return formatCardItems(component)
.map(
(item) =>
`<div class="info-item${item.empty ? " info-item--empty" : ""}">` +
`<span class="info-label">${item.label}</span>` +
`<span class="info-value">${item.value}</span>` +
`</div>`
)
.join("");
}
function collectFromCreateForm() {
return buildNutrientsPayload(document.getElementById("labNutrientsCreateMount"));
}
function collectFromEditForm() {
return buildNutrientsPayload(document.getElementById("labNutrientsEditMount"));
}
global.WespLabComponentsNutrients = {
NUTRIENT_ROWS,
LAB_INPUT_ROWS,
EXTRA_LAB_ROWS,
normalizeKey,
readNutrientValue,
parseNutrients,
nutrientsFormHtml,
mountCreateForm,
mountEditForm,
resetCreateForm,
fillCreateFromComponent,
fillEditNutrients,
renderCardInfoHtml,
collectFromCreateForm,
collectFromEditForm,
init() {
if (!global.WESP_CAN_LAB) return;
mountCreateForm();
mountEditForm();
},
};
})(window);
@@ -0,0 +1,601 @@
(function (global) {
"use strict";
const DEFAULT_OPTIMIZE_KEYS = [
"dry_matter",
"usp",
"nel",
"crude_protein",
"rnb",
"nfc_pct_dm_uk",
];
let catalog = [];
let groups = [];
let profiles = [];
let currentStep = 1;
const selections = {};
function formulateError(message) {
global.WespLabHeisenbergPrime?.error?.(message, "formulate");
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
}
function diffClass(diff) {
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
const d = Number(diff);
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
return "lab-sandbox-diff--bad";
}
function totalSteps() {
return groups.length + 1;
}
function groupById(id) {
return groups.find((g) => g.id === id);
}
function selectedForGroup(gid) {
return Array.from(document.querySelectorAll(`.lab-formulate-gcb[data-group="${gid}"]:checked`)).map(
(el) => el.value
);
}
function allSelections() {
const out = {};
groups.forEach((g) => {
out[g.id] = selectedForGroup(g.id);
});
return out;
}
function totalPicked() {
return Object.values(allSelections()).reduce((n, arr) => n + arr.length, 0);
}
function componentsForGroup(gid, filterText) {
const q = (filterText || "").trim().toLowerCase();
return catalog.filter((c) => {
if (!c.eligible || c.feedGroup !== gid) return false;
if (q && !String(c.name || "").toLowerCase().includes(q)) return false;
return true;
});
}
function renderGroupTable(gid, filterText) {
const body = document.querySelector(`#labFormulatePane-${gid} tbody`);
if (!body) return;
const picked = new Set(selectedForGroup(gid));
const rows = componentsForGroup(gid, filterText);
if (!rows.length) {
body.innerHTML =
'<tr><td colspan="4" class="lab-sandbox-empty">В этой фракции пусто — нечего готовить</td></tr>';
return;
}
body.innerHTML = rows
.map((c) => {
const checked = picked.has(c.id) ? " checked" : "";
return (
`<tr>` +
`<td><input type="checkbox" class="form-check-input lab-formulate-gcb" data-group="${escapeHtml(gid)}" value="${escapeHtml(c.id)}"${checked}></td>` +
`<td>${escapeHtml(c.name)}</td>` +
`<td>${formatNum(c.mainFeedDmGPerKg)}</td>` +
`<td>${formatNum(c.dryMatterPct)}%</td>` +
`</tr>`
);
})
.join("");
}
function buildWizard() {
const stepsEl = document.getElementById("labFormulateSteps");
const panesEl = document.getElementById("labFormulatePanes");
if (!stepsEl || !panesEl) return;
stepsEl.innerHTML =
groups
.map(
(g, i) =>
`<button type="button" class="lab-formulate-step${i === 0 ? " lab-formulate-step--active" : ""}" data-step="${i + 1}">${i + 1}. ${escapeHtml(g.shortLabel)}</button>`
)
.join("") +
`<button type="button" class="lab-formulate-step" data-step="${groups.length + 1}">${groups.length + 1}. Кристалл</button>`;
panesEl.innerHTML = groups
.map((g) => {
const req = g.required
? `<span class="lab-formulate-badge lab-formulate-badge--ok">без этого никак</span>`
: `<span class="lab-formulate-badge">не мешай</span>`;
return (
`<div id="labFormulatePane-${g.id}" class="lab-formulate-pane${g.step === 1 ? "" : " d-none"}" data-group="${g.id}">` +
`<div class="d-flex justify-content-between align-items-center mb-2">` +
`<div class="lab-sandbox-panel__title mb-0">${escapeHtml(g.label)}</div>${req}</div>` +
`<p class="small text-muted mb-2">${escapeHtml(g.hint)}</p>` +
`<p class="small mb-2 lab-formulate-group-count" data-group="${g.id}">В партии: 0</p>` +
`<div class="lab-sandbox-field">` +
`<input type="search" class="form-control form-control-sm lab-formulate-filter" data-group="${g.id}" placeholder="Найти реагент">` +
`</div>` +
`<div class="table-responsive lab-formulate-pool-wrap">` +
`<table class="table table-sm lab-sandbox-table mb-0">` +
`<thead><tr><th></th><th>Реагент</th><th>Осн.</th><th>СВ%</th></tr></thead>` +
`<tbody></tbody></table></div>` +
`<div class="lab-sandbox-actions mt-2">` +
(g.step > 1
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-back" data-step="${g.step}">← Откат</button>`
: "") +
(!g.required
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-skip" data-step="${g.step}">Не мешай</button>`
: "") +
`<button type="button" class="btn btn-sm btn-primary lab-formulate-next" data-step="${g.step}">Вперёд →</button>` +
`</div></div>`
);
})
.join("");
groups.forEach((g) => renderGroupTable(g.id, ""));
}
function updateCounters() {
const sel = allSelections();
groups.forEach((g) => {
const el = document.querySelector(`.lab-formulate-group-count[data-group="${g.id}"]`);
if (el) {
const n = (sel[g.id] || []).length;
el.textContent = g.required ? `В партии: ${n} (мин. ${g.minPick})` : `В партии: ${n}`;
}
});
const total = totalPicked();
const summaryEl = document.getElementById("labFormulateSummary");
const prefilter = document.getElementById("labFormulatePrefilterNote");
const roughOk = (sel.rough || []).length >= 1;
if (summaryEl) {
summaryEl.textContent = roughOk && total >= 3
? `Партия из ${total} реагентов — можно готовить`
: `Нужна база (≥1) и ≥3 реагента всего — сейчас ${total}. Нам нужно готовить.`;
}
if (prefilter) prefilter.classList.toggle("d-none", total <= 18);
const runBtn = document.getElementById("labFormulateRun");
if (runBtn) runBtn.disabled = !(roughOk && total >= 3 && currentStep === totalSteps());
renderReview();
}
function renderReview() {
const el = document.getElementById("labFormulateReview");
if (!el) return;
const sel = allSelections();
let html = "";
groups.forEach((g) => {
const ids = sel[g.id] || [];
html += `<li><strong>${escapeHtml(g.shortLabel)}</strong> (${ids.length})</li>`;
ids.forEach((id) => {
const name = catalog.find((c) => c.id === id)?.name || id;
html += `<li class="ms-3">${escapeHtml(name)}</li>`;
});
});
el.innerHTML = html || "<li>—</li>";
}
function setStep(step) {
currentStep = step;
const calcStep = totalSteps();
groups.forEach((g) => {
document.getElementById(`labFormulatePane-${g.id}`)?.classList.toggle("d-none", g.step !== step);
});
document.getElementById("labFormulateReviewPane")?.classList.toggle("d-none", step !== calcStep);
document.querySelector(".lab-formulate-step-panel--calc")?.classList.toggle("d-none", step !== calcStep);
document.querySelectorAll(".lab-formulate-step").forEach((btn) => {
btn.classList.toggle("lab-formulate-step--active", Number(btn.dataset.step) === step);
});
updateCounters();
}
function canGoToStep(step) {
if (step <= 1) return true;
const sel = allSelections();
for (const g of groups) {
if (g.step >= step) break;
if (g.required && (sel[g.id] || []).length < (g.minPick || 1)) return false;
}
if (step === totalSteps()) {
return (sel.rough || []).length >= 1 && totalPicked() >= 3;
}
return true;
}
function readNumInput(id) {
const el = document.getElementById(id);
if (!el) return null;
const raw = String(el.value ?? "").trim();
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function readNormsMethod() {
return document.getElementById("labFormulateNormsMethod")?.value || "wesp";
}
function readNormsParams() {
const method = readNormsMethod();
if (method === "wesp") return {};
const params = {
milkFatPct: readNumInput("labFormulateFat") ?? 4,
lactationNo: readNumInput("labFormulateLactation") ?? 2,
};
if (method === "racion_piter") {
params.koncOeSv = readNumInput("labFormulateKonc") ?? 10.3;
}
return params;
}
function syncNormsMethodUi() {
const method = readNormsMethod();
const racionBox = document.getElementById("labFormulateRacionFields");
const piterOnly = document.querySelectorAll(".lab-formulate-piter-only");
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
}
function readFormulateParams() {
return {
profileId: document.getElementById("labFormulateProfile")?.value || "",
massKg: readNumInput("labFormulateMass"),
milkYieldKg: readNumInput("labFormulateMilk"),
totalKgPerHead: readNumInput("labFormulateTotalKg") ?? 7.3,
costWeight: readNumInput("labFormulateCostWeight") ?? 100,
optimizeKeys: Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value),
groupSelections: allSelections(),
normsMethod: readNormsMethod(),
normsParams: readNormsParams(),
};
}
function resolveMassMilk(profile) {
return {
mass: readNumInput("labFormulateMass") ?? profile?.massKg ?? null,
milk: readNumInput("labFormulateMilk") ?? profile?.milkYieldKg ?? null,
};
}
async function normsForOptimizeDisplay(profile) {
const method = readNormsMethod();
let norms =
method === "wesp"
? { ...(profile?.norms || profile?.normsData?.indicators || {}) }
: {};
const { mass, milk } = resolveMassMilk(profile);
if (method !== "wesp" && (mass == null || milk == null)) return norms;
if (mass == null && milk == null) return norms;
try {
const q = new URLSearchParams({ method });
const profileId = document.getElementById("labFormulateProfile")?.value;
if (profileId) q.set("profile_id", profileId);
if (mass != null) q.set("mass_kg", String(mass));
if (milk != null) q.set("milk_yield_kg", String(milk));
const np = readNormsParams();
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
const resolved = preview.resolvedIndicators || {};
const dynamic = preview.dynamicNorms || {};
const merged = { ...resolved };
Object.keys(dynamic).forEach((key) => {
const d = dynamic[key];
if (d?.min != null) merged[key] = { ...(merged[key] || {}), min: d.min };
});
if (method === "wesp") {
Object.assign(norms, merged);
} else {
norms = merged;
}
} catch (_err) {
/* preview optional */
}
return norms;
}
async function refreshOptimizeKeysForInputs() {
const profileId = document.getElementById("labFormulateProfile")?.value;
const profile = profiles.find((p) => String(p.id) === String(profileId));
if (!profile) return;
const norms = await normsForOptimizeDisplay(profile);
renderOptimizeKeys({ ...profile, norms });
}
let optimizeKeysRefreshTimer = null;
function scheduleOptimizeKeysRefresh() {
if (optimizeKeysRefreshTimer) clearTimeout(optimizeKeysRefreshTimer);
optimizeKeysRefreshTimer = setTimeout(() => {
optimizeKeysRefreshTimer = null;
refreshOptimizeKeysForInputs().catch(() => {});
}, 200);
}
function profileOptions(selectedId) {
const opts = ['<option value="">— стандарт чистоты —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
const key = p.profileKey ? `[${p.profileKey}] ` : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)}</option>`
);
});
return opts.join("");
}
function fillProfileFields(profileId) {
const profile = profiles.find((p) => String(p.id) === String(profileId));
if (!profile) return;
const massEl = document.getElementById("labFormulateMass");
const milkEl = document.getElementById("labFormulateMilk");
if (massEl && profile.massKg != null) massEl.value = profile.massKg;
if (milkEl && profile.milkYieldKg != null) milkEl.value = profile.milkYieldKg;
refreshOptimizeKeysForInputs().catch(() => renderOptimizeKeys(profile));
}
function renderOptimizeKeys(profile) {
const box = document.getElementById("labFormulateOptimizeKeys");
if (!box) return;
const norms = profile?.norms || profile?.normsData?.indicators || {};
const keys = Object.keys(norms).filter((k) => norms[k]?.min != null || norms[k]?.max != null);
const useKeys = keys.length ? keys : DEFAULT_OPTIMIZE_KEYS;
const prevChecked = new Set(
Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value)
);
const hasPrev = prevChecked.size > 0;
box.innerHTML = useKeys
.map((key) => {
const checked =
(hasPrev ? prevChecked.has(key) : DEFAULT_OPTIMIZE_KEYS.includes(key)) ? " checked" : "";
const b = norms[key] || {};
const bounds =
b.min != null && b.max != null
? `${formatNum(b.min)}${formatNum(b.max)}`
: b.max != null
? `${formatNum(b.max)}`
: b.min != null
? `${formatNum(b.min)}`
: "";
return (
`<label class="lab-formulate-opt-key">` +
`<input type="checkbox" class="form-check-input lab-formulate-opt-cb" value="${escapeHtml(key)}"${checked}>` +
`<span>${escapeHtml(key)}</span>` +
`<small class="text-muted">${escapeHtml(bounds)}</small>` +
`</label>`
);
})
.join("");
}
function renderResult(data) {
const el = document.getElementById("labFormulateResult");
if (!el) return;
if (!data) {
el.innerHTML = '<div class="lab-sandbox-empty">Скажи моё имя и жми кнопку</div>';
return;
}
const stats = data.searchStats || {};
const optimizeSet = new Set(data.optimizeKeys || []);
let html =
`<div class="lab-formulate-result-meta">` +
`<span>Реагентов ${data.candidatePoolSize}</span>` +
`<span>Троек ${stats.tripletsEvaluated || 0}</span>` +
`<span>${stats.durationMs || 0} мс — время деньги</span></div>` +
`<table class="table table-sm lab-sandbox-table mb-3"><thead><tr><th>Реагент</th><th>кг/сут</th><th>%</th></tr></thead><tbody>` +
(data.lines || [])
.map(
(line) =>
`<tr><td>${escapeHtml(line.name)}</td><td>${formatNum(line.dailyKg)}</td><td>${formatNum(line.sharePct)}</td></tr>`
)
.join("") +
`</tbody></table>`;
const rows = (data.indicators || []).filter(
(ind) => ind.min != null || ind.max != null || ind.content != null
);
html +=
`<table class="table table-sm lab-sandbox-table"><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
rows
.map((ind) => {
const target = optimizeSet.has(ind.key) ? " lab-formulate-row--target" : "";
const dc = ind.min != null || ind.max != null ? diffClass(ind.diff) : "lab-sandbox-diff--na";
const norm =
ind.min != null && ind.max != null
? `${formatNum(ind.min)}${formatNum(ind.max)}`
: ind.max != null
? `${formatNum(ind.max)}`
: ind.min != null
? `${formatNum(ind.min)}`
: "—";
return (
`<tr class="${target}"><td>${escapeHtml(ind.label || ind.key)}</td>` +
`<td>${formatNum(ind.content)}</td><td>${norm}</td>` +
`<td class="${dc}">${formatNum(ind.diff)}</td></tr>`
);
})
.join("") +
`</tbody></table>`;
el.innerHTML = html;
}
async function loadCatalogs() {
const [compData, profData] = await Promise.all([
api("/api/lab/formulate/components"),
api("/api/lab/animal-profiles?ration_type=DAIRY"),
]);
catalog = compData.components || [];
groups = (compData.groups || []).slice().sort((a, b) => a.step - b.step);
profiles = profData.profiles || [];
buildWizard();
const profileSel = document.getElementById("labFormulateProfile");
if (profileSel) {
const labDefault = profiles.find((p) => p.profileKey === "lab_math_dairy_01") || profiles[0];
profileSel.innerHTML = profileOptions(labDefault?.id);
if (labDefault) fillProfileFields(labDefault.id);
}
setStep(1);
}
async function labFormulateRun() {
const params = readFormulateParams();
const profile = profiles.find((p) => String(p.id) === String(params.profileId));
if (!params.profileId) throw new Error("Выбери стандарт чистоты — я предупреждал");
if (params.normsMethod !== "wesp") {
const { mass, milk } = resolveMassMilk(profile);
if (mass == null || milk == null) {
throw new Error("Для Москва/Петербург нужны масса и удой — заполни поля или выбери профиль с удоем");
}
params.massKg = mass;
params.milkYieldKg = milk;
}
if ((params.groupSelections.rough || []).length < 1) {
throw new Error("Без базы не готовим. Минимум один грубый корм");
}
if (totalPicked() < 3) throw new Error("Минимум три реагента. Нам нужно готовить");
const data = await api("/api/lab/formulate", {
method: "POST",
headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({
profileId: params.profileId,
groupSelections: params.groupSelections,
massKg: params.massKg,
milkYieldKg: params.milkYieldKg,
totalKgPerHead: params.totalKgPerHead,
costWeight: params.costWeight,
optimizeKeys: params.optimizeKeys,
normsMethod: params.normsMethod,
normsParams: params.normsParams,
}),
});
renderResult(data);
return data;
}
function selectLabForGroup(gid) {
catalog
.filter((c) => c.eligible && c.feedGroup === gid && String(c.name || "").startsWith("LAB тест —"))
.forEach((c) => {
const cb = document.querySelector(
`.lab-formulate-gcb[data-group="${CSS.escape(gid)}"][value="${CSS.escape(c.id)}"]`
);
if (cb) cb.checked = true;
});
updateCounters();
}
function bindEvents() {
document.getElementById("labFormulateRun")?.addEventListener("click", async () => {
try {
await labFormulateRun();
} catch (err) {
formulateError(String(err.message || err));
} finally {
updateCounters();
}
});
document.getElementById("labFormulateProfile")?.addEventListener("change", (e) => {
fillProfileFields(e.target.value);
});
for (const id of [
"labFormulateMass",
"labFormulateMilk",
"labFormulateTotalKg",
"labFormulateCostWeight",
"labFormulateFat",
"labFormulateLactation",
"labFormulateKonc",
]) {
document.getElementById(id)?.addEventListener("input", scheduleOptimizeKeysRefresh);
}
document.getElementById("labFormulateNormsMethod")?.addEventListener("change", () => {
syncNormsMethodUi();
scheduleOptimizeKeysRefresh();
});
document.getElementById("labFormulatePanes")?.addEventListener("input", (e) => {
if (e.target.classList.contains("lab-formulate-filter")) {
renderGroupTable(e.target.dataset.group, e.target.value);
}
});
document.getElementById("labFormulatePanes")?.addEventListener("change", (e) => {
if (e.target.classList.contains("lab-formulate-gcb")) updateCounters();
});
document.getElementById("labFormulatePanes")?.addEventListener("click", (e) => {
const next = e.target.closest(".lab-formulate-next");
const back = e.target.closest(".lab-formulate-back");
const skip = e.target.closest(".lab-formulate-skip");
if (next) {
const step = Number(next.dataset.step);
const g = groups.find((x) => x.step === step);
if (g?.required && selectedForGroup(g.id).length < (g.minPick || 1)) {
formulateError(`В «${g.label}» нужно минимум ${g.minPick}. Всё под контролем — выбери`);
return;
}
setStep(step + 1);
}
if (back) setStep(Number(back.dataset.step) - 1);
if (skip) setStep(Number(skip.dataset.step) + 1);
});
document.getElementById("labFormulateSteps")?.addEventListener("click", (e) => {
const btn = e.target.closest(".lab-formulate-step");
if (!btn) return;
const step = Number(btn.dataset.step);
if (!canGoToStep(step)) return;
setStep(step);
});
document.addEventListener("keydown", (e) => {
if (e.altKey && e.key === "l") {
selectLabForGroup("rough");
selectLabForGroup("succulent");
selectLabForGroup("concentrate");
}
});
}
function init() {
bindEvents();
syncNormsMethodUi();
loadCatalogs().catch((err) => {
const msg = err.message || String(err);
formulateError(msg);
const panes = document.getElementById("labFormulatePanes");
if (panes) panes.innerHTML = `<p class="text-danger">${escapeHtml(msg)}</p>`;
});
}
global.labFormulateRun = labFormulateRun;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
@@ -0,0 +1,93 @@
(function (global) {
"use strict";
const MAX_MS = 5000;
const MIN_MS = 2400;
const DEFAULT_MS = 4000;
const SOURCE_LABELS = {
agrostar: "Поставка AgroStar",
sandbox: "Песочница рациона",
formulate: "Автоготовка",
lab: "Лаборатория",
};
let overlay = null;
let hideTimer = null;
function ensureOverlay() {
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.id = "labHeisenbergPrime";
overlay.className = "lab-heisenberg-prime-overlay";
overlay.hidden = true;
overlay.innerHTML =
'<div class="lab-heisenberg-prime__vignette" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__eyes" aria-hidden="true">' +
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--left"></span>' +
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--right"></span>' +
"</div>" +
'<div class="lab-heisenberg-prime__glow" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__scanlines" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__banner" role="alert" aria-live="assertive">' +
'<div class="lab-heisenberg-prime__title">В нашем деле - или ты, или тебя.</div>' +
"</div>";
document.body.appendChild(overlay);
return overlay;
}
function sourceLabel(source) {
const key = String(source || "lab").toLowerCase();
return SOURCE_LABELS[key] || SOURCE_LABELS.lab;
}
function dismiss() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
document.body.classList.remove("lab-heisenberg-prime-active");
if (overlay) overlay.hidden = true;
}
/**
* @param {string} reason почему сработал prime (показываем пользователю)
* @param {{ source?: string, durationMs?: number }} [options]
*/
function trigger(reason, options) {
if (!document.body.classList.contains("lab-sandbox-page")) return;
const opts = options || {};
const text = String(reason || "Что-то пошло не по плану").trim();
const duration = Math.min(MAX_MS, Math.max(MIN_MS, Number(opts.durationMs) || DEFAULT_MS));
global.WespZootechNotify?.dismissAll?.();
dismiss();
const el = ensureOverlay();
const reasonEl = el.querySelector(".lab-heisenberg-prime__reason");
const sourceEl = el.querySelector(".lab-heisenberg-prime__source");
if (reasonEl) reasonEl.textContent = text;
el.hidden = false;
requestAnimationFrame(() => {
document.body.classList.add("lab-heisenberg-prime-active");
});
hideTimer = setTimeout(dismiss, duration);
}
/** Ошибка на /lab: только prime (без toast/alert — они ломают сцену). */
function error(reason, source) {
trigger(reason, { source: source || "lab" });
}
global.WespLabHeisenbergPrime = {
trigger,
dismiss,
error,
MAX_MS,
SOURCE_LABELS,
};
})(window);
@@ -0,0 +1,272 @@
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function createLabMasterPanel() {
let root = null;
let recipeId = null;
let state = null;
let components = [];
let profiles = [];
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || "Ошибка API");
return data;
}
async function ensureCatalogs(rationType) {
const [compResp, profData] = await Promise.all([
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
]);
if (Array.isArray(compResp)) components = compResp;
profiles = profData.profiles || [];
}
async function loadRecipes() {
const data = await api("/api/lab/recipes");
return data.recipes || [];
}
async function loadRation(id) {
return api(`/api/lab/rations/${encodeURIComponent(id)}`);
}
function componentOptionsHtml(selectedId) {
const opts = ['<option value="">— компонент —</option>'];
components.forEach((c) => {
const sel = String(c.id) === String(selectedId) ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
});
return opts.join("");
}
function profileOptionsHtml(selectedId) {
const opts = ['<option value="">— профиль норм —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
);
});
return opts.join("");
}
function render() {
if (!root) return;
const indicators = (state?.rationResults?.indicators || []).slice(0, 8);
const lines = state?.lines || [];
root.innerHTML =
`<div class="zt-k-hub-lab">` +
`<div class="zt-k-hub-lab__toolbar">` +
`<label class="zt-k-hub-lab__field">Рецепт <select data-lab-recipe-select class="form-select form-select-sm"></select></label>` +
`<label class="zt-k-hub-lab__field">Профиль <select data-lab-profile-select class="form-select form-select-sm">${profileOptionsHtml(state?.animalProfileId)}</select></label>` +
`<label class="zt-k-hub-lab__field">Тип <select data-lab-ration-type class="form-select form-select-sm">` +
`<option value="BEEF" ${state?.rationType === "BEEF" ? "selected" : ""}>Мясное</option>` +
`<option value="DAIRY" ${state?.rationType === "DAIRY" ? "selected" : ""}>Дойное</option>` +
`</select></label>` +
`<button type="button" class="btn btn-sm btn-primary" data-action="lab-recalculate">Пересчёт</button>` +
`<button type="button" class="btn btn-sm btn-outline-primary" data-action="lab-save">Сохранить</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-add-line">+ Строка</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-ration" title="Печать рациона">Печать</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-compound" title="Печать комбикорма">Комб.</button>` +
`</div>` +
`<div class="zt-k-hub-lab__subtitle">Зоотехнический мастер · ${escapeHtml(state?.recipeName || "")}</div>` +
`<table class="table table-sm zt-k-hub-lab__table"><thead><tr>` +
`<th>Компонент WESP</th><th>кг/день</th><th>В рац.</th><th>Комб.</th><th></th></tr></thead><tbody>` +
(lines.length
? lines
.map(
(l, i) =>
`<tr data-line-idx="${i}">` +
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptionsHtml(l.componentId)}</select></td>` +
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${l.dailyKg ?? ""}"></td>` +
`<td class="text-center"><input type="checkbox" data-field="inRation" ${l.inRation ? "checked" : ""}></td>` +
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${l.inCompound ? "checked" : ""}></td>` +
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="lab-remove-line" data-line-idx="${i}" title="Удалить">&times;</button></td>` +
`</tr>`
)
.join("")
: `<tr><td colspan="5" class="text-muted text-center">Нет строк — добавьте или создайте из рецепта</td></tr>`) +
`</tbody></table>` +
`<div class="zt-k-hub-lab__indicators">` +
indicators
.map(
(ind) =>
`<span class="badge bg-light text-dark me-1 mb-1">${escapeHtml(ind.label)}: ${escapeHtml(ind.content)} ${escapeHtml(ind.unit)}</span>`
)
.join("") +
`</div></div>`;
const sel = root.querySelector("[data-lab-recipe-select]");
if (sel && sel.options.length === 0) {
loadRecipes().then((recipes) => {
sel.innerHTML =
'<option value="">—</option>' +
recipes
.map(
(r) =>
`<option value="${escapeHtml(r.id)}" ${r.id === recipeId ? "selected" : ""}>${escapeHtml(r.name)}</option>`
)
.join("");
});
}
}
async function reload() {
if (!recipeId) return;
try {
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
if (!state.exists) {
const ok = await global.WespDialog?.confirm?.(
"Создать мастер из текущего рецепта?",
{ title: "Мастер рациона" }
);
if (ok) {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, {
method: "POST",
});
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
} else {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, {
method: "POST",
});
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
}
}
render();
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
}
function collectLines() {
if (!root || !state) return [];
return (state.lines || []).map((line, i) => {
const row = root.querySelector(`tr[data-line-idx="${i}"]`);
if (!row) return line;
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
const comp = components.find((c) => String(c.id) === String(componentId));
return {
...line,
componentId,
ingredientName: comp?.name || line.ingredientName,
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
inRation: row.querySelector('[data-field="inRation"]')?.checked,
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
};
});
}
function collectPayload() {
const rationType = root.querySelector("[data-lab-ration-type]")?.value || state?.rationType;
return {
lines: collectLines(),
rationType,
animalProfileId: root.querySelector("[data-lab-profile-select]")?.value || null,
};
}
return {
mount(el) {
root = el;
root.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn || !recipeId) return;
const action = btn.getAttribute("data-action");
try {
if (action === "lab-add-line") {
state.lines = [...(state?.lines || []), {
componentId: "",
ingredientName: "",
dailyKg: 0,
inRation: true,
inCompound: false,
}];
render();
return;
}
if (action === "lab-remove-line") {
const idx = parseInt(btn.getAttribute("data-line-idx"), 10);
if (!Number.isNaN(idx)) {
state.lines = (state.lines || []).filter((_, i) => i !== idx);
render();
}
return;
}
if (action === "lab-recalculate") {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, {
method: "POST",
});
await reload();
}
if (action === "lab-save") {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await reload();
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер сохранён");
}
if (action === "lab-print-ration" || action === "lab-print-compound") {
const mode = action === "lab-print-compound" ? "compound" : "ration";
global.open?.(
`/api/lab/rations/${encodeURIComponent(recipeId)}/print?mode=${mode}`,
"_blank",
"noopener"
);
}
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
});
root.addEventListener("change", async (ev) => {
if (ev.target.matches("[data-lab-recipe-select]")) {
recipeId = ev.target.value;
reload();
return;
}
if (ev.target.matches("[data-lab-ration-type]")) {
const rt = ev.target.value;
state = { ...state, rationType: rt };
profiles = [];
await ensureCatalogs(rt);
render();
return;
}
if (ev.target.matches("[data-lab-profile-select]")) {
const profile = profiles.find((p) => String(p.id) === String(ev.target.value));
if (profile?.rationType) {
state = { ...state, rationType: profile.rationType, animalProfileId: profile.id };
const typeSel = root.querySelector("[data-lab-ration-type]");
if (typeSel) typeSel.value = profile.rationType;
}
}
});
},
destroy() {
root = null;
state = null;
},
reload,
setRecipeId(id) {
recipeId = id;
reload();
},
};
}
global.WespLabMasterPanel = { createLabMasterPanel };
})(window);
@@ -0,0 +1,777 @@
(function (global) {
"use strict";
const GFE_DYNAMIC_KEYS = ["usp", "nel"];
let catalog = [];
let profiles = [];
let selectedId = null;
let draftNorms = {};
let dynamicNorms = {};
let normCoverage = null;
let normSourceMap = {};
let resolvedNormsPreview = {};
let seedCatalog = [];
let seedCatalogRation = null;
let normsCoverageTimer = null;
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function notify() {
return global.WespZootechNotify?.createAdapter?.();
}
function readMass() {
const raw = document.getElementById("labProfileMass")?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readMilk() {
const raw = document.getElementById("labProfileMilk")?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readNumInput(id) {
const raw = document.getElementById(id)?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readNormsMethod() {
return document.getElementById("labProfileNormsMethod")?.value || "wesp";
}
function readNormsParams() {
const method = readNormsMethod();
if (method === "wesp") return {};
const params = {
milkFatPct: readNumInput("labProfileFat") ?? 4,
lactationNo: readNumInput("labProfileLactation") ?? 2,
};
if (method === "racion_piter") {
params.koncOeSv = readNumInput("labProfileKonc") ?? 10.3;
}
return params;
}
function syncNormsMethodUi() {
const method = readNormsMethod();
const racionBox = document.getElementById("labProfileRacionFields");
const piterOnly = document.querySelectorAll(".lab-profile-piter-only");
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
syncSourceFilterOptions(method);
renderNormsSummary();
scheduleNormsCoverageRefresh();
}
function syncSourceFilterOptions(method) {
const sel = document.getElementById("labProfilesSourceFilter");
if (!sel) return;
sel.querySelectorAll("option[data-racion-only]").forEach((opt) => {
opt.hidden = method === "wesp";
opt.disabled = method === "wesp";
});
if (method === "wesp" && ["racion", "racion_only", "derived", "new_racion"].includes(sel.value)) {
sel.value = "all";
}
}
function buildNormSourceMap(coverage, storedNorms) {
const map = {};
if (coverage) {
(coverage.racion || []).forEach((k) => {
map[k] = "racion";
});
(coverage.derived || []).forEach((k) => {
map[k] = "derived";
});
(coverage.fallback || []).forEach((k) => {
if (!map[k]) map[k] = "fallback";
});
(coverage.missing || []).forEach((k) => {
if (!map[k]) map[k] = "missing";
});
}
Object.keys(storedNorms || {}).forEach((k) => {
if (!map[k]) map[k] = "stored";
});
return map;
}
function normSourceLabel(src) {
const labels = {
racion: "RACION",
derived: "расч.",
fallback: "WESP",
stored: "БД",
missing: "—",
gfe: "GfE",
};
return labels[src] || "—";
}
function normSourceBadge(src) {
if (!src || src === "missing") return '<span class="norm-source-badge norm-source-badge--empty">—</span>';
const cls = `norm-source-badge norm-source-badge--${src}`;
return `<span class="${cls}" title="${escapeHtml(normSourceLabel(src))}">${escapeHtml(normSourceLabel(src))}</span>`;
}
function isInDb(key) {
const b = draftNorms[key] || {};
return b.min != null || b.max != null;
}
function matchesSourceFilter(key) {
const filter = document.getElementById("labProfilesSourceFilter")?.value || "all";
if (filter === "all") return true;
const src = normSourceMap[key];
if (filter === "in_db") return isInDb(key);
if (filter === "seed") return src === "fallback" || src === "stored";
if (filter === "racion") return src === "racion" || src === "derived";
if (filter === "racion_only") return src === "racion";
if (filter === "derived") return src === "derived";
if (filter === "new_racion") return src === "racion" || src === "derived";
return true;
}
function countNormSources() {
const counts = { inDb: 0, racion: 0, derived: 0, fallback: 0, stored: 0, missing: 0 };
const seen = new Set();
catalog.forEach((c) => seen.add(c.key));
Object.keys(draftNorms).forEach((k) => seen.add(k));
if (normCoverage) {
(normCoverage.racion || []).forEach((k) => seen.add(k));
(normCoverage.derived || []).forEach((k) => seen.add(k));
(normCoverage.fallback || []).forEach((k) => seen.add(k));
(normCoverage.missing || []).forEach((k) => seen.add(k));
}
seen.forEach((key) => {
if (isInDb(key)) counts.inDb += 1;
const src = normSourceMap[key];
if (src === "racion") counts.racion += 1;
else if (src === "derived") counts.derived += 1;
else if (src === "fallback") counts.fallback += 1;
else if (src === "stored") counts.stored += 1;
else if (src === "missing") counts.missing += 1;
});
return counts;
}
function renderNormsSummary() {
const panel = document.getElementById("labProfilesNormsSummary");
const text = document.getElementById("labProfilesNormsSummaryText");
const form = document.getElementById("labProfilesForm");
if (!panel || !text || !form || form.hidden) {
if (panel) panel.hidden = true;
return;
}
const method = readNormsMethod();
const counts = countNormSources();
if (method === "wesp") {
panel.hidden = false;
text.textContent = `В БД ${counts.inDb} показ. · справочник/ручные ${counts.stored + counts.fallback}`;
return;
}
if (!normCoverage) {
panel.hidden = false;
text.textContent = "Укажи массу и удой — разберём источники RACION";
return;
}
panel.hidden = false;
const racTotal = counts.racion + counts.derived;
const withMin = normCoverage.withMin != null ? normCoverage.withMin : racTotal + counts.fallback;
text.textContent =
`В БД ${counts.inDb} · RACION ${racTotal} (NPitV ${counts.racion} + расч. ${counts.derived}) · ` +
`WESP fallback ${counts.fallback} · с min ${withMin}/${normCoverage.total ?? "—"}`;
}
function applyNormsCoveragePayload(payload) {
normCoverage = payload.coverage || null;
resolvedNormsPreview = payload.resolvedIndicators || {};
if (payload.dynamicNorms) {
dynamicNorms = { ...dynamicNorms, ...payload.dynamicNorms };
}
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
renderNormsSummary();
renderNormsTable();
}
async function refreshNormsCoverage() {
const method = readNormsMethod();
const mass = readMass();
const milk = readMilk();
if (method === "wesp") {
normCoverage = null;
resolvedNormsPreview = {};
normSourceMap = buildNormSourceMap(null, draftNorms);
GFE_DYNAMIC_KEYS.forEach((key) => {
if (dynamicNorms[key]?.min != null && !isInDb(key)) normSourceMap[key] = "gfe";
});
renderNormsSummary();
renderNormsTable();
return;
}
if (mass == null || milk == null) {
normCoverage = null;
normSourceMap = buildNormSourceMap(null, draftNorms);
renderNormsSummary();
renderNormsTable();
return;
}
const q = new URLSearchParams({ method, mass_kg: String(mass), milk_yield_kg: String(milk) });
if (selectedId) q.set("profile_id", selectedId);
const np = readNormsParams();
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
try {
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
applyNormsCoveragePayload(preview);
} catch (err) {
notify()?.error?.(err.message);
}
}
function scheduleNormsCoverageRefresh() {
if (normsCoverageTimer) clearTimeout(normsCoverageTimer);
normsCoverageTimer = setTimeout(() => {
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
}, 350);
}
function fillNormsParamsFromProfile(p) {
const method = p.normsMethod || "wesp";
const sel = document.getElementById("labProfileNormsMethod");
if (sel) sel.value = method;
const np = p.normsParams || {};
const fat = document.getElementById("labProfileFat");
const lact = document.getElementById("labProfileLactation");
const konc = document.getElementById("labProfileKonc");
if (fat) fat.value = np.milkFatPct != null ? np.milkFatPct : 4;
if (lact) lact.value = np.lactationNo != null ? np.lactationNo : 2;
if (konc) konc.value = np.koncOeSv != null ? np.koncOeSv : 10.3;
syncNormsMethodUi();
}
/** GfE 2001 — parity app/lab/calc/gfe_norms.py */
function gfeUspMinG(massKg, milkKg) {
if (!massKg || massKg <= 0) return null;
return 0.09 * massKg ** 0.75 * 6.25 + Math.max(milkKg || 0, 0) * 85;
}
function gfeNelMinMj(massKg, milkKg) {
if (!massKg || massKg <= 0) return null;
return 0.293 * massKg ** 0.75 + Math.max(milkKg || 0, 0) * 3.3;
}
function computeDynamicNormsLocal() {
const mass = readMass();
const milk = readMilk();
const out = {};
const usp = gfeUspMinG(mass, milk);
if (usp != null) {
out.usp = { min: usp, formula: "0.09×масса^0.75×6.25 + удой×85" };
}
const nel = gfeNelMinMj(mass, milk);
if (nel != null) {
out.nel = { min: nel, formula: "0.293×масса^0.75 + удой×3.3" };
}
return out;
}
function formatGfe(v) {
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(2);
}
function refreshDynamicNorms() {
dynamicNorms = computeDynamicNormsLocal();
renderGfePanel();
renderNormsTable();
}
function renderGfePanel() {
const panel = document.getElementById("labProfilesGfePanel");
const vals = document.getElementById("labProfilesGfeValues");
const form = document.getElementById("labProfilesForm");
if (!panel || !form || form.hidden) {
if (panel) panel.hidden = true;
return;
}
const mass = readMass();
if (!mass || mass <= 0) {
panel.hidden = true;
return;
}
panel.hidden = false;
const parts = [];
if (dynamicNorms.usp?.min != null) {
parts.push(`min уСП ≈ ${formatGfe(dynamicNorms.usp.min)} г`);
}
if (dynamicNorms.nel?.min != null) {
parts.push(`min ЧЭЛ ≈ ${formatGfe(dynamicNorms.nel.min)} МДж`);
}
if (vals) vals.textContent = parts.join(" · ") || "—";
const enabled = parts.length > 0;
["labProfilesGfeApplyUsp", "labProfilesGfeApplyNel", "labProfilesGfeApplyAll"].forEach((id) => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function applyGfeMin(keys) {
collectNormsFromTable();
let applied = 0;
keys.forEach((key) => {
const v = dynamicNorms[key]?.min;
if (v == null) return;
const cur = draftNorms[key] || {};
if (cur.min != null) return;
draftNorms[key] = { ...cur, min: v };
applied += 1;
});
renderNormsTable();
if (applied) {
notify()?.success?.("Подставлено по GfE — нажми «Запомни это»");
} else {
notify()?.error?.("Нижние границы уже заданы или нет массы");
}
}
function syncDeleteButton() {
const btn = document.getElementById("labProfilesDelete");
if (btn) btn.hidden = !selectedId;
}
function readRationType() {
return document.getElementById("labProfileType")?.value || "DAIRY";
}
function syncSeedPanelVisibility() {
const panel = document.getElementById("labProfilesSeedPanel");
const form = document.getElementById("labProfilesForm");
if (panel) panel.hidden = !form || form.hidden;
}
function filteredSeedCatalog() {
const q = (document.getElementById("labProfilesSeedSearch")?.value || "").trim().toLowerCase();
if (!q) return seedCatalog;
return seedCatalog.filter((e) => {
const hay = `${e.externalNo} ${e.label} ${e.massKg ?? ""}`.toLowerCase();
return hay.includes(q);
});
}
function renderSeedSelect() {
const sel = document.getElementById("labProfilesSeedSelect");
const applyBtn = document.getElementById("labProfilesSeedApply");
if (!sel) return;
const prev = sel.value;
const rows = filteredSeedCatalog();
const opts = ['<option value="">— выбери строку —</option>'].concat(
rows.map((e) => {
const mass = e.massKg != null ? ` · ${e.massKg} кг` : "";
const cnt = e.indicatorCount != null ? ` · ${e.indicatorCount} показ.` : "";
return `<option value="${escapeHtml(String(e.externalNo))}">№${escapeHtml(String(e.externalNo))}${escapeHtml(e.label)}${escapeHtml(mass)}${escapeHtml(cnt)}</option>`;
})
);
sel.innerHTML = opts.join("");
if (prev && rows.some((e) => String(e.externalNo) === prev)) {
sel.value = prev;
}
if (applyBtn) applyBtn.disabled = !sel.value;
}
async function loadSeedCatalog(rationType) {
const ration = rationType || readRationType();
if (seedCatalogRation === ration && seedCatalog.length) {
renderSeedSelect();
return;
}
const data = await api(`/api/lab/seed-norms-catalog?ration_type=${encodeURIComponent(ration)}`);
seedCatalog = data.entries || [];
seedCatalogRation = ration;
renderSeedSelect();
}
async function applySeedNorms() {
const sel = document.getElementById("labProfilesSeedSelect");
const externalNo = sel?.value;
if (!externalNo) {
notify()?.error?.("Выбери строку справочника");
return;
}
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
if (filled > 0) {
const ok = global.confirm("Заменить текущие границы нормами из справочника?");
if (!ok) return;
}
const ration = readRationType();
const entry = await api(
`/api/lab/seed-norms-catalog/${encodeURIComponent(externalNo)}?ration_type=${encodeURIComponent(ration)}`
);
draftNorms = { ...(entry.indicators || {}) };
if (entry.massKg != null) {
document.getElementById("labProfileMass").value = entry.massKg;
}
document.getElementById("labProfileExternal").value = entry.externalNo;
const labelEl = document.getElementById("labProfileLabel");
if (labelEl && !labelEl.value.trim()) {
labelEl.value = entry.label || "";
}
refreshDynamicNorms();
notify()?.success?.(`Загружено ${Object.keys(draftNorms).length} показателей — нажми «Запомни это»`);
}
function filteredProfiles() {
const type = document.getElementById("labProfilesType")?.value || "";
const q = (document.getElementById("labProfilesSearch")?.value || "").trim().toLowerCase();
return profiles.filter((p) => {
if (type && p.rationType !== type) return false;
if (!q) return true;
const hay = `${p.profileKey} ${p.label}`.toLowerCase();
return hay.includes(q);
});
}
function renderList() {
const el = document.getElementById("labProfilesList");
if (!el) return;
const rows = filteredProfiles();
if (!rows.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Стандартов нет — создай новый</div>';
return;
}
el.innerHTML = rows
.map(
(p) =>
`<button type="button" class="${p.id === selectedId ? "is-active" : ""}" data-id="${escapeHtml(p.id)}">` +
`${escapeHtml(p.label)}` +
`<span class="profile-key">${escapeHtml(p.profileKey)} · ${escapeHtml(p.rationType)}</span>` +
`</button>`
)
.join("");
}
function indicatorRows() {
const onlyCalc = document.getElementById("labProfilesOnlyCalc")?.checked;
const onlyFilled = document.getElementById("labProfilesOnlyFilled")?.checked;
const q = (document.getElementById("labProfilesNormSearch")?.value || "").trim().toLowerCase();
const extraKeys = Object.keys(draftNorms).filter((k) => !catalog.some((c) => c.key === k));
const rows = [
...catalog.map((c) => ({ ...c })),
...extraKeys.map((k) => ({ key: k, label: k, unit: "", inCalc: false })),
];
return rows.filter((row) => {
if (onlyCalc && !row.inCalc) return false;
const b = draftNorms[row.key] || {};
const has = b.min != null || b.max != null;
if (onlyFilled && !has) return false;
if (!matchesSourceFilter(row.key)) return false;
if (q) {
const hay = `${row.key} ${row.label}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function renderNormsTable() {
const tbody = document.getElementById("labProfilesNormsBody");
const countEl = document.getElementById("labProfilesNormCount");
if (!tbody) return;
const rows = indicatorRows();
tbody.innerHTML = rows
.map((row) => {
const b = draftNorms[row.key] || {};
const min = b.min != null ? b.min : "";
const max = b.max != null ? b.max : "";
const badge = row.inCalc ? '<span class="norm-calc-badge ms-1">в деле</span>' : "";
const unit = row.unit ? ` <span class="text-muted">${escapeHtml(row.unit)}</span>` : "";
const dyn = dynamicNorms[row.key];
const gfeHint =
dyn?.min != null && min === ""
? `<small class="text-muted d-block">GfE ≈ ${formatGfe(dyn.min)}${dyn.formula ? ` (${escapeHtml(dyn.formula)})` : ""}</small>`
: "";
const gfeBadge = GFE_DYNAMIC_KEYS.includes(row.key)
? '<span class="norm-calc-badge ms-1" title="min по GfE 2001, если нижняя пуста">GfE</span>'
: "";
let src = normSourceMap[row.key];
if (!src && isInDb(row.key)) src = "stored";
const dbBadge = isInDb(row.key) ? '<span class="norm-source-badge norm-source-badge--db" title="Сохранено в БД">БД</span>' : "";
const srcBadge = normSourceBadge(src);
const resolved = resolvedNormsPreview[row.key];
const resolvedHint =
resolved?.min != null && min !== "" && Number(min) !== Number(resolved.min)
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)}</small>`
: resolved?.min != null && min === ""
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)} (не в БД)</small>`
: "";
return (
`<tr data-key="${escapeHtml(row.key)}">` +
`<td>${escapeHtml(row.label)}${badge}${gfeBadge}${unit}${gfeHint}${resolvedHint}</td>` +
`<td class="lab-profiles-source-cell">${dbBadge}${srcBadge}</td>` +
`<td><input type="number" class="form-control form-control-sm" data-bound="min" step="any" value="${min}"` +
`${dyn?.min != null && min === "" ? ` placeholder="${formatGfe(dyn.min)}"` : ""}></td>` +
`<td><input type="number" class="form-control form-control-sm" data-bound="max" step="any" value="${max}"></td>` +
`</tr>`
);
})
.join("");
if (countEl) {
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
const counts = countNormSources();
const method = readNormsMethod();
const srcPart =
method === "wesp"
? ` · в БД ${counts.inDb}`
: ` · БД ${counts.inDb} · RACION ${counts.racion + counts.derived} · WESP ${counts.fallback}`;
countEl.textContent = `В таблице ${rows.length} · с границами ${filled}${srcPart}`;
}
renderNormsSummary();
}
function collectNormsFromTable() {
const out = { ...draftNorms };
document.querySelectorAll("#labProfilesNormsBody tr[data-key]").forEach((tr) => {
const key = tr.getAttribute("data-key");
if (!key) return;
const minIn = tr.querySelector('[data-bound="min"]');
const maxIn = tr.querySelector('[data-bound="max"]');
const minRaw = minIn?.value?.trim();
const maxRaw = maxIn?.value?.trim();
const min = minRaw === "" ? null : Number(minRaw);
const max = maxRaw === "" ? null : Number(maxRaw);
if (min == null && max == null) {
delete out[key];
} else {
out[key] = {
min: Number.isNaN(min) ? null : min,
max: Number.isNaN(max) ? null : max,
};
}
});
draftNorms = out;
}
async function selectProfile(id) {
selectedId = id;
syncDeleteButton();
renderList();
const empty = document.getElementById("labProfilesEmpty");
const form = document.getElementById("labProfilesForm");
if (!id) {
if (empty) empty.hidden = false;
if (form) form.hidden = true;
syncSeedPanelVisibility();
return;
}
const p = await api(`/api/lab/animal-profiles/${encodeURIComponent(id)}`);
if (empty) empty.hidden = true;
if (form) form.hidden = false;
document.getElementById("labProfileKey").value = p.profileKey || "";
document.getElementById("labProfileLabel").value = p.label || "";
document.getElementById("labProfileType").value = p.rationType || "BEEF";
document.getElementById("labProfileMass").value = p.massKg != null ? p.massKg : "";
document.getElementById("labProfileMilk").value = p.milkYieldKg != null ? p.milkYieldKg : "";
document.getElementById("labProfileExternal").value = p.externalNo != null ? p.externalNo : "";
fillNormsParamsFromProfile(p);
draftNorms = { ...(p.norms || p.normsData?.indicators || {}) };
normCoverage = p.coverage || p.normsData?.coverage || null;
resolvedNormsPreview = p.resolvedNorms || p.normsData?.resolvedIndicators || {};
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
refreshDynamicNorms();
syncSourceFilterOptions(readNormsMethod());
renderNormsSummary();
scheduleNormsCoverageRefresh();
syncSeedPanelVisibility();
loadSeedCatalog(p.rationType || "DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
}
function newProfile() {
selectedId = null;
syncDeleteButton();
renderList();
document.getElementById("labProfilesEmpty").hidden = true;
document.getElementById("labProfilesForm").hidden = false;
document.getElementById("labProfileKey").value = "";
document.getElementById("labProfileLabel").value = "";
document.getElementById("labProfileType").value = "DAIRY";
document.getElementById("labProfileMass").value = "";
document.getElementById("labProfileMilk").value = "";
document.getElementById("labProfileExternal").value = "";
fillNormsParamsFromProfile({ normsMethod: "wesp", normsParams: {} });
draftNorms = {};
normCoverage = null;
resolvedNormsPreview = {};
normSourceMap = {};
refreshDynamicNorms();
syncSourceFilterOptions("wesp");
renderNormsSummary();
syncSeedPanelVisibility();
loadSeedCatalog("DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
}
async function saveProfile() {
collectNormsFromTable();
const key = document.getElementById("labProfileKey")?.value?.trim();
const label = document.getElementById("labProfileLabel")?.value?.trim();
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
const massRaw = document.getElementById("labProfileMass")?.value?.trim();
const milkRaw = document.getElementById("labProfileMilk")?.value?.trim();
const extRaw = document.getElementById("labProfileExternal")?.value?.trim();
if (!key || !label) {
notify()?.error?.("Укажи код и название — я предупреждал");
return;
}
const payload = {
profileKey: key,
label,
rationType,
indicators: draftNorms,
normsMethod: readNormsMethod(),
normsParams: readNormsParams(),
};
if (massRaw !== "") payload.massKg = Number(massRaw);
payload.milkYieldKg = milkRaw === "" ? null : Number(milkRaw);
if (extRaw !== "") payload.externalNo = Number(extRaw);
const method = selectedId ? "PUT" : "POST";
const url = selectedId
? `/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`
: "/api/lab/animal-profiles";
await api(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
notify()?.success?.("Стандарт сохранён. Ты чертовски прав");
const typeFilter = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
);
profiles = data.profiles || [];
if (!selectedId && profiles.length) {
const hit = profiles.find((p) => p.profileKey === key);
if (hit) selectedId = hit.id;
}
renderList();
if (selectedId) await selectProfile(selectedId);
}
async function deleteProfile() {
if (!selectedId) return;
const label = document.getElementById("labProfileLabel")?.value?.trim() || selectedId;
if (!global.confirm(`Удалить «${label}»? Никаких полумер.`)) return;
await api(`/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`, { method: "DELETE" });
notify()?.success?.("Стандарт снесён. Say my name.");
selectedId = null;
syncDeleteButton();
const typeFilter = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
);
profiles = data.profiles || [];
document.getElementById("labProfilesEmpty").hidden = false;
document.getElementById("labProfilesForm").hidden = true;
renderList();
}
async function init() {
const [cat, prof] = await Promise.all([
api("/api/lab/norm-indicators"),
api("/api/lab/animal-profiles"),
]);
catalog = cat.indicators || [];
profiles = prof.profiles || [];
renderList();
}
function bind() {
document.getElementById("labProfilesList")?.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-id]");
if (!btn) return;
selectProfile(btn.getAttribute("data-id")).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesType")?.addEventListener("change", async () => {
const type = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${type ? `?ration_type=${encodeURIComponent(type)}` : ""}`
);
profiles = data.profiles || [];
renderList();
});
document.getElementById("labProfilesSearch")?.addEventListener("input", renderList);
document.getElementById("labProfilesOnlyFilled")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesOnlyCalc")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesSourceFilter")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesNormSearch")?.addEventListener("input", renderNormsTable);
document.getElementById("labProfilesNormsRefresh")?.addEventListener("click", () => {
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesNew")?.addEventListener("click", newProfile);
document.getElementById("labProfilesSave")?.addEventListener("click", () => {
saveProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesDelete")?.addEventListener("click", () => {
deleteProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesNormsBody")?.addEventListener("change", (ev) => {
if (ev.target.matches("input[data-bound]")) collectNormsFromTable();
});
document.getElementById("labProfileMass")?.addEventListener("input", () => {
refreshDynamicNorms();
scheduleNormsCoverageRefresh();
});
document.getElementById("labProfileMilk")?.addEventListener("input", () => {
refreshDynamicNorms();
scheduleNormsCoverageRefresh();
});
document.getElementById("labProfileNormsMethod")?.addEventListener("change", syncNormsMethodUi);
["labProfileFat", "labProfileLactation", "labProfileKonc"].forEach((id) => {
document.getElementById(id)?.addEventListener("input", scheduleNormsCoverageRefresh);
});
document.getElementById("labProfileType")?.addEventListener("change", () => {
seedCatalogRation = null;
loadSeedCatalog(readRationType()).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesSeedSearch")?.addEventListener("input", renderSeedSelect);
document.getElementById("labProfilesSeedSelect")?.addEventListener("change", () => {
const applyBtn = document.getElementById("labProfilesSeedApply");
const sel = document.getElementById("labProfilesSeedSelect");
if (applyBtn && sel) applyBtn.disabled = !sel.value;
});
document.getElementById("labProfilesSeedApply")?.addEventListener("click", () => {
applySeedNorms().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesGfeApplyUsp")?.addEventListener("click", () => applyGfeMin(["usp"]));
document.getElementById("labProfilesGfeApplyNel")?.addEventListener("click", () => applyGfeMin(["nel"]));
document.getElementById("labProfilesGfeApplyAll")?.addEventListener("click", () =>
applyGfeMin(GFE_DYNAMIC_KEYS)
);
}
document.addEventListener("DOMContentLoaded", () => {
bind();
init().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
})(window);
@@ -0,0 +1,299 @@
(function (global) {
"use strict";
let diffByComponent = new Map();
let hooked = false;
function getActiveRecipeId() {
const fromEdit = document.getElementById("recipeEdit")?.dataset?.recipeId;
if (fromEdit) return fromEdit;
return document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || null;
}
function ensureToolbarButtons() {
const toolbar = document.querySelector("#recipeEdit .recipe-section-toolbar");
if (!toolbar) return;
if (!toolbar.querySelector('[data-action="sync-to-master"]')) {
const syncBtn = document.createElement("button");
syncBtn.type = "button";
syncBtn.className = "btn btn-outline-secondary";
syncBtn.setAttribute("data-action", "sync-to-master");
syncBtn.innerHTML = '<i class="fas fa-arrow-up me-1"></i>В мастер';
toolbar.insertBefore(syncBtn, toolbar.firstChild);
}
if (!toolbar.querySelector('[data-action="apply-from-master"]')) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-outline-primary";
btn.setAttribute("data-action", "apply-from-master");
btn.hidden = true;
btn.innerHTML = '<i class="fas fa-arrow-down me-1"></i>Из мастера';
const addBtn = toolbar.querySelector('[data-action="add-ingredient"]');
if (addBtn) toolbar.insertBefore(btn, addBtn);
else toolbar.appendChild(btn);
}
}
function ensureKHubButton() {
const actions = document.querySelector("#recipeEdit .recipe-edit-header-actions .d-flex");
if (!actions || actions.querySelector('[data-action="open-lab-k-hub"]')) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-outline-secondary recipe-edit-header-icon-btn";
btn.setAttribute("data-action", "open-lab-k-hub");
btn.title = "Открыть зоотех-мастер в K-hub";
btn.setAttribute("aria-label", "K-hub");
btn.innerHTML = '<i class="fas fa-flask" aria-hidden="true"></i>';
actions.insertBefore(btn, actions.firstChild);
}
function ensureRationTypeField() {
const grid = document.querySelector("#recipeEdit .recipe-form-grid");
if (!grid || document.getElementById("rationType")) return;
const wrap = document.createElement("div");
wrap.className = "form-group";
wrap.innerHTML =
'<label for="rationType">Тип стада</label>' +
'<select class="form-control" id="rationType">' +
'<option value="">—</option><option value="DAIRY">Дойное</option><option value="BEEF">Мясное</option>' +
"</select>";
const tripWrap = document.getElementById("tripPercent")?.closest(".form-group");
if (tripWrap?.parentElement === grid) {
tripWrap.insertAdjacentElement("afterend", wrap);
} else {
grid.appendChild(wrap);
}
}
function ensureDiffStyles() {
if (document.getElementById("wesp-lab-diff-styles")) return;
const style = document.createElement("style");
style.id = "wesp-lab-diff-styles";
style.textContent =
".lab-diff-badge{margin-left:6px;font-size:.7rem;vertical-align:middle;}" +
".lab-diff-badge--warn{background:#fff3cd;color:#664d03;}" +
".lab-diff-badge--miss{background:#f8d7da;color:#842029;}" +
".lab-recipe-quality{margin-top:4px;font-size:.78rem;color:var(--bs-secondary-color,#6c757d);}" +
".lab-recipe-quality__label{font-weight:600;margin-right:4px;}";
document.head.appendChild(style);
}
function diffBadgeHtml(reasons) {
if (!reasons?.length) return "";
const title = reasons.join(", ");
const cls = reasons.includes("missing_in_master") || reasons.includes("missing_in_execution")
? "lab-diff-badge--miss"
: "lab-diff-badge--warn";
return `<span class="badge lab-diff-badge ${cls}" title="${title}">мастер</span>`;
}
function applyDiffBadges() {
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach((row) => {
const select = row.querySelector(".ingredient-select");
const cell = row.querySelector('.ingredient-detail-cell[data-label="Ингредиент"] .recipe-row-name-with-skip');
if (!select || !cell) return;
cell.querySelectorAll(".lab-diff-badge").forEach((el) => el.remove());
const compId = select.value;
if (!compId) return;
const reasons = diffByComponent.get(String(compId));
if (!reasons?.length) return;
cell.insertAdjacentHTML("beforeend", diffBadgeHtml(reasons));
});
}
function ensureQualityBlock() {
const headerText = document.querySelector("#recipeEdit .recipe-edit-header-text");
if (!headerText || document.getElementById("labRecipeQuality")) return;
const block = document.createElement("div");
block.id = "labRecipeQuality";
block.className = "lab-recipe-quality";
block.hidden = true;
block.innerHTML =
'<span class="lab-recipe-quality__label">Зоотех:</span>' +
'<span class="lab-recipe-quality__items" data-lab-quality-items></span>';
headerText.appendChild(block);
}
async function refreshQuality(recipeId) {
ensureQualityBlock();
const block = document.getElementById("labRecipeQuality");
const items = block?.querySelector("[data-lab-quality-items]");
if (!block || !items || !recipeId) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
if (!resp.ok) throw new Error("no ration");
const data = await resp.json();
const indicators = (data.rationResults?.indicators || []).slice(0, 4);
if (!data.exists || !indicators.length) {
block.hidden = true;
return;
}
items.textContent = indicators
.map((ind) => `${ind.label}: ${ind.content ?? "—"} ${ind.unit || ""}`.trim())
.join(" · ");
block.hidden = false;
} catch (_) {
block.hidden = true;
}
}
async function refreshDiff(recipeId) {
const btn = document.querySelector('[data-action="apply-from-master"]');
if (!recipeId) return;
await refreshQuality(recipeId);
if (!btn) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/diff`);
const data = await resp.json();
diffByComponent = new Map();
(data.lines || []).forEach((line) => {
if (line.componentId) diffByComponent.set(String(line.componentId), line.reasons || []);
});
btn.hidden = !data.hasChanges;
applyDiffBadges();
} catch (_) {
btn.hidden = true;
diffByComponent = new Map();
}
}
function patchRecipeLoad() {
if (hooked) return;
const orig = global.loadRecipeDetails;
if (typeof orig !== "function") return;
global.loadRecipeDetails = async function (recipeId, options) {
const recipeEdit = document.getElementById("recipeEdit");
if (recipeEdit && recipeId) recipeEdit.dataset.recipeId = String(recipeId);
await orig(recipeId, options);
await refreshDiff(recipeId);
global.WespLabRecipesPlanOverlay?.decorateRows?.();
};
hooked = true;
}
function patchRecipeSave() {
const orig = global.doSaveRecipe;
if (typeof orig !== "function" || orig.__labPatched) return;
global.doSaveRecipe = async function (closeAfterSave) {
const rationEl = document.getElementById("rationType");
const prev = global.__wespLabRationTypeForSave;
if (rationEl) {
global.__wespLabRationTypeForSave = rationEl.value || null;
}
try {
return await orig(closeAfterSave);
} finally {
global.__wespLabRationTypeForSave = prev;
}
};
const origFetch = global.fetch.bind(global);
global.fetch = async function (input, init) {
const url = typeof input === "string" ? input : input?.url;
const method = (init?.method || "GET").toUpperCase();
if (
method === "PUT" &&
url &&
/\/api\/recipes\/[^/]+$/.test(url) &&
init?.body &&
global.__wespLabRationTypeForSave !== undefined
) {
try {
const body = JSON.parse(init.body);
body.ration_type = global.__wespLabRationTypeForSave || null;
init = { ...init, body: JSON.stringify(body) };
} catch (_) {
/* ignore */
}
}
return origFetch(input, init);
};
global.doSaveRecipe.__labPatched = true;
}
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn) return;
const action = btn.getAttribute("data-action");
const recipeId = getActiveRecipeId();
if (action === "open-lab-k-hub") {
global.WespZootechNotificationCenter?.openDailyPlan?.(recipeId || undefined);
return;
}
if (action === "sync-to-master") {
if (!recipeId) return;
const ok = await global.WespDialog?.confirm?.(
"Сохранить текущий рецепт в зоотех-мастер?",
{ title: "В мастер" }
);
if (!ok) return;
try {
const resp = await fetch(
`/api/lab/rations/${encodeURIComponent(recipeId)}/sync-from-execution`,
{ method: "POST" }
);
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка");
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер обновлён из рецепта");
await refreshDiff(recipeId);
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
return;
}
if (action !== "apply-from-master") return;
if (!recipeId) return;
const ok = await global.WespDialog?.confirm?.("Перенести ингредиенты из зоотех-мастера?", {
title: "Из мастера",
});
if (!ok) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, {
method: "POST",
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка");
if (typeof global.reloadRecipeEditor === "function") {
await global.reloadRecipeEditor(recipeId);
} else if (typeof global.loadRecipeDetails === "function") {
await global.loadRecipeDetails(recipeId);
}
await refreshDiff(recipeId);
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер применён к рецепту");
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось применить мастер" });
}
});
document.addEventListener("change", (ev) => {
if (!ev.target?.matches?.(".ingredient-select")) return;
applyDiffBadges();
});
global.WespLabRecipesOverlay = {
init() {
ensureDiffStyles();
ensureToolbarButtons();
ensureKHubButton();
ensureRationTypeField();
patchRecipeLoad();
patchRecipeSave();
},
refreshDiff,
refreshQuality,
applyDiffBadges,
};
function boot() {
global.WespLabRecipesOverlay.init();
if (typeof global.loadRecipeDetails === "function") patchRecipeLoad();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(window);
@@ -0,0 +1,252 @@
(function (global) {
"use strict";
let observer = null;
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function planDate() {
return (
global.WespDailyPlanPanel?.getSelectedPlanDate?.() ||
global.WespDailyPlanSkipDuration?.todayIso?.() ||
new Date().toISOString().slice(0, 10)
);
}
function getRecipeId() {
return (
document.getElementById("recipeEdit")?.dataset?.recipeId ||
document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId ||
null
);
}
function notifySuccess(message) {
global.WespZootechNotify?.createAdapter?.()?.success?.(message);
}
function notifyError(message, fallback) {
global.WespZootechNotify?.createAdapter?.()?.error?.(message, { fallback: fallback || message });
}
async function reloadEditor() {
const recipeId = getRecipeId();
if (!recipeId) return;
if (typeof global.reloadRecipeEditor === "function") {
await global.reloadRecipeEditor(recipeId);
} else if (typeof global.loadRecipeDetailsWithPlanOverlay === "function") {
await global.loadRecipeDetailsWithPlanOverlay();
} else if (typeof global.loadRecipeDetails === "function") {
await global.loadRecipeDetails(recipeId);
}
}
function planBtnHtml(action, title, icon, attrs = "") {
return (
`<button type="button" class="btn btn-outline-secondary btn-sm recipe-plan-action-btn" ` +
`data-action="${action}" title="${escapeHtml(title)}" ${attrs}>` +
`<i class="fas ${icon}"></i></button>`
);
}
function decorateRow(row) {
const actions = row.querySelector(".recipe-table-row-actions");
if (!actions || actions.querySelector(".recipe-plan-actions")) return;
const ingredientId = row.dataset.id;
if (!ingredientId) return;
const skipped = row.classList.contains("ingredient-row--skipped");
const replaced = row.classList.contains("ingredient-row--replaced");
const select = row.querySelector(".ingredient-select");
const componentId = select?.value || "";
const label = select?.selectedOptions?.[0]?.textContent?.trim() || "Компонент";
const recipeId = getRecipeId();
if (!recipeId) return;
const wrap = document.createElement("div");
wrap.className = "btn-group recipe-plan-actions me-1";
wrap.setAttribute("role", "group");
if (skipped) {
wrap.innerHTML = planBtnHtml(
"recipe-plan-unskip-ingredient",
"Вернуть в план",
"fa-undo",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
);
} else if (replaced) {
wrap.innerHTML = planBtnHtml(
"recipe-plan-undo-replace-ingredient",
"Отменить замену",
"fa-undo",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
);
} else {
wrap.innerHTML =
planBtnHtml(
"recipe-plan-skip-ingredient",
"Убрать из плана",
"fa-ban",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" data-label="${escapeHtml(label)}"`
) +
(componentId
? planBtnHtml(
"recipe-plan-replace-ingredient",
"Заменить в плане",
"fa-exchange-alt",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" ` +
`data-component-id="${escapeHtml(componentId)}" data-label="${escapeHtml(label)}"`
)
: "");
}
const deleteBtn = actions.querySelector('[data-action="remove-ingredient"]');
if (deleteBtn) actions.insertBefore(wrap, deleteBtn);
else actions.appendChild(wrap);
}
function decorateRows() {
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach(decorateRow);
}
function ensureStyles() {
if (document.getElementById("wesp-lab-plan-overlay-styles")) return;
const style = document.createElement("style");
style.id = "wesp-lab-plan-overlay-styles";
style.textContent =
".recipe-plan-action-btn{padding:2px 6px;}" +
".recipe-plan-actions .btn+.btn{margin-left:2px;}";
document.head.appendChild(style);
}
async function skipIngredient(recipeId, ingredientId, label) {
const skip = global.WespDailyPlanSkipDuration;
if (!skip?.skipDurationBody) return;
const body = await skip.skipDurationBody(planDate(), { recipeId, ingredientId }, "На какой срок убрать компонент?");
if (!body) return;
const resp = await fetch("/api/daily-plan/skips/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать компонент");
}
await reloadEditor();
notifySuccess(`«${label}» убран из плана`);
}
async function unskipIngredient(recipeId, ingredientId) {
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть компонент");
}
await reloadEditor();
notifySuccess("Компонент снова в плане");
}
async function openReplace(recipeId, ingredientId, componentId, label) {
const replaceModal = global.WespDailyPlanReplaceModal;
if (!replaceModal?.open) {
notifyError("Модалка замены недоступна");
return;
}
await replaceModal.open({
currentName: label,
onPick: async (replacementComponentId, replacementName) => {
const skip = global.WespDailyPlanSkipDuration;
const body = await skip?.skipDurationBody?.(
planDate(),
{ recipeId, ingredientId, replacementComponentId },
"На какой срок заменить компонент?"
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось заменить компонент");
}
await reloadEditor();
notifySuccess(`Компонент заменён на «${replacementName}»`);
},
});
}
async function undoReplace(recipeId, ingredientId) {
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось отменить замену");
}
await reloadEditor();
notifySuccess("Замена отменена");
}
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn) return;
const action = btn.dataset.action;
if (!action?.startsWith("recipe-plan-")) return;
if (!document.getElementById("recipeEdit")?.contains(btn)) return;
ev.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
try {
if (action === "recipe-plan-skip-ingredient") {
await skipIngredient(recipeId, ingredientId, btn.dataset.label || "Компонент");
} else if (action === "recipe-plan-unskip-ingredient") {
await unskipIngredient(recipeId, ingredientId);
} else if (action === "recipe-plan-replace-ingredient") {
await openReplace(recipeId, ingredientId, btn.dataset.componentId, btn.dataset.label || "Компонент");
} else if (action === "recipe-plan-undo-replace-ingredient") {
await undoReplace(recipeId, ingredientId);
}
} catch (e) {
notifyError(e.message, "Ошибка плана");
}
});
function watchIngredients() {
const tbody = document.getElementById("ingredientsTableBody");
if (!tbody || observer) return;
observer = new MutationObserver(() => decorateRows());
observer.observe(tbody, { childList: true, subtree: true });
}
global.WespLabRecipesPlanOverlay = {
init() {
ensureStyles();
watchIngredients();
decorateRows();
},
decorateRows,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => global.WespLabRecipesPlanOverlay.init());
} else {
global.WespLabRecipesPlanOverlay.init();
}
})(window);
@@ -0,0 +1,525 @@
(function (global) {
"use strict";
const INDICATOR_LABELS = {
dry_matter: "Сухое вещество",
dm_main: "СВ — основной корм",
oe: "ОЭ — КРС",
nel: "ЧЭЛ — КРС",
nel_per_kg_dm: "ЧЭЛ/кг СВ",
crude_protein: "Сырой протеин",
rup_per_kg_dm: "Нер. СП / кг СВ",
insoluble_protein: "Нерастворимый протеин",
usp: "уСП",
usp_pct_dm: "% уСП/кг СВ",
ndf: "Сырая клетчатка",
structural_fiber: "Структур. клетчатка",
crude_fat: "Сырой жир",
rnb: "RNB",
calcium: "Ca",
phosphorus: "P",
magnesium: "Mg",
sodium: "Na",
dcab: "DCAB",
sugar_starch: "Сахар и крахмал",
insoluble_starch: "Нераств. крахмал",
sugar: "Сахар",
starch: "Крахмал",
carotene: "Каротин",
};
let state = null;
let recipeId = null;
let components = [];
let profiles = [];
let normCatalog = [];
async function loadNormCatalog() {
if (normCatalog.length) return normCatalog;
try {
const data = await api("/api/lab/norm-indicators");
normCatalog = data.indicators || [];
} catch (_e) {
normCatalog = [];
}
return normCatalog;
}
function indicatorMeta(key) {
const fromCat = normCatalog.find((item) => item.key === key);
if (fromCat) return fromCat;
return { key, label: INDICATOR_LABELS[key] || key, unit: "" };
}
function mergeIndicatorsForDisplay(calcRows, norms) {
const byKey = new Map();
for (const ind of calcRows || []) {
if (ind?.key) byKey.set(ind.key, { ...ind });
}
for (const key of Object.keys(norms || {})) {
const bounds = norms[key] || {};
if (bounds.min == null && bounds.max == null) continue;
const meta = indicatorMeta(key);
const existing = byKey.get(key);
if (existing) {
existing.min = bounds.min ?? existing.min;
existing.max = bounds.max ?? existing.max;
existing.label = existing.label || meta.label;
existing.unit = existing.unit || meta.unit || "";
} else {
byKey.set(key, {
key,
label: meta.label,
unit: meta.unit || "",
content: null,
min: bounds.min,
max: bounds.max,
diff: null,
});
}
}
return [...byKey.values()];
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function labError(message) {
global.WespLabHeisenbergPrime?.error?.(message, "sandbox");
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
}
function formatKgInput(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
return String(Math.round(n * 100) / 100);
}
function diffClass(diff) {
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
const d = Number(diff);
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
return "lab-sandbox-diff--bad";
}
async function loadCatalogs(rationType) {
const [compResp, profResp] = await Promise.all([
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
]);
if (Array.isArray(compResp)) components = compResp;
profiles = profResp.profiles || [];
}
async function loadRecipes() {
try {
const data = await api("/api/lab/recipes");
return data.recipes || [];
} catch (_e) {
return [];
}
}
function componentOptions(selectedId) {
const opts = ['<option value="">— реагент —</option>'];
components.forEach((c) => {
const sel = String(c.id) === String(selectedId) ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
});
return opts.join("");
}
function profileOptions(selectedId) {
const opts = ['<option value="">— стандарт чистоты —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
const key = p.profileKey ? `[${p.profileKey}] ` : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
);
});
return opts.join("");
}
function hasNormBounds(ind) {
return ind != null && (ind.min != null || ind.max != null);
}
function formatNormBounds(bounds) {
const b = bounds || {};
const min = b.min != null ? formatNum(b.min) : null;
const max = b.max != null ? formatNum(b.max) : null;
if (min != null && max != null) return `${min}${max}`;
if (max != null) return `${max}`;
if (min != null) return `${min}`;
return "—";
}
function formatIndicatorNorm(ind) {
if (!hasNormBounds(ind)) {
return '<span class="lab-sandbox-norm--empty" title="Для этого стандарта нет границы">вне стандарта</span>';
}
return formatNormBounds(ind);
}
function formatIndicatorDiff(ind) {
if (!hasNormBounds(ind)) return "—";
return formatNum(ind.diff);
}
function renderNormsPanel() {
const el = document.getElementById("labSandboxNorms");
if (!el) return;
const norms = state?.norms || {};
const keys = Object.keys(norms);
if (!keys.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Выбери стандарт чистоты</div>';
return;
}
let note = "";
el.innerHTML =
note +
`<dl class="lab-sandbox-norms">` +
keys
.map((key) => {
const b = norms[key] || {};
const label = INDICATOR_LABELS[key] || key;
return `<dt>${escapeHtml(label)}</dt><dd>${formatNormBounds(b)}</dd>`;
})
.join("") +
`</dl>`;
}
function renderResultsPanel() {
const el = document.getElementById("labSandboxResults");
if (!el) return;
const calcRows = state?.rationResults?.indicators || [];
const totals = state?.rationResults?.totals || [];
const norms = state?.norms || {};
const indicators = mergeIndicatorsForDisplay(calcRows, norms);
if (!indicators.length && !totals.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Нажми «Примени науку»</div>';
return;
}
let html = "";
if (!Object.keys(norms).length && calcRows.length) {
html +=
'<p class="small text-warning mb-2">Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».</p>';
}
if (totals.length) {
html +=
`<p class="small text-muted mb-1">Синий — значит чистый</p><ul class="small mb-2">` +
totals.map((t) => `<li>${escapeHtml(t.label)}: ${formatNum(t.value)}</li>`).join("") +
`</ul>`;
}
if (indicators.length) {
const withNorm = indicators.filter((ind) => hasNormBounds(ind)).length;
const rows = indicators.filter((ind) => hasNormBounds(ind) || ind.content != null);
const sorted = rows.slice().sort((a, b) => {
const an = hasNormBounds(a) ? 0 : 1;
const bn = hasNormBounds(b) ? 0 : 1;
if (an !== bn) return an - bn;
return String(a.label).localeCompare(String(b.label), "ru");
});
html +=
`<p class="small text-muted mb-1">Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.</p>` +
`<table><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
sorted
.map((ind) => {
const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na";
return (
`<tr>` +
`<td>${escapeHtml(ind.label)}</td>` +
`<td>${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}</td>` +
`<td>${formatIndicatorNorm(ind)}</td>` +
`<td class="${dc}">${formatIndicatorDiff(ind)}</td>` +
`</tr>`
);
})
.join("") +
`</tbody></table>`;
}
if (state?.calculatedAt) {
html += `<p class="small text-muted mt-2 mb-0">Готово: ${escapeHtml(state.calculatedAt)}</p>`;
}
el.innerHTML = html;
}
function renderLines() {
const tbody = document.getElementById("labSandboxLinesBody");
if (!tbody) return;
const lines = state?.lines || [];
if (!lines.length) {
tbody.innerHTML =
`<tr><td colspan="6" class="lab-sandbox-empty">Партия пуста — добавь реагент или возьми из WESP</td></tr>`;
return;
}
tbody.innerHTML = lines
.map(
(line, i) =>
`<tr data-line-idx="${i}">` +
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptions(line.componentId)}</select></td>` +
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${formatKgInput(line.dailyKg)}"></td>` +
`<td class="text-center"><input type="checkbox" data-field="inRation" ${line.inRation ? "checked" : ""}></td>` +
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${line.inCompound ? "checked" : ""}></td>` +
`<td class="small text-muted">${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}</td>` +
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="remove-line" data-idx="${i}">&times;</button></td>` +
`</tr>`
)
.join("");
}
function renderSetup() {
const recipeSel = document.getElementById("labSandboxRecipe");
const profileSel = document.getElementById("labSandboxProfile");
const typeSel = document.getElementById("labSandboxRationType");
if (profileSel) profileSel.innerHTML = profileOptions(state?.animalProfileId);
if (typeSel && state?.rationType) typeSel.value = state.rationType;
if (profileSel && state?.animalProfileId) profileSel.value = state.animalProfileId;
if (recipeSel && recipeId) recipeSel.value = recipeId;
const meta = document.getElementById("labSandboxMeta");
if (meta && state) {
meta.textContent = `${state.recipeName || ""} · ${state.headsPerTrip || 1} гол. · кг/сут на стадо · ${state.exists ? "формула есть" : "формула пуста — готовим?"}`;
}
}
function collectLines() {
const tbody = document.getElementById("labSandboxLinesBody");
if (!tbody || !state) return state.lines || [];
return (state.lines || []).map((line, i) => {
const row = tbody.querySelector(`tr[data-line-idx="${i}"]`);
if (!row) return line;
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
const comp = components.find((c) => String(c.id) === String(componentId));
return {
...line,
id: line.id,
componentId,
ingredientName: comp?.name || line.ingredientName,
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
inRation: row.querySelector('[data-field="inRation"]')?.checked,
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
};
});
}
function collectPayload() {
return {
lines: collectLines(),
rationType: document.getElementById("labSandboxRationType")?.value || state?.rationType,
animalProfileId: document.getElementById("labSandboxProfile")?.value || null,
};
}
async function reloadRation() {
if (!recipeId) return;
await loadNormCatalog();
state = await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
if (state) {
state.normsProfileKey = state.normsProfileKey || null;
state.legacyNormsRemapped = Boolean(state.legacyNormsRemapped);
}
await loadCatalogs(state.rationType);
if (state.legacyNormsRemapped && state.normsProfileKey) {
const fp = profiles.find((p) => p.profileKey === state.normsProfileKey);
if (fp) state.animalProfileId = fp.id;
}
renderSetup();
renderLines();
renderNormsPanel();
renderResultsPanel();
}
async function ensureMaster() {
if (!recipeId) return;
if (state?.exists) return;
const ok = await global.WespDialog?.confirm?.("Взять состав из WESP и начать готовку?", {
title: "Нам нужно готовить",
});
if (ok) {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
} else {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { method: "POST" });
}
await reloadRation();
}
async function initRecipeSelect() {
const sel = document.getElementById("labSandboxRecipe");
if (!sel) return;
const recipes = await loadRecipes();
sel.innerHTML =
'<option value="">— партия —</option>' +
recipes.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
const params = new URLSearchParams(global.location.search);
const q = params.get("recipe");
if (q) {
sel.value = q;
recipeId = q;
await reloadRation();
await ensureMaster();
}
}
function bindEvents() {
document.getElementById("labSandboxRecipe")?.addEventListener("change", async (ev) => {
recipeId = ev.target.value || null;
if (!recipeId) {
state = null;
renderLines();
renderResultsPanel();
return;
}
await reloadRation();
await ensureMaster();
});
document.getElementById("labSandboxProfile")?.addEventListener("change", async (ev) => {
const pid = ev.target.value;
if (!pid) {
if (state) state.norms = {};
renderNormsPanel();
return;
}
try {
const profile = await api(`/api/lab/animal-profiles/${encodeURIComponent(pid)}`);
if (state) {
state.animalProfileId = pid;
state.norms =
profile.resolvedNorms ||
profile.normsData?.resolvedIndicators ||
profile.normsData?.indicators ||
{};
state.normsProfileKey = profile.normsProfileKey || profile.profileKey;
state.legacyNormsRemapped = Boolean(profile.legacyNormsRemapped);
if (profile.rationType) state.rationType = profile.rationType;
}
const typeSel = document.getElementById("labSandboxRationType");
if (typeSel && profile.rationType) typeSel.value = profile.rationType;
renderNormsPanel();
} catch (e) {
labError(e.message);
}
});
document.getElementById("labSandboxRationType")?.addEventListener("change", async (ev) => {
await loadCatalogs(ev.target.value);
renderSetup();
});
document.getElementById("labSandboxLinesBody")?.addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-action='remove-line']");
if (!btn || !state) return;
const idx = parseInt(btn.getAttribute("data-idx"), 10);
if (!Number.isNaN(idx)) {
state.lines = (state.lines || []).filter((_, i) => i !== idx);
renderLines();
}
});
document.querySelector("[data-action='add-line']")?.addEventListener("click", () => {
if (!state) state = { lines: [] };
state.lines = [
...(state.lines || []),
{ componentId: "", ingredientName: "", dailyKg: 0, inRation: true, inCompound: false },
];
renderLines();
});
document.querySelector("[data-action='save']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — без этого не готовим");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await reloadRation();
global.WespZootechNotify?.createAdapter?.()?.success?.("Запомни это — формула сохранена");
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='recalculate']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — науку некуда применять");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { method: "POST" });
await reloadRation();
global.WespZootechNotify?.createAdapter?.()?.success?.("Применена наука — 99,1% чистоты");
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='seed']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — нечего брать с поля");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
await reloadRation();
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='apply']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — некуда отправлять");
return;
}
const ok = await global.WespDialog?.confirm?.("Отправить кг/сут из лаборатории в WESP?");
if (!ok) return;
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { method: "POST" });
global.WespZootechNotify?.createAdapter?.()?.success?.("Отправлено в WESP. Ты чертовски прав");
} catch (e) {
labError(e.message);
}
});
}
function init() {
bindEvents();
loadNormCatalog();
initRecipeSelect();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
+284
View File
@@ -0,0 +1,284 @@
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=6";
import {
markLoginTransition,
storePreloadedApiPayload,
PRELOAD_FEED_DISPENSERS_KEY,
} from "/static/js/wesp-nav-preload.js";
const LS_LOGIN = "wesp_saved_login";
const LS_REMEMBER = "wesp_remember_login";
const THEME_STORAGE_KEY = "theme";
const POST_LOGIN_REDIRECT_DELAY_MS = 300;
/** Длительность анимации логотипа после успешного входа (на странице login). */
const LOGIN_EXIT_ANIM_MS = 3500;
const RECIPES_WARM_URLS = [
"/recipes",
"/static/css/bootstrap.min.css",
"/static/css/wesp-zootech-shell.css",
"/static/css/wesp-zootech-layout.css",
"/static/css/wesp-zootech-components.css",
"/static/css/wesp-recipes-skeleton.css",
"/static/css/wesp-logo-loader.css",
"/static/css/notyf.min.css",
"/static/css/font-awesome.min.css",
"/static/js/wesp-theme-boot.js",
"/static/js/wesp-logo-loader.js?v=6",
"/static/js/wesp-page-enter.js",
"/static/js/wesp-nav-logo-shimmer.js",
"/static/js/wesp-zootech-nav.js",
"/static/js/notyf.min.js",
"/static/js/bootstrap.bundle.min.js",
"/static/js/wesp-dialog.js",
"/static/js/pages/recipes-page.js",
"/static/js/pages/recipes-auth-settings.js",
"/static/js/pages/recipes-data-controller.js",
"/static/js/pages/recipes-operations-controller.js",
"/static/js/pages/recipes-editor-controller.js",
"/static/js/modules/app-state.js",
];
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function preloadUrl(url) {
return fetch(url, { credentials: "same-origin" }).catch(() => null);
}
async function preloadRecipesAfterLogin() {
markLoginTransition();
const apiTask = preloadUrl("/api/feed_dispensers").then(async (response) => {
if (!response?.ok) return;
const payload = await response.json();
if (Array.isArray(payload)) {
storePreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY, payload);
}
});
await Promise.allSettled([
apiTask,
...RECIPES_WARM_URLS.map((url) => preloadUrl(url)),
]);
}
async function finishLoginTransition(target) {
const preloadPromise = preloadRecipesAfterLogin();
await playLoginExitAnimation();
await Promise.all([preloadPromise, delay(POST_LOGIN_REDIRECT_DELAY_MS)]);
}
function themeForLogoLoader() {
try {
const saved = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
} catch {
/* ignore */
}
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "light" || attr === "organic" || attr === "dark") return attr;
return "dark";
}
function isDarkTheme() {
return themeForLogoLoader() === "dark";
}
function safeNextPath(raw) {
if (raw == null || typeof raw !== "string") return null;
const s = raw.trim();
if (!s || s.charAt(0) !== "/") return null;
if (s.indexOf("//") === 0) return null;
if (s.indexOf("://") !== -1) return null;
if (s.indexOf("\0") !== -1 || s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) return null;
const pathOnly = s.split("?", 1)[0];
if (pathOnly.indexOf("@") !== -1) return null;
return s;
}
function postLoginRedirectUrl() {
try {
const params = new URLSearchParams(window.location.search);
const n = safeNextPath(params.get("next"));
if (n) return n;
} catch (e) {
/* ignore */
}
return "/recipes";
}
function showError(message) {
const errorDiv = document.getElementById("errorMessage");
if (!errorDiv) return;
errorDiv.textContent = message;
errorDiv.style.display = "block";
window.setTimeout(() => {
errorDiv.style.display = "none";
}, 5000);
}
function setLoading(loading) {
const button = document.getElementById("authButton");
const buttonText = document.getElementById("buttonText");
const spinner = document.getElementById("loadingSpinner");
const loginInput = document.getElementById("loginInput");
const passwordInput = document.getElementById("passwordInput");
const rememberInput = document.getElementById("rememberInput");
if (button) button.disabled = loading;
if (loginInput) loginInput.disabled = loading;
if (passwordInput) passwordInput.disabled = loading;
if (rememberInput) rememberInput.disabled = loading;
if (buttonText && spinner) {
buttonText.style.display = loading ? "none" : "block";
spinner.style.display = loading ? "block" : "none";
}
}
function applyRememberToStorage(login, remember) {
if (remember) {
try {
localStorage.setItem(LS_LOGIN, login);
localStorage.setItem(LS_REMEMBER, "1");
} catch (e) {
/* ignore */
}
} else {
try {
localStorage.removeItem(LS_LOGIN);
localStorage.removeItem(LS_REMEMBER);
} catch (e) {
/* ignore */
}
}
}
function loadRememberFromStorage() {
try {
if (localStorage.getItem(LS_REMEMBER) === "1") {
const saved = localStorage.getItem(LS_LOGIN);
if (saved) {
const loginInput = document.getElementById("loginInput");
const rememberInput = document.getElementById("rememberInput");
if (loginInput) loginInput.value = saved;
if (rememberInput) rememberInput.checked = true;
}
}
} catch (e) {
/* ignore */
}
}
async function checkSession() {
try {
const r = await fetch("/api/auth/check", { credentials: "same-origin" });
const data = await r.json();
if (data.authenticated && data.remember_login) {
const target = postLoginRedirectUrl();
await finishLoginTransition(target);
window.location.href = target;
}
} catch (e) {
/* stay on login */
}
}
function playLoginExitAnimation() {
return new Promise((resolve) => {
const page = document.querySelector(".z-login-page");
if (page) page.classList.add("z-login-page--hidden");
const backdrop = document.createElement("div");
backdrop.className = "z-login-backdrop";
document.body.appendChild(backdrop);
const wrap = document.createElement("div");
wrap.className = "z-login-loader";
const root = document.createElement("div");
wrap.appendChild(root);
document.body.appendChild(wrap);
mountWespLogoLoader(root, {
loop: false,
durationMs: LOGIN_EXIT_ANIM_MS,
totalDurationMs: LOGIN_EXIT_ANIM_MS,
embedded: true,
transparentBackground: true,
knockoutBackground: true,
theme: themeForLogoLoader(),
onComplete: () => resolve(),
}).catch(() => resolve());
});
}
async function authenticate(event) {
event.preventDefault();
const login = document.getElementById("loginInput")?.value?.trim() || "";
const password = document.getElementById("passwordInput")?.value || "";
const remember = !!document.getElementById("rememberInput")?.checked;
if (!login) {
showError("Введите логин");
return;
}
if (!password) {
showError("Введите пароль");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ login, password, remember }),
});
const data = await response.json();
if (data.status === "success") {
applyRememberToStorage(login, remember);
const target = postLoginRedirectUrl();
await finishLoginTransition(target);
window.location.href = target;
return;
}
showError(data.message || "Неверный логин или пароль");
const passwordInput = document.getElementById("passwordInput");
if (passwordInput) {
passwordInput.value = "";
passwordInput.focus();
}
setLoading(false);
} catch (error) {
showError("Ошибка соединения. Попробуйте снова.");
console.error("Authentication error:", error);
setLoading(false);
}
}
function initLoginPage() {
loadRememberFromStorage();
checkSession();
document.getElementById("loginInput")?.focus();
document.getElementById("loginForm")?.addEventListener("submit", authenticate);
document.getElementById("loginInput")?.addEventListener("keypress", (e) => {
if (e.key === "Enter") document.getElementById("passwordInput")?.focus();
});
document.getElementById("passwordInput")?.addEventListener("keypress", (e) => {
if (e.key === "Enter") authenticate(e);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLoginPage);
} else {
initLoginPage();
}
@@ -0,0 +1,95 @@
/**
* K-hub «Мультисервер»: manual conflict resolution (orchestrator vs farm hub).
*/
(function (global) {
const API = "/api/v1/sync/conflicts";
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderList(root, items, enterpriseId) {
if (!items.length) {
root.innerHTML = '<p class="text-muted small">Нет конфликтов синхронизации.</p>';
return;
}
root.innerHTML =
'<table class="table table-sm zt-multiserver-table"><thead><tr>' +
"<th>Таблица</th><th>Запись</th><th>Ферма</th><th></th></tr></thead><tbody>" +
items
.map(
(c) =>
`<tr data-conflict-id="${escapeHtml(c.id)}">` +
`<td>${escapeHtml(c.table_name)}</td>` +
`<td>${escapeHtml(c.record_id)}</td>` +
`<td>${escapeHtml(c.farm_hub_id || "—")}</td>` +
`<td><button type="button" class="btn btn-sm btn-outline-primary" data-action="open-conflict">Открыть</button></td>` +
"</tr>",
)
.join("") +
"</tbody></table>";
root.querySelectorAll("[data-action=open-conflict]").forEach((btn) => {
btn.addEventListener("click", () => {
const row = btn.closest("tr");
const id = row && row.getAttribute("data-conflict-id");
if (id) openDetail(root, id, enterpriseId);
});
});
}
function openDetail(root, conflictId, enterpriseId) {
fetch(`${API}/${conflictId}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((detail) => {
root.innerHTML =
`<div class="zt-multiserver-detail">` +
`<h4>${escapeHtml(detail.table_name)} · ${escapeHtml(detail.record_id)}</h4>` +
`<div class="row"><div class="col-md-6"><h5>Оркестр</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.orchestrator_snapshot, null, 2))}</pre></div>` +
`<div class="col-md-6"><h5>Сервер</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.hub_snapshot, null, 2))}</pre></div></div>` +
`<div class="zt-multiserver-actions">` +
`<button type="button" class="btn btn-primary btn-sm" data-resolution="keep_orchestrator">Принять с оркестра</button> ` +
`<button type="button" class="btn btn-secondary btn-sm" data-resolution="keep_hub">Принять с сервера</button> ` +
`<button type="button" class="btn btn-link btn-sm" data-action="back-list">Назад</button>` +
`</div></div>`;
root.querySelector("[data-action=back-list]")?.addEventListener("click", () => load(root, enterpriseId));
root.querySelectorAll("[data-resolution]").forEach((btn) => {
btn.addEventListener("click", () => {
const resolution = btn.getAttribute("data-resolution");
fetch(`${API}/${conflictId}/resolve?enterprise_id=${encodeURIComponent(enterpriseId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution }),
}).then(() => load(root, enterpriseId));
});
});
});
}
function load(root, enterpriseId) {
root.innerHTML = '<p class="text-muted small">Загрузка конфликтов…</p>';
fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => renderList(root, items || [], enterpriseId))
.catch(() => {
root.innerHTML = '<p class="text-danger small">Не удалось загрузить конфликты.</p>';
});
}
global.WespMultiserverPanel = {
mount(rootEl, enterpriseId) {
if (!rootEl) return;
load(rootEl, enterpriseId || "");
},
pendingCount(enterpriseId) {
return fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => (Array.isArray(items) ? items.length : 0))
.catch(() => 0);
},
};
})(window);
@@ -0,0 +1,404 @@
import { initThemePicker, initHighContrastToggle, initThemeToggle, applyTheme, getTheme } from "/static/js/wesp-theme.js";
import {
DEFAULT_PASSWORD_MIN_LEN,
validateCredentialChange,
} from "/static/js/wesp-user-credentials.js";
export function createRecipesAuthSettingsController({ notyf }) {
let settingsModalClickHandler = null;
let settingsEscapeHandler = null;
let syncDispenserNames = [];
let passwordMinLen = DEFAULT_PASSWORD_MIN_LEN;
function escapeHtmlSyncSettings(value) {
if (value == null) return "";
const div = document.createElement("div");
div.textContent = String(value);
return div.innerHTML;
}
function closeSettings() {
const modal = document.getElementById("settingsModal");
if (!modal) return;
modal.style.display = "none";
document.body.style.overflow = "";
const form = document.getElementById("settingsForm");
if (form) form.reset();
const credForm = document.getElementById("credentialsForm");
if (credForm) credForm.style.display = "none";
const userCard = document.querySelector(".settings-user-card--clickable");
if (userCard) userCard.classList.remove("open");
const syncForm = document.getElementById("syncSettingsForm");
if (syncForm) syncForm.style.display = "none";
const syncCard = document.querySelector(".settings-sync-card--clickable");
if (syncCard) syncCard.classList.remove("open");
if (settingsModalClickHandler) {
modal.removeEventListener("click", settingsModalClickHandler);
settingsModalClickHandler = null;
}
if (settingsEscapeHandler) {
document.removeEventListener("keydown", settingsEscapeHandler);
settingsEscapeHandler = null;
}
}
async function openSettings(event) {
if (event) event.preventDefault();
try {
const response = await fetch("/api/auth/get_current_credentials");
const data = await response.json();
if (data.status === "success") {
const oldLogin = document.getElementById("oldLogin");
const oldPassword = document.getElementById("oldPassword");
if (oldLogin) oldLogin.value = data.login || "";
if (oldPassword) oldPassword.value = "";
}
} catch (error) {
console.error("Ошибка загрузки текущих учетных данных:", error);
}
const modal = document.getElementById("settingsModal");
if (!modal) return;
modal.style.display = "block";
document.body.style.overflow = "hidden";
initThemePicker();
initHighContrastToggle();
if (settingsModalClickHandler) {
modal.removeEventListener("click", settingsModalClickHandler);
}
if (settingsEscapeHandler) {
document.removeEventListener("keydown", settingsEscapeHandler);
}
settingsModalClickHandler = (e) => {
if (e.target === modal) closeSettings();
};
settingsEscapeHandler = (e) => {
if (e.key === "Escape" && modal.style.display === "block") closeSettings();
};
modal.addEventListener("click", settingsModalClickHandler);
document.addEventListener("keydown", settingsEscapeHandler);
}
async function checkAuth() {
try {
const response = await fetch("/api/auth/check");
const data = await response.json();
if (data.status === "success" && data.authenticated) {
const userInfo = document.getElementById("userInfo");
const userLogin = document.getElementById("userLogin");
const mobileUserInfo = document.getElementById("mobileUserInfo");
const canOpenAdmin = Boolean(data.is_superuser);
window.WESP_CAN_LAB = Boolean(data.can_lab);
window.WespLabAccess?.apply?.(window.WESP_CAN_LAB);
const rules = data.user_rules || {};
passwordMinLen = Number(rules.password_min_len) || DEFAULT_PASSWORD_MIN_LEN;
if (userLogin) userLogin.textContent = data.user_login;
if (userInfo) {
userInfo.style.cursor = canOpenAdmin ? "pointer" : "";
userInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
userInfo.onclick = canOpenAdmin
? () => {
window.location.href = "/admin";
}
: null;
}
if (mobileUserInfo) {
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${data.user_login}`;
mobileUserInfo.style.cursor = canOpenAdmin ? "pointer" : "";
mobileUserInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
mobileUserInfo.onclick = canOpenAdmin
? () => {
window.location.href = "/admin";
}
: null;
}
} else {
window.location.href = "/login";
}
return data;
} catch (error) {
console.error("Ошибка проверки авторизации:", error);
window.location.href = "/login";
return null;
}
}
async function logout() {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await response.json();
if (data.status === "success") {
window.location.href = "/";
} else {
notyf.error("Ошибка при выходе из системы");
}
} catch (error) {
console.error("Ошибка при выходе:", error);
notyf.error("Ошибка при выходе из системы");
}
}
function setupMobileMenu() {
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return;
hamburgerMenu.addEventListener("click", () => {
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
document.body.style.overflow = "hidden";
const userInfo = document.getElementById("userInfo");
if (userInfo && mobileUserInfo) {
mobileUserInfo.textContent = userInfo.textContent || "";
}
});
const closeMobileMenu = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
document.body.style.overflow = "";
};
closeMenu.addEventListener("click", closeMobileMenu);
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) closeMobileMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) {
closeMobileMenu();
}
});
const mobileMenuItems = document.querySelectorAll(".mobile-menu-item");
mobileMenuItems.forEach((item) => {
item.addEventListener("click", () => {
setTimeout(closeMobileMenu, 300);
});
});
}
async function loadSyncClientsSettings() {
const listEl = document.getElementById("syncClientsListSettings");
const emptyEl = document.getElementById("syncClientsEmptySettings");
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.style.display = "none";
try {
const [clientsRes, namesRes] = await Promise.all([
fetch("/api/sync/clients"),
fetch("/api/feed_dispensers/names"),
]);
const clients = clientsRes.ok ? await clientsRes.json() : [];
syncDispenserNames = namesRes.ok ? await namesRes.json() : [];
if (!clients || clients.length === 0) {
if (emptyEl) {
emptyEl.style.display = "block";
emptyEl.textContent = "Нет подключенных клиентов";
}
return;
}
clients.forEach((client) => {
const displayLabel = client.display_name || client.client_name || client.node_id;
const item = document.createElement("div");
item.className = "settings-sync-client-item";
item.dataset.nodeId = client.node_id;
item.dataset.displayName = displayLabel || "";
item.innerHTML =
`<span class="settings-sync-client-name">${escapeHtmlSyncSettings(displayLabel)}</span>` +
'<div class="settings-sync-client-actions">' +
'<button type="button" class="btn btn-sm btn-edit btn-outline-primary" data-action="sync-edit-client"><i class="fas fa-edit"></i> Изменить</button> ' +
'<button type="button" class="btn btn-sm btn-delete btn-outline-danger" data-action="sync-delete-client"><i class="fas fa-trash"></i> Удалить</button>' +
"</div>";
listEl.appendChild(item);
});
} catch (error) {
if (emptyEl) {
emptyEl.style.display = "block";
emptyEl.textContent = "Не удалось загрузить список клиентов";
}
}
}
function editSyncClientSettings(btn) {
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
const nodeId = item ? item.dataset.nodeId : null;
const currentName = item ? item.dataset.displayName || "" : "";
if (!item || !nodeId) return;
let selectHtml = '<option value="">— не задано —</option>';
syncDispenserNames.forEach((name) => {
const escaped = escapeHtmlSyncSettings(name);
selectHtml += `<option value="${escaped}">${escaped}</option>`;
});
item.innerHTML =
'<div class="settings-sync-edit-row w-100">' +
'<label class="mb-0 me-2">Имя клиента:</label>' +
`<select class="form-control form-control-sm d-inline-block" id="syncEditSelect_${nodeId}">${selectHtml}</select> ` +
`<button type="button" class="btn btn-sm btn-primary" data-action="sync-save-client" data-node-id="${String(nodeId).replace(/"/g, "&quot;")}"><i class="fas fa-save"></i> Сохранить</button> ` +
'<button type="button" class="btn btn-sm btn-secondary" data-action="sync-cancel-edit">Отмена</button>' +
"</div>";
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
if (sel && currentName) sel.value = currentName;
}
function saveSyncClientDisplayName(nodeId) {
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
const displayName = sel ? (sel.value || "").trim() : "";
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: displayName || null }),
})
.then((r) => r.json())
.then((data) => {
if (data.error) {
notyf.error(data.error);
return;
}
notyf.success(displayName ? "Имя сохранено" : "Имя сброшено");
loadSyncClientsSettings();
})
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
}
async function deleteSyncClientSettings(btn) {
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
const nodeId = item ? item.dataset.nodeId : null;
if (!nodeId) return;
const ok = globalThis.WespKioskDialog
? await globalThis.WespKioskDialog.confirm("Удалить этого клиента из синхронизации?")
: globalThis.confirm("Удалить этого клиента из синхронизации?");
if (!ok) return;
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" })
.then((r) => r.json())
.then((data) => {
if (data.error) {
notyf.error(data.error);
return;
}
notyf.success(data.message || "Клиент удалён");
loadSyncClientsSettings();
})
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
}
function toggleCredentialsForm() {
const credForm = document.getElementById("credentialsForm");
const userCard = document.querySelector(".settings-user-card--clickable");
if (!credForm) return;
const isOpen = credForm.style.display !== "none";
credForm.style.display = isOpen ? "none" : "block";
if (userCard) userCard.classList.toggle("open", !isOpen);
}
function toggleSyncForm() {
const syncForm = document.getElementById("syncSettingsForm");
const syncCard = document.querySelector(".settings-sync-card--clickable");
if (!syncForm) return;
const isOpen = syncForm.style.display !== "none";
syncForm.style.display = isOpen ? "none" : "block";
if (syncCard) syncCard.classList.toggle("open", !isOpen);
if (!isOpen) loadSyncClientsSettings();
}
async function changeCredentials() {
const oldLogin = (document.getElementById("oldLogin")?.value || "").trim();
const oldPassword = document.getElementById("oldPassword")?.value || "";
const newLogin = (document.getElementById("newLogin")?.value || "").trim();
const newPassword = document.getElementById("newPassword")?.value || "";
const confirmPassword = document.getElementById("confirmPassword")?.value || "";
const wantsCredChange = !!(newLogin || newPassword || confirmPassword.trim());
if (!wantsCredChange) {
notyf.success("Настройки сохранены");
closeSettings();
return;
}
const validationError = validateCredentialChange({
oldLogin,
oldPassword,
newLogin,
newPassword,
confirmPassword,
passwordMinLen,
});
if (validationError) {
notyf.error(validationError);
return;
}
try {
const response = await fetch("/api/auth/change_credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_login: oldLogin,
old_password: oldPassword,
new_login: newLogin,
new_password: newPassword,
confirm_password: confirmPassword,
}),
});
const data = await response.json();
if (data.status === "success") {
notyf.success(data.message);
closeSettings();
const userLogin = document.getElementById("userLogin");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (userLogin) userLogin.textContent = newLogin;
if (mobileUserInfo) {
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${newLogin}`;
}
} else {
notyf.error(data.message);
}
} catch (error) {
console.error("Ошибка при изменении учетных данных:", error);
notyf.error("Ошибка при изменении учетных данных");
}
}
applyTheme(getTheme());
return {
checkAuth,
logout,
setupMobileMenu,
openSettings,
closeSettings,
toggleSyncForm,
loadSyncClientsSettings,
editSyncClientSettings,
saveSyncClientDisplayName,
deleteSyncClientSettings,
toggleCredentialsForm,
changeCredentials,
initThemePicker,
initHighContrastToggle,
initThemeToggle,
};
}

Some files were not shown because too many files have changed in this diff Show More