1482 lines
76 KiB
HTML
1482 lines
76 KiB
HTML
<!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=17" 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=1"></script>
|
||
<script src="/static/js/pages/reports-date-range.js?v=4"></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=1"></script>
|
||
<script src="/static/js/pages/reports-export.js?v=1"></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">×</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> |