Files

1482 lines
76 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчеты о загрузках</title>
<script src="/static/js/wesp-theme-boot.js"></script>
<script>document.documentElement.classList.add('wesp-page-enter-pending');</script>
<script src="/static/js/wesp-page-enter.js?v=5"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=3"></script>
<script src="/static/js/wesp-zootech-nav.js?v=5"></script>
<style>
html { background: #f5f7fa; }
html[data-theme="dark"] { background: #12181b; }
html[data-theme="organic"] { background: #f6f6f4; }
</style>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell.css?v=3" rel="stylesheet">
<link href="/static/css/inter-fonts.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=17" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-recipes-editor.css?v=15" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell-modals.css?v=4" rel="stylesheet">
<link href="/static/css/wesp-zootech-k-hub.css?v=28" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<!-- Добавляем библиотеку SheetJS -->
<script src="/static/js/xlsx.full.min.js"></script>
<link href="/static/css/wesp-reports-page.css?v=20" rel="stylesheet">
<link href="/static/css/wesp-feed-quality-settings.css?v=3" rel="stylesheet">
<link href="/static/css/wesp-zootech-pages-polish.css?v=9" rel="stylesheet">
<style>
html[data-theme="dark"] {
--bg-light: #12181b;
--bg-white: #2c3338;
--text-dark: #f5f5f5;
--text-muted: #a8b0b8;
--border-light: rgba(255, 255, 255, 0.12);
}
.reports-page .page-content > .container {
padding-bottom: 1.5rem;
}
.reports-page .reports-controls__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.reports-page .report-card.list-item {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.25rem;
margin-bottom: 0;
box-shadow: none;
}
.reports-page .report-card--highlight {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.2);
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.reports-page .reports-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reports-page .report-card__top {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.reports-page .report-card__head {
flex: 1;
min-width: 0;
}
.reports-page .report-name {
font-size: 1.0625rem;
font-weight: 600;
color: var(--text-dark);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reports-page .report-name i {
color: var(--primary-color);
flex-shrink: 0;
}
.reports-page .report-date {
flex-shrink: 0;
font-size: 0.8125rem;
color: var(--text-muted);
font-weight: 500;
}
.reports-page .component-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.35rem 1rem;
margin-bottom: 0.75rem;
}
.reports-page .info-item {
display: flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
}
.reports-page .info-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
}
.reports-page .info-value {
font-size: 0.875rem;
color: var(--text-dark);
font-weight: 600;
}
.reports-page .info-value--price {
color: var(--primary-color);
}
.reports-page .info-value--success {
color: #10b981;
}
html[data-theme="dark"] .reports-page .report-card.list-item {
background: var(--bg-white) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
html[data-theme="dark"] .reports-page .report-name,
html[data-theme="dark"] .reports-page .info-value {
color: #f5f5f5 !important;
}
html[data-theme="dark"] .reports-page .form-control,
html[data-theme="dark"] .reports-page select {
background-color: var(--dm-field, #3e444a) !important;
color: #f5f5f5 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
</style>
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-app-body reports-page">
<div id="wespZootechNavMount" data-active-page="reports"></div>
<div class="page-content wesp-page-main">
<div class="container">
<div class="form-section zt-section reports-controls" data-wesp-enter>
<div class="reports-controls__head">
<h2 class="form-title">
<i class="fas fa-chart-bar text-primary"></i>
Отчёты о загрузках
</h2>
<div class="reports-controls__head-actions">
<span id="feedAlertsSummaryChip" class="feed-alerts-summary-chip" hidden></span>
<div class="reports-export-dropdown">
<button
type="button"
class="btn btn-outline-success btn-sm reports-export-dropdown__btn"
id="reportsExportBtn"
aria-expanded="false"
aria-haspopup="menu"
>
<i class="fas fa-download" aria-hidden="true"></i>
Скачать
<i class="fas fa-chevron-down reports-export-dropdown__chev" aria-hidden="true"></i>
</button>
<div class="reports-export-dropdown__menu" id="reportsExportMenu" hidden role="menu">
<div class="reports-export-dropdown__group">
<div class="reports-export-dropdown__group-label">Excel (.xlsx)</div>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="xlsx" data-reports-export-section="all" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-excel text-success"></i></span>
<span class="reports-export-dropdown__text"><strong>Все</strong><small>Итоги · Сравнение · Подробно</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="xlsx" data-reports-export-section="summary" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-excel text-success"></i></span>
<span class="reports-export-dropdown__text"><strong>Итоги</strong><small>Перерасход, недогруз, топ компонентов</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="xlsx" data-reports-export-section="comparison" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-excel text-success"></i></span>
<span class="reports-export-dropdown__text"><strong>Сравнение</strong><small>По рецепту · на сегодня · сделали</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="xlsx" data-reports-export-section="reports" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-excel text-success"></i></span>
<span class="reports-export-dropdown__text"><strong>Подробно</strong><small>Компоненты по дням</small></span>
</button>
</div>
<div class="reports-export-dropdown__group">
<div class="reports-export-dropdown__group-label">PDF</div>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="pdf" data-reports-export-section="all" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-pdf text-danger"></i></span>
<span class="reports-export-dropdown__text"><strong>Все</strong><small>Итоги · Сравнение · Подробно</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="pdf" data-reports-export-section="summary" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-pdf text-danger"></i></span>
<span class="reports-export-dropdown__text"><strong>Итоги</strong><small>Сводка за период</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="pdf" data-reports-export-section="comparison" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-pdf text-danger"></i></span>
<span class="reports-export-dropdown__text"><strong>Сравнение</strong><small>План и факт по компонентам</small></span>
</button>
<button type="button" class="reports-export-dropdown__item" data-reports-export-format="pdf" data-reports-export-section="reports" role="menuitem">
<span class="reports-export-dropdown__icon" aria-hidden="true"><i class="fas fa-file-pdf text-danger"></i></span>
<span class="reports-export-dropdown__text"><strong>Подробно</strong><small>Компоненты по дням</small></span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="reports-controls__block reports-controls__block--tabs">
<div class="reports-view-tabs-row">
<div class="zt-tabs reports-view-tabs">
<button type="button" class="zt-tab active" data-reports-view-tab="summary">Итоги</button>
<button type="button" class="zt-tab" data-reports-view-tab="comparison">Сравнение</button>
<button type="button" class="zt-tab" data-reports-view-tab="journal">Подробно</button>
</div>
<div class="reports-journal-tabs-wrap" id="journalSubTabsBlock" hidden>
<span class="reports-view-tabs-row__sep" aria-hidden="true"></span>
<div class="zt-tabs journal-view-tabs">
<button type="button" class="zt-tab active" data-journal-view-tab="reports">Отчёты</button>
<button type="button" class="zt-tab" data-journal-view-tab="alerts">Отклонения</button>
</div>
</div>
<div class="reports-journal-tabs-wrap" id="alertsSeverityBlock" hidden>
<span class="reports-view-tabs-row__sep" aria-hidden="true"></span>
<div class="zt-tabs alerts-severity-tabs">
<button type="button" class="zt-tab active" data-alerts-severity="">Все</button>
<button type="button" class="zt-tab" data-alerts-severity="warning">Предупр.</button>
<button type="button" class="zt-tab" data-alerts-severity="error">Критичные</button>
</div>
</div>
</div>
</div>
<div class="reports-controls__block reports-controls__block--filters">
<div class="reports-filter-bar">
<div class="reports-drp" id="reportsDateRange">
<button
type="button"
class="reports-drp__trigger reports-filter-bar__control"
id="reportsDateRangeTrigger"
aria-expanded="false"
aria-haspopup="dialog"
>
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
<span id="reportsDateRangeLabel">Сегодня</span>
<i class="fas fa-chevron-down reports-drp__chev" aria-hidden="true"></i>
</button>
<div class="reports-drp__panel" id="reportsDateRangePanel" hidden role="dialog" aria-label="Выбор периода">
<div class="reports-drp__presets">
<button type="button" class="reports-drp__preset active" data-drp-preset="day">Сегодня</button>
<button type="button" class="reports-drp__preset" data-drp-preset="week">Неделя</button>
<button type="button" class="reports-drp__preset" data-drp-preset="month">Месяц</button>
</div>
<div class="reports-drp__cal-head">
<button type="button" class="reports-drp__nav" id="reportsDateRangePrev" aria-label="Предыдущий месяц">
<i class="fas fa-chevron-left"></i>
</button>
<span id="reportsDateRangeMonth"></span>
<button type="button" class="reports-drp__nav" id="reportsDateRangeNext" aria-label="Следующий месяц">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="reports-drp__weekdays">
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
</div>
<div class="reports-drp__grid" id="reportsDateRangeGrid"></div>
</div>
<input type="hidden" id="date-from">
<input type="hidden" id="date-to">
</div>
<div class="reports-filter-select" id="reportsFarmFilterWrap" hidden>
<select id="farm-select" data-multiselect="true" class="form-select reports-filter-bar__control reports-filter-bar__select" aria-label="Ферма">
<option value="">Все фермы</option>
</select>
</div>
<div class="reports-filter-select reports-filter-select--dispenser">
<select id="dispenser-select" data-multiselect="true" class="form-select reports-filter-bar__control reports-filter-bar__select" aria-label="Кормораздатчик">
<option value="">Все кормораздатчики</option>
</select>
</div>
<div class="reports-filter-select reports-filter-select--recipe">
<select id="recipe-select" data-multiselect="true" class="form-select reports-filter-bar__control reports-filter-bar__select" aria-label="Рейс">
<option value="">Все рейсы</option>
</select>
</div>
<button type="button" class="btn btn-primary btn-sm reports-filter-bar__apply" onclick="applyFilters()" title="Применить фильтры">
<i class="fas fa-search" aria-hidden="true"></i>
<span class="reports-filter-bar__apply-text">Применить</span>
</button>
</div>
</div>
</div>
<div class="reports-container reports-container--summary">
<div id="analyticsSummaryList">
<div class="loading-state">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">Загрузка итогов…</p>
</div>
</div>
</div>
<div class="reports-container reports-container--comparison" hidden>
<div id="analyticsPlanFactList">
<div class="loading-state">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">Загрузка сравнения…</p>
</div>
</div>
</div>
<div class="reports-container reports-container--reports" hidden>
<div id="reportsList">
<div class="loading-state">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">Загрузка отчётов…</p>
</div>
</div>
</div>
<div class="reports-container reports-container--alerts" hidden>
<div id="alertsList">
<div class="loading-state">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">Загрузка отклонений…</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/wesp-zootech-notification-messages.js?v=1"></script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script src="/static/js/wesp-zootech-notify.js?v=6"></script>
<script src="/static/js/wesp-dialog.js"></script>
<script src="/static/js/wesp-zootech-bootstrap-modals.js?v=4"></script>
<script src="/static/js/modules/daily-plan-skip-duration.js?v=2"></script>
<script src="/static/js/modules/daily-plan-replace-component-modal.js?v=1"></script>
<script src="/static/js/pages/daily-plan-panel.js?v=11"></script>
<script src="/static/js/pages/lab-master-panel.js?v=2"></script>
<script src="/static/js/pages/lab-animal-profiles-modal.js?v=1"></script>
<script src="/static/js/pages/daily-plan-component-norms-modal.js?v=23"></script>
<script src="/static/js/wesp-zootech-notification-center.js?v=13"></script>
<script src="/static/js/pages/analytics-copy.js?v=1"></script>
<script src="/static/js/pages/reports-filters.js?v=2"></script>
<script src="/static/js/pages/reports-date-range.js?v=7"></script>
<script src="/static/js/pages/analytics-summary.js?v=4"></script>
<script src="/static/js/pages/analytics-plan-fact.js?v=5"></script>
<script src="/static/js/pages/reports-filter-dropdown.js?v=3"></script>
<script src="/static/js/pages/reports-export.js?v=3"></script>
<script src="/static/js/pages/feed-alerts.js?v=11"></script>
<script>
window.reportsDispenserIdByName = window.reportsDispenserIdByName || {};
function notifySuccess(message) {
window.WespZootechNotify?.success(message);
}
function notifyError(message) {
window.WespZootechNotify?.error(message);
}
function escapeHtml(value) {
if (value == null) return '';
const div = document.createElement('div');
div.textContent = String(value);
return div.innerHTML;
}
function formatDeviationPercent(deviation, target) {
const t = Number(target);
if (!t) return null;
return (deviation / t) * 100;
}
function formatDeviationPercentDisplay(deviation, target) {
const pct = formatDeviationPercent(deviation, target);
if (pct == null || !Number.isFinite(pct)) return '—';
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}%`;
}
function deviationClassFor(deviation, target) {
const pct = formatDeviationPercent(deviation, target);
if (pct != null && Math.abs(pct) >= 10) return 'deviation-significant';
if (deviation < 0) return 'deviation-negative';
if (deviation > 0) return 'deviation-positive';
return '';
}
// Глобальные переменные
let allReports = [];
let filteredReports = [];
let componentsData = {}; // Карта компонентов с ценами
let currentSort = {
column: null,
direction: 'asc'
};
document.addEventListener('DOMContentLoaded', function() {
window.WespReportsFilterDropdown?.init?.();
setupDateFilters();
window.WespReportsExport?.init?.();
window.WespFeedAlerts?.init?.();
document.getElementById('dispenser-select')?.addEventListener('change', onDispenserFilterChange);
document.getElementById('farm-select')?.addEventListener('change', onFarmFilterChange);
});
function onFarmFilterChange() {
updateDispensersList();
}
function onDispenserFilterChange() {
updateRecipesList();
}
function registerDispenserFarm(dispenser) {
if (!dispenser || !dispenser.name) return;
const name = dispenser.name.trim();
const farm = (dispenser.farm || '').trim();
if (!farm) return;
window.reportsDispenserFarmByName[name] = farm;
if (!window.reportsDispensersByFarm[farm]) {
window.reportsDispensersByFarm[farm] = [];
}
if (!window.reportsDispensersByFarm[farm].includes(name)) {
window.reportsDispensersByFarm[farm].push(name);
}
}
function collectDispenserRecipes(dispenser, recipesOut) {
if (!dispenser || !dispenser.name) return;
const name = dispenser.name.trim();
if (!window.reportsRecipesByDispenserName[name]) {
window.reportsRecipesByDispenserName[name] = [];
}
const seen = new Set(window.reportsRecipesByDispenserName[name].map((r) => r.id));
(recipesOut || []).forEach((recipe) => {
if (!recipe || !recipe.id) return;
if (seen.has(recipe.id)) return;
seen.add(recipe.id);
window.reportsRecipesByDispenserName[name].push({
id: recipe.id,
name: recipe.name || recipe.title || '',
});
});
}
// Функция расчета цены рейса с учетом только перегруза
function calculateTripPrice(report) {
let actualPrice = 0;
let overloadPrice = 0;
let targetPrice = 0;
if (report.components && Array.isArray(report.components)) {
report.components.forEach(component => {
const componentName = component.name || component.component_name;
const actualWeight = component.actual_weight || 0;
const targetWeight = component.target_weight || 0;
if (componentsData[componentName]) {
const componentPrice = componentsData[componentName].price || 0;
// Фактическая стоимость
const actualCost = actualWeight * componentPrice;
actualPrice += actualCost;
// Целевая стоимость
const targetCost = targetWeight * componentPrice;
targetPrice += targetCost;
// Стоимость перегруза (если есть)
if (actualWeight > targetWeight) {
const overloadWeight = actualWeight - targetWeight;
const overloadCost = overloadWeight * componentPrice;
overloadPrice += overloadCost;
}
}
});
}
return {
actual: actualPrice,
overload: overloadPrice,
target: targetPrice,
total: actualPrice
};
}
async function loadReports() {
try {
// Загружаем только отчёты за выбранный период (по умолчанию — последние 24 ч, если даты не заданы)
const dateFromEl = document.getElementById('date-from');
const dateToEl = document.getElementById('date-to');
let reportsUrl = '/api/reports';
if (dateFromEl && dateToEl && dateFromEl.value && dateToEl.value) {
reportsUrl += '?date_from=' + encodeURIComponent(dateFromEl.value) + '&date_to=' + encodeURIComponent(dateToEl.value);
}
const [loadingResponse, componentsResponse, dispensersResponse] = await Promise.all([
fetch(reportsUrl),
fetch('/api/components').catch(() => ({ ok: false })), // Не критично
fetch('/api/feed_dispensers')
]);
const loadingData = await loadingResponse.json();
if (!Array.isArray(loadingData)) {
throw new Error('Данные отчетов не являются массивом');
}
// ОПТИМИЗАЦИЯ 2: Обработка компонентов без лишних проверок
if (componentsResponse && componentsResponse.ok) {
try {
const componentsArray = await componentsResponse.json();
if (Array.isArray(componentsArray)) {
// Используем цикл for вместо forEach для лучшей производительности
for (let i = 0; i < componentsArray.length; i++) {
const component = componentsArray[i];
if (component && component.name) {
componentsData[component.name] = {
id: component.id,
name: component.name,
price: component.price || 0
};
}
}
}
} catch (error) {
console.error('Ошибка при загрузке компонентов:', error);
}
}
// ОПТИМИЗАЦИЯ 3: Оптимизированная обработка кормораздатчиков
let recipeToDispenserMap = {};
window.reportsDispenserIdByName = {};
window.reportsRecipesByDispenserName = {};
window.reportsDispenserFarmByName = {};
window.reportsDispensersByFarm = {};
try {
const dispensersData = await dispensersResponse.json();
// ОПТИМИЗАЦИЯ 4: Более быстрое создание Set с ID рецептов
const reportRecipeIds = new Set();
for (let i = 0; i < loadingData.length; i++) {
const recipeId = loadingData[i]?.recipe_id;
if (recipeId) {
reportRecipeIds.add(recipeId);
}
}
if (Array.isArray(dispensersData)) {
const millRecipesPromises = [];
// ОПТИМИЗАЦИЯ 5: Используем циклы for вместо forEach
for (let i = 0; i < dispensersData.length; i++) {
const dispenser = dispensersData[i];
if (!dispenser || !dispenser.name) {
continue;
}
if (dispenser.id) {
window.reportsDispenserIdByName[dispenser.name.trim()] = dispenser.id;
}
registerDispenserFarm(dispenser);
// Получаем рецепты из всех периодов
const recipes = [];
if (dispenser.periods && Array.isArray(dispenser.periods)) {
for (let j = 0; j < dispenser.periods.length; j++) {
const period = dispenser.periods[j];
if (period.recipes && Array.isArray(period.recipes)) {
recipes.push(...period.recipes); // Используем spread вместо concat
}
}
}
// Для кормоцеха загружаем рецепты отдельно
if (dispenser.type === 'mill' && dispenser.id) {
millRecipesPromises.push(
fetch(`/api/feed_dispensers/${dispenser.id}/recipes`)
.then(response => response.json())
.then(millRecipes => ({ dispenser: dispenser, recipes: millRecipes }))
.catch(error => {
console.error(`Ошибка загрузки рецептов для кормоцеха ${dispenser.name}:`, error);
return { dispenser: dispenser, recipes: [] };
})
);
}
// Обрабатываем рецепты обычных кормораздатчиков
collectDispenserRecipes(dispenser, recipes);
for (let k = 0; k < recipes.length; k++) {
const recipe = recipes[k];
if (!recipe) continue;
const recipeId = recipe.id;
if (recipeId && reportRecipeIds.has(recipeId)) {
recipeToDispenserMap[recipeId] = dispenser.name;
}
}
}
// ОПТИМИЗАЦИЯ 6: Параллельная загрузка всех рецептов кормоцехов
if (millRecipesPromises.length > 0) {
const millRecipesResults = await Promise.all(millRecipesPromises);
for (let i = 0; i < millRecipesResults.length; i++) {
const { dispenser, recipes } = millRecipesResults[i];
collectDispenserRecipes(dispenser, recipes);
for (let j = 0; j < recipes.length; j++) {
const recipe = recipes[j];
if (!recipe) continue;
const recipeId = recipe.id;
if (recipeId && reportRecipeIds.has(recipeId)) {
recipeToDispenserMap[recipeId] = dispenser.name;
}
}
}
}
}
} catch (error) {
console.error('Ошибка при загрузке кормораздатчиков:', error);
}
// ОПТИМИЗАЦИЯ 7: Фильтрация и подстановка dispenser_name (тип — из reports.db, report.dispenser_type)
allReports = [];
for (let i = 0; i < loadingData.length; i++) {
const report = loadingData[i];
if (report && report.recipe_id) {
if (recipeToDispenserMap[report.recipe_id]) {
report.dispenser_name = recipeToDispenserMap[report.recipe_id];
}
if (report.dispenser_name) {
report.farm = window.reportsDispenserFarmByName[report.dispenser_name.trim()] || '';
}
allReports.push(report);
}
}
// Обновляем списки фильтров
updateFarmsList();
updateDispensersList();
filterAndDisplayReports();
window.WespFeedAlerts?.onFiltersApplied?.();
} catch (error) {
console.error('Ошибка при загрузке отчетов:', error);
notifyError('Ошибка при загрузке отчётов');
document.getElementById('reportsList').innerHTML = `
<div class="empty-state wesp-content-reveal zt-empty no-reports">
<div class="empty-icon"><i class="fas fa-exclamation-triangle"></i></div>
<h3 class="zt-empty__title">Ошибка при загрузке отчётов</h3>
<p class="zt-empty__hint">Проверьте подключение и попробуйте снова.</p>
<button type="button" class="btn btn-primary btn-sm mt-2" onclick="loadReports()">Попробовать снова</button>
</div>
`;
}
}
function setupDateFilters() {
window.WespReportsDateRange?.init?.({
onReady: () => loadReports(),
});
}
function setPeriod(period) {
window.WespReportsDateRange?.setPeriod?.(period);
}
function getAllowedRecipeNames() {
const selectedDispensers = window.WespReportsFilters?.getEffectiveDispenserNames?.() || [];
const allowed = new Set();
const recipeIdByName = {};
if (!selectedDispensers.length) {
allReports.forEach((report) => {
if (report.recipe_name) {
allowed.add(report.recipe_name);
if (report.recipe_id) recipeIdByName[report.recipe_name] = report.recipe_id;
}
});
return { allowed, recipeIdByName };
}
selectedDispensers.forEach((dispenserName) => {
(window.reportsRecipesByDispenserName?.[dispenserName] || []).forEach((recipe) => {
if (recipe.name) {
allowed.add(recipe.name);
if (recipe.id) recipeIdByName[recipe.name] = recipe.id;
}
});
});
return { allowed, recipeIdByName };
}
function updateFarmsList() {
const farmSelect = document.getElementById('farm-select');
const wrap = document.getElementById('reportsFarmFilterWrap');
const bar = document.querySelector('.reports-filter-bar');
if (!farmSelect || !wrap) return;
const farms = Object.keys(window.reportsDispensersByFarm || {})
.filter((farm) => farm && farm.trim())
.sort((a, b) => a.localeCompare(b, 'ru'));
const currentSelected = window.WespReportsFilters?.getSelectedFarmNames?.() || [];
farmSelect.innerHTML = '<option value="">Все фермы</option>';
farms.forEach((farm) => {
const option = document.createElement('option');
option.value = farm;
option.textContent = farm;
farmSelect.appendChild(option);
});
if (farms.length <= 1) {
wrap.hidden = true;
bar?.classList.remove('reports-filter-bar--with-farm');
window.WespReportsFilterDropdown?.setMultiSelectValues?.('farm-select', []);
} else {
wrap.hidden = false;
bar?.classList.add('reports-filter-bar--with-farm');
const validSelected = currentSelected.filter((farm) => farms.includes(farm));
window.WespReportsFilterDropdown?.setMultiSelectValues?.('farm-select', validSelected);
}
window.WespReportsFilterDropdown?.refreshAll?.();
}
function updateRecipesList() {
const recipeSelect = document.getElementById('recipe-select');
if (!recipeSelect) return;
const { allowed: allowedRecipes, recipeIdByName } = getAllowedRecipeNames();
const uniqueRecipes = [...allowedRecipes].sort((a, b) => a.localeCompare(b, 'ru'));
const currentSelected = window.WespReportsFilters?.getSelectedRecipeNames?.() || [];
recipeSelect.innerHTML = '<option value="">Все рейсы</option>';
uniqueRecipes.forEach((recipe) => {
const option = document.createElement('option');
option.value = recipe;
option.textContent = recipe;
if (recipeIdByName[recipe]) option.dataset.recipeId = recipeIdByName[recipe];
recipeSelect.appendChild(option);
});
const validSelected = currentSelected.filter((recipe) => uniqueRecipes.includes(recipe));
window.WespReportsFilterDropdown?.setMultiSelectValues?.('recipe-select', validSelected);
window.WespReportsFilterDropdown?.refreshAll?.();
}
function updateDispensersList() {
const dispenserSelect = document.getElementById('dispenser-select');
if (!dispenserSelect) return;
const currentSelected = window.WespReportsFilters?.getSelectedDispenserNames?.() || [];
const selectedFarms = window.WespReportsFilters?.getSelectedFarmNames?.() || [];
dispenserSelect.innerHTML = '<option value="">Все кормораздатчики</option>';
const dispensersFromReports = new Set();
if (allReports && allReports.length > 0) {
allReports.forEach(report => {
if (report.dispenser_name && report.dispenser_name.trim()) {
dispensersFromReports.add(report.dispenser_name.trim());
}
});
}
Object.keys(window.reportsRecipesByDispenserName || {}).forEach((name) => {
if (name) dispensersFromReports.add(name);
});
const visibleDispensers = Array.from(dispensersFromReports)
.filter((name) => {
if (!selectedFarms.length) return true;
return selectedFarms.includes(window.reportsDispenserFarmByName[name] || '');
})
.sort((a, b) => a.localeCompare(b, 'ru'));
visibleDispensers.forEach(dispenserName => {
const option = document.createElement('option');
option.value = dispenserName;
option.textContent = dispenserName;
dispenserSelect.appendChild(option);
});
const visibleSet = new Set(visibleDispensers);
const validSelected = currentSelected.filter((name) => visibleSet.has(name));
window.WespReportsFilterDropdown?.setMultiSelectValues?.(
'dispenser-select',
validSelected
);
if (dispensersFromReports.size === 0) {
fetch('/api/feed_dispensers')
.then(response => response.json())
.then(dispensers => {
if (!Array.isArray(dispensers)) {
console.warn('Данные о кормораздатчиках не являются массивом');
return;
}
dispensers
.filter(d => d && d.name)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(dispenser => {
registerDispenserFarm(dispenser);
if (selectedFarms.length && !selectedFarms.includes((dispenser.farm || '').trim())) {
return;
}
const option = document.createElement('option');
option.value = dispenser.name;
option.textContent = dispenser.name;
dispenserSelect.appendChild(option);
const recipes = [];
if (dispenser.periods && Array.isArray(dispenser.periods)) {
dispenser.periods.forEach((period) => {
if (period.recipes && Array.isArray(period.recipes)) {
recipes.push(...period.recipes);
}
});
}
collectDispenserRecipes(dispenser, recipes);
});
updateFarmsList();
const restored = validSelected.filter((name) =>
Array.from(dispenserSelect.options).some((opt) => opt.value === name)
);
window.WespReportsFilterDropdown?.setMultiSelectValues?.(
'dispenser-select',
restored
);
updateRecipesList();
})
.catch(error => {
console.error('Ошибка при загрузке списка кормораздатчиков:', error);
});
} else {
updateRecipesList();
}
}
function getActualTotalWeight(report) {
if (report && Array.isArray(report.components) && report.components.length > 0) {
const hasActual = report.components.some(c => c.actual_weight !== undefined && c.actual_weight !== null);
if (hasActual) {
return report.components.reduce((acc, c) => acc + (Number(c.actual_weight) || 0), 0);
}
}
return Number(report && report.total_weight) || 0;
}
async function exportToExcel() {
if (!filteredReports || filteredReports.length === 0) {
notifyError('Нет данных для экспорта');
return;
}
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([[]]); // стартовый пустой лист
const merges = [];
// ширина блока отчета (в колонках)
const BLOCK_WIDTH = 9; // [0..8]
// отступ в 1 пустую колонку между блоками
const GAP = 1;
// вспомогательная: формирует 2D-массив для одного отчета
function buildReportBlock(report) {
const rows = [];
const startTime = parseDate(report.start_time);
const title = `${report.recipe_name || 'Рецепт'}${startTime.toLocaleString('ru-RU')}`;
const tripPrice = calculateTripPrice(report);
const unloadedTotal = (() => {
const u = report.unloading_data;
if (u?.unloading_groups?.length) {
return u.unloading_groups.reduce((s, g) => s + (Number(g.unloaded_weight) || 0), 0);
}
if (u && u.total_unloaded_weight != null) {
return Number(u.total_unloaded_weight) || 0;
}
return 0;
})();
const actualTotalWeight = getActualTotalWeight(report);
rows.push([title]);
rows.push([]);
rows.push(['Время начала', startTime.toLocaleString('ru-RU'), 'Общий вес (кг)', actualTotalWeight.toFixed(1)]);
rows.push(['Стоимость рейса (₽)', tripPrice.actual.toFixed(2), 'Сумма перегруза (₽)', tripPrice.overload.toFixed(2)]);
if (report.target_mixing_time || report.actual_mixing_time) {
rows.push(['Время смешивания (план)', report.target_mixing_time ? formatTime(report.target_mixing_time) : '-',
'Время смешивания (факт)', report.actual_mixing_time ? formatTime(report.actual_mixing_time) : '-']);
}
rows.push([]);
rows.push(['Компоненты']);
rows.push(['Компонент', 'План (кг)', 'Факт (кг)', 'Отклонение (кг)', 'Отклонение (%)', 'Цена (₽/кг)', 'Факт. стоимость (₽)', 'Перегруз (₽)', 'Время загрузки (с)']);
if (Array.isArray(report.components)) {
report.components.forEach(c => {
const name = c.name || c.component_name || '—';
const target = c.target_weight || 0;
const actual = c.actual_weight || 0;
const dev = actual - target;
const devPct = target > 0 ? (dev / target) * 100 : 0;
const price = componentsData[name]?.price || 0;
const actualCost = actual * price;
const overloadCost = dev > 0 ? dev * price : 0;
const lt = (report.component_loading_times || []).find(x => x && x.component_name === name);
const loadingSec = lt?.loading_duration ? Number(lt.loading_duration).toFixed(1) : '-';
rows.push([
name,
target.toFixed(2),
actual.toFixed(2),
dev.toFixed(2),
devPct.toFixed(2),
price.toFixed(2),
actualCost.toFixed(2),
overloadCost > 0 ? overloadCost.toFixed(2) : '0.00',
loadingSec
]);
});
}
rows.push([]);
rows.push(['Выгрузка по группам']);
rows.push(['Группа', 'План (кг)', 'Выгружено (кг)', 'Отклонение (кг)', 'Отклонение (%)']);
const u = report.unloading_data;
if (u && Array.isArray(u.unloading_groups)) {
u.unloading_groups.forEach(g => {
const t = g.target_weight || 0;
const a = g.unloaded_weight || 0;
const dev = a - t;
const devPct = t > 0 ? (dev / t) * 100 : 0;
rows.push([
g.name || '—',
t.toFixed(1),
a.toFixed(1),
dev.toFixed(1),
devPct.toFixed(1)
]);
});
rows.push([]);
rows.push(['Итого', (u.total_weight || 0).toFixed(1), unloadedTotal.toFixed(1),
(unloadedTotal - (u.total_weight || 0)).toFixed(1), '']);
}
return rows;
}
// добавление блока в лист с заданной колонкой-стартом
function placeBlock(ws, rows, startCol) {
XLSX.utils.sheet_add_aoa(ws, rows, { origin: { r: 0, c: startCol } });
// мердж заголовка на ширину блока
merges.push({ s: { r: 0, c: startCol }, e: { r: 0, c: startCol + (BLOCK_WIDTH - 1) } });
}
// раскладываем отчеты блоками по горизонтали
let col = 0;
filteredReports.forEach(report => {
const rows = buildReportBlock(report);
placeBlock(ws, rows, col);
col += BLOCK_WIDTH + GAP; // следующий блок справа через 1 пустую колонку
});
// ширины колонок (на весь лист одинаково)
const totalCols = col > 0 ? col - GAP : BLOCK_WIDTH;
ws['!cols'] = Array.from({ length: totalCols }, (_, i) =>
({ width: (i % (BLOCK_WIDTH + GAP)) === BLOCK_WIDTH ? 2 : 16 }) // колонка‑разделитель уже узкая
);
ws['!merges'] = (ws['!merges'] || []).concat(merges);
const date = new Date();
const fileName = `Отчеты_загрузок_${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}.xlsx`;
XLSX.utils.book_append_sheet(wb, ws, 'Отчеты');
XLSX.writeFile(wb, fileName);
notifySuccess('Отчёт экспортирован в Excel');
}
function applyClientFilters() {
filterAndDisplayReports();
window.WespFeedAlerts?.onFiltersApplied?.();
}
// Функции для фильтров
function applyFilters() {
window.WespReportsDateRange?.commit?.();
loadReports();
}
function resetFilters(e) {
if (e && typeof e.preventDefault === 'function') {
e.preventDefault();
}
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation();
}
window.WespReportsFilters?.clearDispenserSelection?.();
window.WespReportsFilters?.clearFarmSelection?.();
window.WespReportsFilters?.clearRecipeSelection?.();
setPeriod('day');
loadReports();
}
function filterAndDisplayReports() {
if (!allReports) {
console.log('Нет загруженных отчетов для фильтрации');
return;
}
const parseLocal = window.WespReportsFilters?.parseLocalDate || function (iso) {
const p = String(iso || '').split('-');
if (p.length !== 3) return new Date('invalid');
return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
};
const selectedFarms = window.WespReportsFilters?.getSelectedFarmNames?.() || [];
const selectedDispensers = window.WespReportsFilters?.getSelectedDispenserNames?.() || [];
const selectedRecipes = window.WespReportsFilters?.getSelectedRecipeNames?.() || [];
const dateFromIso = document.getElementById('date-from').value;
const dateToIso = document.getElementById('date-to').value;
const dateFrom = parseLocal(dateFromIso);
const dateTo = parseLocal(dateToIso);
if (!dateFromIso || !dateToIso || Number.isNaN(dateFrom.getTime()) || Number.isNaN(dateTo.getTime())) {
filteredReports = allReports.slice();
displayReports(filteredReports);
return;
}
dateFrom.setHours(0, 0, 0, 0);
dateTo.setHours(23, 59, 59, 999);
filteredReports = allReports.filter(report => {
let matches = true;
const reportDispenserName = (report.dispenser_name || '').trim();
const reportFarm = (report.farm || window.reportsDispenserFarmByName[reportDispenserName] || '').trim();
if (selectedFarms.length > 0 && !selectedFarms.includes(reportFarm)) {
matches = false;
}
// Фильтр по кормораздатчику (один или несколько)
if (selectedDispensers.length > 0) {
if (!selectedDispensers.includes(reportDispenserName)) {
matches = false;
}
}
// Фильтр по рецепту
if (selectedRecipes.length > 0 && !selectedRecipes.includes(report.recipe_name)) {
matches = false;
}
// Фильтр по дате
const reportDate = parseDate(report.start_time);
const matchesDate = reportDate >= dateFrom && reportDate <= dateTo;
if (!matchesDate) matches = false;
return matches;
});
console.log(`Отфильтровано ${filteredReports.length} отчетов из ${allReports.length}`);
displayReports(filteredReports);
}
function displayReports(reports) {
const reportsList = document.getElementById('reportsList');
if (!reports || reports.length === 0) {
reportsList.innerHTML = `
<div class="empty-state wesp-content-reveal zt-empty no-reports">
<div class="empty-icon"><i class="fas fa-chart-bar"></i></div>
<h3 class="zt-empty__title">Отчёты не найдены</h3>
<p class="zt-empty__hint">Попробуйте изменить фильтры или выбрать другой период</p>
</div>
`;
window.WespZootechModals?.revealContent(reportsList.querySelector('.empty-state'));
return;
}
let html = '';
reports.forEach((report, index) => {
const startTime = parseDate(report.start_time);
const actualTotalWeight = getActualTotalWeight(report);
const unloadedTotal = (() => {
const u = report.unloading_data;
if (u?.unloading_groups?.length) {
return u.unloading_groups.reduce((s, g) => s + (Number(g.unloaded_weight) || 0), 0);
}
if (u && u.total_unloaded_weight != null) {
return Number(u.total_unloaded_weight) || 0;
}
return 0;
})();
const tripPrice = calculateTripPrice(report);
html += `
<div class="report-card list-item" data-report-id="${escapeHtml(report.id)}">
<div class="report-card__top">
<div class="report-card__head">
<h3 class="report-name">
<i class="fas fa-clipboard-list" aria-hidden="true"></i>
${escapeHtml(report.recipe_name)}
</h3>
</div>
<div class="report-date">${startTime.toLocaleString('ru-RU')}</div>
</div>
<div class="component-info">
<div class="info-item">
<span class="info-label">Загружено</span>
<span class="info-value">${actualTotalWeight.toFixed(1)} кг</span>
</div>
<div class="info-item">
<span class="info-label">Выгружено</span>
<span class="info-value">${unloadedTotal.toFixed(1)} кг</span>
</div>
<div class="info-item">
<span class="info-label">Стоимость</span>
<span class="info-value info-value--price">${tripPrice.actual.toFixed(2)} ₽</span>
</div>
${tripPrice.overload > 0 ? `
<div class="info-item">
<span class="info-label">Перегруз</span>
<span class="info-value info-value--success">+${tripPrice.overload.toFixed(2)} ₽</span>
</div>` : ''}
${report.target_mixing_time ? `
<div class="info-item">
<span class="info-label">Смешивание</span>
<span class="info-value">${formatTime(report.target_mixing_time)}</span>
</div>` : ''}
${report.actual_mixing_time ? `
<div class="info-item">
<span class="info-label">Факт</span>
<span class="info-value">${formatTime(report.actual_mixing_time)}</span>
</div>` : ''}
</div>
<div class="report-section">
<h4 class="report-section__title"><i class="fas fa-boxes" aria-hidden="true"></i> Загрузка компонентов</h4>
<div class="report-table-wrap">
<table class="components-table">
<thead>
<tr>
<th onclick="sortComponents(${index}, 'name')">Компонент</th>
<th onclick="sortComponents(${index}, 'target')">План</th>
<th onclick="sortComponents(${index}, 'actual')">Факт</th>
<th onclick="sortComponents(${index}, 'deviation')">Δ кг</th>
<th onclick="sortComponents(${index}, 'deviation_percent')">Δ %</th>
<th>₽/кг</th>
<th>Стоимость</th>
<th>Перегруз</th>
<th>Время</th>
</tr>
</thead>
<tbody>
`;
report.components.forEach(component => {
const name = component.name || component.component_name;
const deviation = component.actual_weight - component.target_weight;
const deviationClass = deviationClassFor(deviation, component.target_weight);
const loadingTimeData = report.component_loading_times?.find(lt => lt.component_name === name);
const loadingDuration = loadingTimeData?.loading_duration;
const componentPrice = componentsData[name]?.price || 0;
const actualCost = component.actual_weight * componentPrice;
const overloadCost = deviation > 0 ? deviation * componentPrice : 0;
html += `
<tr>
<td>${escapeHtml(name)}</td>
<td>${Number(component.target_weight).toFixed(2)}</td>
<td>${Number(component.actual_weight).toFixed(2)}</td>
<td class="${deviationClass}">${deviation > 0 ? '+' : ''}${deviation.toFixed(2)}</td>
<td class="${deviationClass}">${formatDeviationPercentDisplay(deviation, component.target_weight)}</td>
<td>${componentPrice.toFixed(2)}</td>
<td class="info-value--price">${actualCost.toFixed(2)}</td>
<td>${overloadCost > 0 ? '+' + overloadCost.toFixed(2) : '—'}</td>
<td class="loading-duration">${loadingDuration ? loadingDuration.toFixed(1) + 's' : '—'}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
</div>
`;
if (report.unloading_data) {
const hasGroups = report.unloading_data.unloading_groups && report.unloading_data.unloading_groups.length > 0;
const isMill = report.dispenser_type === 'mill';
const unloadingIcon = isMill ? 'fa-industry' : 'fa-tractor';
html += `
<div class="report-section">
<h4 class="report-section__title"><i class="fas ${unloadingIcon}" aria-hidden="true"></i> Выгрузка по группам</h4>
`;
if (hasGroups) {
html += `
<div class="report-table-wrap">
<table class="components-table">
<thead>
<tr>
<th>Группа</th>
<th>План</th>
<th>Факт</th>
<th>Δ кг</th>
<th>Δ %</th>
</tr>
</thead>
<tbody>
`;
report.unloading_data.unloading_groups.forEach(group => {
const unloadDeviation = group.unloaded_weight - group.target_weight;
const unloadClass = deviationClassFor(unloadDeviation, group.target_weight);
html += `
<tr>
<td>${escapeHtml(group.name)}</td>
<td>${Number(group.target_weight).toFixed(1)}</td>
<td>${Number(group.unloaded_weight).toFixed(1)}</td>
<td class="${unloadClass}">${unloadDeviation > 0 ? '+' : ''}${unloadDeviation.toFixed(1)}</td>
<td class="${unloadClass}">${formatDeviationPercentDisplay(unloadDeviation, group.target_weight)}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
} else {
html += `<p class="info-label mb-0">Групп нет</p>`;
}
html += `</div>`;
}
html += '</div>';
});
reportsList.innerHTML = `<div class="reports-grid">${html}</div>`;
window.WespZootechModals?.animateGridCards('.reports-grid');
scrollToHighlightedReport();
}
function scrollToHighlightedReport() {
const targetId = window.__wespHighlightReportId;
if (!targetId) return;
const card = document.querySelector(`[data-report-id="${CSS.escape(String(targetId))}"]`);
if (!card) return;
card.classList.add('report-card--highlight');
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => card.classList.remove('report-card--highlight'), 3200);
window.__wespHighlightReportId = null;
}
// Функция сортировки компонентов
function sortComponents(reportIndex, sortBy) {
const report = filteredReports[reportIndex];
let sortDirection = report.sortDirection || {};
const currentDirection = sortDirection[sortBy] || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
sortDirection[sortBy] = newDirection;
report.sortDirection = sortDirection;
report.components.sort((a, b) => {
let valueA, valueB;
switch (sortBy) {
case 'name':
valueA = (a.name || a.component_name).toLowerCase();
valueB = (b.name || b.component_name).toLowerCase();
break;
case 'target':
valueA = a.target_weight;
valueB = b.target_weight;
break;
case 'actual':
valueA = a.actual_weight;
valueB = b.actual_weight;
break;
case 'deviation':
valueA = a.actual_weight - a.target_weight;
valueB = b.actual_weight - b.target_weight;
break;
case 'deviation_percent':
valueA = ((a.actual_weight - a.target_weight) / a.target_weight) * 100;
valueB = ((b.actual_weight - b.target_weight) / b.target_weight) * 100;
break;
default:
return 0;
}
if (typeof valueA === 'string') {
return newDirection === 'asc' ?
valueA.localeCompare(valueB) :
valueB.localeCompare(valueA);
} else {
return newDirection === 'asc' ? valueA - valueB : valueB - valueA;
}
});
displayReports(filteredReports);
}
// Функция форматирования времени
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function parseDate(dateStr) {
if (!dateStr) return new Date('1970-01-01T00:00:00');
// Оставляем только YYYY-MM-DDTHH:MM:SS
const clean = dateStr.split(/[+Z]/)[0].replace(' ', 'T');
return new Date(clean);
}
</script>
<!-- Настройки — как на /recipes -->
<div id="settingsModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content">
<div class="settings-modal-header">
<h3><i class="fas fa-cog"></i> Настройки</h3>
<span class="settings-close" data-action="close-settings">&times;</span>
</div>
<div class="settings-modal-body">
<div class="settings-user-card settings-user-card--clickable" data-action="toggle-credentials-form">
<h4 class="settings-card-title"><i class="fas fa-user"></i> Сменить логин/пароль <i class="fas fa-chevron-down settings-chevron"></i></h4>
</div>
<div class="settings-credentials-form" id="credentialsForm" style="display: none;">
<form id="settingsForm">
<div class="form-group">
<label for="oldLogin">Текущий логин:</label>
<input type="text" id="oldLogin" class="form-control" readonly>
</div>
<div class="form-group">
<label for="oldPassword">Текущий пароль:</label>
<input type="password" id="oldPassword" class="form-control">
</div>
<div class="form-group">
<label for="newLogin">Новый логин:</label>
<input type="text" id="newLogin" class="form-control" required>
</div>
<div class="form-group">
<label for="newPassword">Новый пароль:</label>
<input type="password" id="newPassword" class="form-control" required>
</div>
<div class="form-group">
<label for="confirmPassword">Подтвердите новый пароль:</label>
<input type="password" id="confirmPassword" class="form-control" required>
</div>
</form>
</div>
<div class="settings-sync-card settings-sync-card--clickable" data-action="toggle-sync-form">
<h4 class="settings-card-title"><i class="fas fa-sync-alt"></i> Настройка синхронизации <i class="fas fa-chevron-down settings-chevron"></i></h4>
</div>
<div class="settings-sync-form" id="syncSettingsForm" style="display: none;">
<div id="syncClientsListSettings"></div>
<div id="syncClientsEmptySettings" class="text-muted small" style="display: none;">Нет подключенных клиентов</div>
</div>
<div class="settings-feed-quality-card settings-feed-quality-card--clickable" data-action="open-feed-quality-settings">
<h4 class="settings-card-title"><i class="fas fa-triangle-exclamation"></i> Контроль отклонений <i class="fas fa-chevron-right settings-chevron"></i></h4>
</div>
<div class="settings-theme-card">
<h4 class="settings-card-title"><i class="fas fa-palette"></i> Тема оформления</h4>
<div id="wespThemePicker" class="theme-picker-mount" role="radiogroup" aria-label="Тема оформления"></div>
<div class="settings-contrast-row">
<span class="settings-contrast-row__label">Усиленный контраст</span>
<label class="theme-switch" for="wespHighContrastToggle">
<input type="checkbox" id="wespHighContrastToggle" role="switch" aria-label="Усиленный контраст">
<span class="theme-slider"></span>
</label>
</div>
</div>
<div class="text-muted small mt-2">Версия системы 2.1</div>
</div>
<div class="settings-modal-footer">
<button type="button" class="btn btn-secondary" data-action="close-settings">Отмена</button>
<button type="button" class="btn btn-primary" data-action="change-credentials">
<i class="fas fa-save"></i> Сохранить
</button>
</div>
</div>
</div>
<script type="module">
import { createRecipesAuthSettingsController } from '/static/js/pages/recipes-auth-settings.js';
import { initFeedQualitySettingsModal } from '/static/js/pages/feed-quality-settings-modal.js?v=3';
import { SyncPanel } from '/static/js/modules/recipes/sync-panel.js';
const notyf = window.WespZootechNotify.createAdapter();
const authSettingsController = createRecipesAuthSettingsController({ notyf });
const syncPanelModule = new SyncPanel({
openSettings: (event) => authSettingsController.openSettings(event),
closeSettings: () => authSettingsController.closeSettings(),
toggleCredentialsForm: () => authSettingsController.toggleCredentialsForm(),
toggleSyncForm: () => authSettingsController.toggleSyncForm(),
changeCredentials: () => authSettingsController.changeCredentials(),
editSyncClientSettings: (btn) => authSettingsController.editSyncClientSettings(btn),
deleteSyncClientSettings: (btn) => authSettingsController.deleteSyncClientSettings(btn),
saveSyncClientDisplayName: (nodeId) => authSettingsController.saveSyncClientDisplayName(nodeId),
loadSyncClientsSettings: () => authSettingsController.loadSyncClientsSettings(),
});
window.openSettings = (event) => authSettingsController.openSettings(event);
window.closeSettings = () => authSettingsController.closeSettings();
window.logout = () => authSettingsController.logout();
document.addEventListener('click', async (event) => {
const actionEl = event.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (await syncPanelModule.handleAction(action, event, actionEl)) return;
if (action === 'logout') {
event.preventDefault();
authSettingsController.logout();
}
});
document.addEventListener('DOMContentLoaded', () => {
authSettingsController.checkAuth();
initFeedQualitySettingsModal(notyf);
});
</script>
<footer class="app-footer" data-wesp-enter>
<span class="text-muted small">Комптон® является зарегистрированным товарным знаком</span>
</footer>
<link href="/static/css/wesp-update-banner.css" rel="stylesheet">
<script src="/static/js/wesp-update-notifier.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.WespUpdateNotifier && window.WespDialog) {
WespUpdateNotifier.init({ dialog: window.WespDialog });
}
});
</script>
</body>
</html>