Files
site/apps/web/public/wesp/consumption.html
T

1862 lines
94 KiB
HTML
Raw 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, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#2196F3">
<meta name="msapplication-navbutton-color" content="#2196F3">
<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=2"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=2"></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/wesp-feed-quality-settings.css?v=3" rel="stylesheet">
<link href="/static/css/inter-fonts.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" 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">
<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/bootstrap.bundle.min.js"></script>
<script src="/static/js/wesp-zootech-bootstrap-modals.js?v=3"></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/stock-forecast.js?v=1"></script>
<script src="/static/js/chart.umd.min.js"></script>
<style>
/* Модальное окно «Добавить на склад»: прокрутка списка */
#addToSkladModal .settings-modal-content {
max-height: 90vh;
display: flex;
flex-direction: column;
}
#addToSkladModal .settings-modal-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
#addToSkladModal #addToSkladList {
max-height: min(50vh, 320px);
overflow-y: auto;
margin: 0;
}
#addToSkladModal #addToSkladEmpty,
#addToSkladModal #addToSkladNoMatch {
flex-shrink: 0;
}
#addToSkladModal .add-to-sklad-search-wrap {
flex-shrink: 0;
}
#addToSkladModal .add-to-sklad-search-wrap .form-control {
border-radius: 8px;
padding: 0.5rem 0.75rem;
}
/* Тёмная тема: модальное окно «Добавить на склад» */
html[data-theme="dark"] #addToSkladModal .settings-modal-body p.text-muted {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladList.list-group {
background-color: var(--dm-surface) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladList .list-group-item {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladList .list-group-item:hover,
html[data-theme="dark"] #addToSkladModal #addToSkladList .list-group-item-action:hover {
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladEmpty.text-muted,
html[data-theme="dark"] #addToSkladModal #addToSkladNoMatch.text-muted {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladSearch.form-control {
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] #addToSkladModal #addToSkladSearch.form-control::placeholder {
color: var(--dm-muted);
}
html[data-theme="dark"] #addToSkladModal .settings-modal-body .text-center.text-muted,
html[data-theme="dark"] #addToSkladModal .settings-modal-body .text-center.text-danger {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #addToSkladModal .settings-modal-body .text-danger {
color: #f88 !important;
}
html[data-theme="dark"] .settings-user-card,
html[data-theme="dark"] .settings-theme-card,
html[data-theme="dark"] .settings-credentials-form {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-user-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
html[data-theme="dark"] .settings-card-title {
color: var(--dm-text);
}
html[data-theme="dark"] .theme-label {
color: var(--dm-muted);
}
@media (max-width: 768px) {
.container { padding: 0 0.5rem; }
.desktop-menu {
display: none !important;
}
.hamburger-menu {
display: flex !important;
}
.user-section {
display: none !important;
}
}
/* Футер */
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.page-footer {
margin-top: auto;
padding: 2rem 0;
background-color: transparent;
}
html[data-theme="dark"] .page-footer {
border: none;
}
@media (max-width: 768px) {
.page-footer {
padding: 1.5rem 0;
}
}
/* Модальное окно настроек */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: #fff;
margin: 5% auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.16), 0 0 0 1px rgba(17, 24, 39, 0.06);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
color: white;
padding: 1.5rem;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.modal-header h3 i {
margin-right: 0.5rem;
color: #ffd700;
}
.close {
color: white;
font-size: 2rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close:hover {
background-color: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.modal-body {
padding: 2rem;
}
.modal-body .form-group {
margin-bottom: 1.5rem;
}
.modal-body label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
display: block;
}
.modal-body .form-control {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
transition: all 0.3s ease;
background-color: #fafafa;
}
.modal-body .form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1);
background-color: white;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 1rem;
background-color: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.modal-footer .btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
min-width: 120px;
}
.modal-footer .btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
}
.modal-footer .btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
transform: translateY(-2px);
}
.modal-footer .btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
border: none;
color: white;
}
.modal-footer .btn-primary:hover {
background: linear-gradient(135deg, var(--primary-dark), var(--primary-darker));
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
@media (max-width: 768px) {
.modal-content {
margin: 10% auto;
width: 95%;
max-width: none;
}
.modal-header {
padding: 1rem;
}
.modal-header h3 {
font-size: 1.1rem;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
flex-direction: column;
}
.modal-footer .btn {
width: 100%;
margin-bottom: 0.5rem;
}
}
html[data-theme="dark"] .modal-body label {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal-footer {
background-color: var(--dm-surface) !important;
border-top-color: var(--dm-border) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary:hover {
background-color: #5a5959 !important;
border-color: rgba(255, 255, 255, 0.25) !important;
}
html[data-theme="dark"] .modal-footer .btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)) !important;
border: none !important;
color: white !important;
}
html[data-theme="dark"] .modal-footer .btn-primary:hover {
background: linear-gradient(135deg, var(--primary-dark), var(--primary-darker)) !important;
}
/* Модальное окно на всю страницу */
.full-page-modal {
display: none;
position: fixed;
z-index: 3000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
overflow-y: auto;
opacity: 0;
transition: opacity 0.3s ease;
}
.full-page-modal.active {
display: block;
opacity: 1;
}
.full-page-modal-content {
background-color: #fff;
margin: 0;
padding: 0;
min-height: 100vh;
transform: scale(0.98);
transition: transform 0.3s ease;
}
.full-page-modal.active .full-page-modal-content {
transform: scale(1);
}
html[data-theme="dark"] .full-page-modal-content {
background-color: var(--dm-bg);
color: var(--dm-text);
}
.full-page-modal-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
color: white;
padding: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.full-page-modal-header h2 {
margin: 0;
font-size: 2rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
}
.full-page-modal-header h2 i {
color: #ffd700;
font-size: 2.2rem;
}
.full-page-modal-header h2 img {
height: 50px;
width: auto;
filter: brightness(0) invert(1);
}
.close-full-modal {
color: white;
font-size: 2.5rem;
font-weight: bold;
cursor: pointer;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
}
.close-full-modal:hover {
background: rgba(255, 255, 255, 0.2);
}
.full-page-modal-body {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
@media (max-width: 768px) {
.full-page-modal-header {
padding: 1.5rem;
}
.full-page-modal-header h2 {
font-size: 1.5rem;
}
.full-page-modal-header h2 i {
font-size: 1.8rem;
}
.full-page-modal-body {
padding: 1rem;
}
}
/* Отдельный блок с компонентами (как в Components), одна карточка на ширину блока */
.consumption-section {
margin: 2rem 0;
}
.consumption-section .consumption-block {
background: #fff;
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
html[data-theme="dark"] .consumption-section .consumption-block {
background: var(--dm-surface);
border-color: var(--dm-border);
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -2px rgba(0,0,0,0.2);
}
.consumption-section .section-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.consumption-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.25rem;
}
.consumption-filters label { margin: 0; font-weight: 500; color: #555; }
.consumption-filters input[type="date"] {
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid #ddd;
}
.consumption-filters-add-btn {
margin-left: auto;
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 1rem;
line-height: 1.5;
}
.fa-tab-bar {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.fa-tab-btn {
border: 1px solid var(--dm-border, #d1d5db);
background: transparent;
border-radius: 8px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.fa-tab-btn.active {
background: var(--primary-color, #2196F3);
color: #fff;
border-color: var(--primary-color, #2196F3);
}
.fa-org-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem 1rem;
}
.fa-org-grid .fa-field--wide {
grid-column: 1 / -1;
}
.fa-org-grid .fa-field--wide .form-control {
min-width: 0;
}
@media (max-width: 640px) {
.fa-org-grid { grid-template-columns: 1fr; }
}
.fa-sig-actions {
margin-top: 0.5rem;
}
.fa-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--dm-border, #e5e7eb);
}
.fa-sig-block {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--dm-border, #e5e7eb);
border-radius: 8px;
}
.fa-sig-canvas {
width: 100%;
max-width: 320px;
height: 80px;
border: 1px dashed #9ca3af;
border-radius: 6px;
touch-action: none;
background: #fff;
}
html[data-theme="dark"] .fa-sig-canvas { background: #2c3338; }
.fa-export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
@media (max-width: 480px) {
.fa-export-grid { grid-template-columns: 1fr; }
}
#feedAccountingModal .settings-modal-content {
max-width: 720px;
max-height: 92vh;
overflow: auto;
}
/* Одна карточка на всю ширину блока (как в Components) */
.component-cards-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.component-card {
background: white;
border: 2px solid transparent;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 0;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 6px 14px rgba(17, 24, 39, 0.08);
width: 100%;
box-sizing: border-box;
}
.component-card:hover {
border-color: var(--primary-color);
box-shadow: 0 6px 14px rgba(17, 24, 39, 0.08), 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.component-card .component-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.component-card .component-name {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin: 0;
}
.component-card .component-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.component-card .info-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.component-card .info-label {
font-weight: 500;
color: #64748b;
min-width: 100px;
}
.component-card .info-value {
color: #1e293b;
font-weight: 600;
}
html[data-theme="dark"] .consumption-section .section-title { color: var(--dm-text); }
html[data-theme="dark"] .consumption-filters label { color: var(--dm-muted); }
html[data-theme="dark"] .consumption-filters input[type="date"] {
background: var(--dm-field);
color: var(--dm-text);
border-color: var(--dm-border);
}
html[data-theme="dark"] .component-card {
background: var(--dm-surface);
box-shadow: 0 6px 14px rgba(0,0,0,0.2);
border-color: transparent !important;
}
html[data-theme="dark"] .component-card:hover {
border-color: rgba(255, 255, 255, 0.25) !important;
box-shadow: 0 6px 14px rgba(0,0,0,0.2), 0 0 0 2px rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] .component-card .component-name { color: var(--dm-text); }
html[data-theme="dark"] .component-card .info-label { color: var(--dm-muted); }
html[data-theme="dark"] .component-card .info-value { color: var(--dm-text); }
.component-card.component-card--clickable { cursor: pointer; }
.component-card.component-card--low-stock {
border: 2px solid rgba(220, 53, 69, 0.4);
animation: component-card-low-stock-pulse 2s ease-in-out infinite;
}
@keyframes component-card-low-stock-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.15); border-color: rgba(220, 53, 69, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.2); border-color: rgba(220, 53, 69, 0.65); }
}
html[data-theme="dark"] .component-card.component-card--low-stock {
border-color: rgba(220, 53, 69, 0.5);
}
html[data-theme="dark"] .component-card.component-card--low-stock {
animation-name: component-card-low-stock-pulse-dark;
}
@keyframes component-card-low-stock-pulse-dark {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.2); border-color: rgba(220, 53, 69, 0.5); }
50% { box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.25); border-color: rgba(220, 53, 69, 0.75); }
}
.component-card .component-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.component-card .component-header .component-name { margin: 0; flex: 1; }
.component-card-actions { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
.component-cards-grid.components-move-pending .component-card-move-up,
.component-cards-grid.components-move-pending .component-card-move-down { pointer-events: none; opacity: 0.6; }
.component-card-remove-btn { flex-shrink: 0; padding: 0.35rem 0.5rem; color: #dc3545; border-color: #dc3545; }
.component-card-remove-btn:hover { color: #fff; background-color: #dc3545; }
#componentCardModal { opacity: 0; transition: opacity 0.2s ease; pointer-events: none; }
#componentCardModal.component-card-modal-show { opacity: 1; pointer-events: auto; }
#componentCardModal .settings-modal-content { transform: scale(0.95); opacity: 0; transition: transform 0.2s ease, opacity 0.2s ease; }
#componentCardModal.component-card-modal-show .settings-modal-content { transform: scale(1); opacity: 1; }
.component-card-modal-consumed-row { text-align: center; padding: 0.75rem 1rem; font-size: 0.95rem; color: #64748b; border-top: 1px solid #e2e8f0; }
html[data-theme="dark"] .component-card-modal-consumed-row { color: var(--dm-muted); border-top-color: var(--dm-border); }
.component-card-modal-consumed { background: transparent; cursor: default; border: none; }
html[data-theme="dark"] .component-card-modal-consumed { background: transparent !important; color: var(--dm-muted) !important; border: none !important; }
/* Правая закладка и панель «Выбранные компоненты» (как в recipes.html) */
.sync-panel-tab {
position: fixed;
top: 50%;
right: 0;
transform: translateY(-50%);
z-index: 1001;
width: 32px;
height: 72px;
padding: 0;
border: none;
border-radius: 12px 0 0 12px;
background: var(--primary-color, #2196F3);
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
transition: background 0.2s, width 0.2s;
}
.sync-panel-tab:hover {
background: var(--primary-dark, #1976D2);
width: 36px;
}
.sync-panel-tab span {
display: block;
width: 14px;
height: 2px;
background: currentColor;
border-radius: 1px;
}
html[data-theme="dark"] .sync-panel-tab {
background: var(--dm-field, #5a5959);
color: var(--dm-text);
}
html[data-theme="dark"] .sync-panel-tab:hover {
background: var(--dm-border);
}
.sync-panel-overlay {
position: fixed;
inset: 0;
z-index: 1001;
background: rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sync-panel-overlay.open {
opacity: 1;
pointer-events: auto;
}
html[data-theme="dark"] .sync-panel-overlay {
background: rgba(0, 0, 0, 0.5);
}
.sync-panel {
position: fixed;
top: 0;
right: 0;
width: min(400px, 90vw);
max-width: 100%;
height: 100vh;
background: #fff;
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
z-index: 1002;
transform: translateX(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sync-panel.open {
transform: translateX(0);
}
/* В обёртке панель и график — соседние блоки, без перекрытия */
.components-panel-wrap .sync-panel {
position: relative;
right: auto;
top: auto;
height: 100%;
transform: none;
flex-shrink: 0;
}
html[data-theme="dark"] .sync-panel {
background: var(--dm-bg);
color: var(--dm-text);
box-shadow: -4px 0 20px rgba(0,0,0,0.4);
}
.sync-panel-header {
flex-shrink: 0;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0,0,0,0.08);
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fa;
}
html[data-theme="dark"] .sync-panel-header {
border-color: var(--dm-border);
background: var(--dm-surface);
}
.sync-panel-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.sync-panel-close {
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
transition: background 0.2s, color 0.2s;
}
.sync-panel-close:hover {
background: rgba(0,0,0,0.06);
color: #333;
}
html[data-theme="dark"] .sync-panel-close {
color: var(--dm-muted);
}
html[data-theme="dark"] .sync-panel-close:hover {
background: var(--dm-border);
color: var(--dm-text);
}
.sync-panel-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.components-panel-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 10px;
background: #f8fafc;
font-weight: 500;
}
.components-panel-item--clickable {
cursor: pointer;
}
.components-panel-item--clickable:hover {
background: #e2e8f0;
border-color: var(--primary-color, #2196F3);
}
html[data-theme="dark"] .components-panel-item {
background: var(--dm-surface);
border-color: var(--dm-border);
color: var(--dm-text);
}
html[data-theme="dark"] .components-panel-item--clickable:hover {
background: var(--dm-border);
border-color: var(--primary-color);
}
.components-panel-wrap {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 1002;
display: flex;
flex-direction: row-reverse;
pointer-events: none;
}
.components-panel-wrap .sync-panel,
.components-panel-wrap .components-chart-area {
pointer-events: auto;
}
.components-chart-area {
width: 0;
overflow: hidden;
transition: width 0.2s ease;
background: #fff;
box-shadow: -4px 0 20px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
border-right: 1px solid rgba(0,0,0,0.08);
}
.components-chart-area.open {
width: min(1200px, 90vw);
}
html[data-theme="dark"] .components-chart-area {
background: var(--dm-bg);
border-right-color: var(--dm-border);
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
}
.components-chart-area-header {
flex-shrink: 0;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0,0,0,0.08);
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fa;
}
html[data-theme="dark"] .components-chart-area-header {
background: var(--dm-surface);
border-color: var(--dm-border);
}
.components-chart-area-header h3 { margin: 0; font-size: 1.1rem; font-weight: 600; }
.components-chart-area-close { width: 36px; height: 36px; padding: 0; border: none; border-radius: 8px; background: transparent; color: #666; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; }
.components-chart-area-close:hover { background: rgba(0,0,0,0.06); color: #333; }
html[data-theme="dark"] .components-chart-area-close { color: var(--dm-muted); }
html[data-theme="dark"] .components-chart-area-close:hover { background: var(--dm-border); color: var(--dm-text); }
.components-chart-area-body { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; }
.components-panel-chart-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.components-panel-chart-filters label { margin: 0; font-size: 0.9rem; }
.components-panel-chart-filters input[type="date"] { padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid #ddd; }
.components-panel-chart-canvas-wrap { flex: 1; min-height: 280px; position: relative; }
.components-panel-item--selected { border-color: var(--primary-color, #2196F3); background: rgba(var(--primary-rgb), 0.08); }
html[data-theme="dark"] .components-panel-item--selected { background: rgba(var(--primary-rgb), 0.15); border-color: var(--primary-color); }
html[data-theme="dark"] .components-chart-area-header h3 { color: var(--dm-text); }
html[data-theme="dark"] .components-chart-area-body input[type="date"] { background: var(--dm-field); color: var(--dm-text); border-color: var(--dm-border); }
.components-panel-empty {
text-align: center;
padding: 1.5rem 1rem;
color: #64748b;
font-size: 0.95rem;
}
html[data-theme="dark"] .components-panel-empty {
color: var(--dm-muted);
}
</style>
<link href="/static/css/wesp-zootech-pages-polish.css?v=1" rel="stylesheet">
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-app-body">
<div id="wespZootechNavMount" data-active-page="feed_consumption"></div>
<div class="page-content wesp-page-main">
<div class="container">
<div class="consumption-section">
<div class="consumption-block zt-section" data-wesp-enter>
<h2 class="section-title">
<i class="fas fa-list" style="color: var(--primary-color);"></i>
Склад и учёт кормов
</h2>
<div class="consumption-filters">
<label for="consumptionDateFrom">С:</label>
<input type="date" id="consumptionDateFrom" aria-label="Дата начала">
<label for="consumptionDateTo">По:</label>
<input type="date" id="consumptionDateTo" aria-label="Дата окончания">
<button type="button" class="btn btn-outline-secondary consumption-filters-add-btn" id="openFeedAccountingBtn">
<i class="fas fa-file-alt"></i> Учётные документы
</button>
<button type="button" class="btn btn-outline-primary consumption-filters-add-btn" id="addToSkladBtn">
<i class="fas fa-plus"></i> Добавить на склад
</button>
</div>
<div id="stockForecastBanner" class="stock-forecast-banner" hidden role="status"></div>
<div id="consumptionComponentsList" class="component-cards-grid">
<!-- Карточки подгружаются через /api/sklad (остатки — recipes.db, расход — reports.db) -->
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно: добавить компонент на склад -->
<div id="addToSkladModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content">
<div class="settings-modal-header">
<h3><i class="fas fa-plus"></i> Добавить компонент на склад</h3>
<span class="settings-close" onclick="closeAddToSkladModal()">&times;</span>
</div>
<div class="settings-modal-body">
<p class="text-muted small mb-2">Выберите компонент из списка:</p>
<div class="add-to-sklad-search-wrap mb-2">
<input type="text" id="addToSkladSearch" class="form-control" placeholder="Поиск по названию или типу..." aria-label="Поиск компонента" autocomplete="off">
</div>
<div id="addToSkladList" class="list-group"></div>
<div id="addToSkladNoMatch" class="text-muted py-3 text-center" style="display: none;">Нет компонентов по запросу</div>
<div id="addToSkladEmpty" class="text-muted py-3 text-center" style="display: none;">Нет доступных компонентов для добавления</div>
</div>
</div>
</div>
<!-- Модальное окно: карточка компонента (Всего кг, Израсходовано внизу) -->
<div id="componentCardModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content">
<div class="settings-modal-header">
<h3><i class="fas fa-box-open"></i> <span id="componentCardModalTitle"></span></h3>
<span class="settings-close" onclick="closeComponentCardModal()">&times;</span>
</div>
<div class="settings-modal-body">
<div class="form-group mb-2">
<label class="form-label">Всего на складе (кг)</label>
<input type="number" step="0.1" min="0" id="componentCardModalTotalKg" class="form-control" placeholder="0">
</div>
<div class="form-group mb-0">
<label class="form-label">Поступление (кг)</label>
<input type="number" step="0.1" min="0" id="componentCardModalInflowKg" class="form-control" placeholder="0">
</div>
</div>
<div class="component-card-modal-consumed-row">
<span id="componentCardModalConsumed" class="component-card-modal-consumed">Израсходовано: —</span>
</div>
<p id="componentCardModalExplanation" class="text-muted small px-3 mb-0" hidden></p>
<div class="settings-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeComponentCardModal()">Закрыть</button>
<button type="button" class="btn btn-primary" id="componentCardModalSaveBtn"><i class="fas fa-save"></i> Сохранить</button>
</div>
</div>
</div>
<!-- Модалка: учётные документы (СП-20, журнал, экспорт) -->
<div id="feedAccountingModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content">
<div class="settings-modal-header">
<h3><i class="fas fa-file-alt"></i> Учётные документы</h3>
<span class="settings-close" onclick="closeFeedAccountingModal()">&times;</span>
</div>
<div class="settings-modal-body">
<div class="fa-tab-bar">
<button type="button" class="fa-tab-btn zt-tab active" data-tab="requisites">Реквизиты СП-20</button>
<button type="button" class="fa-tab-btn zt-tab" data-tab="signatures">Подписи</button>
<button type="button" class="fa-tab-btn zt-tab" data-tab="export">Экспорт</button>
</div>
<div class="fa-tab-panel" data-tab="requisites">
<p class="text-muted small">Данные для шапки формы СП-20 (ОКУД 0325020). Сохраняются один раз и подставляются в каждый документ.</p>
<div class="fa-org-grid">
<div class="fa-field--wide"><label class="form-label" for="faOrgName">Организация</label><input id="faOrgName" class="form-control" type="text" autocomplete="organization"></div>
<div><label class="form-label" for="faOkpo">ОКПО</label><input id="faOkpo" class="form-control" type="text"></div>
<div><label class="form-label" for="faDocPrefix">Префикс №</label><input id="faDocPrefix" class="form-control" type="text"></div>
<div class="fa-field--wide"><label class="form-label" for="faFarm">Ферма</label><input id="faFarm" class="form-control" type="text" list="faFarmList" placeholder="Подтягивается из базы"><datalist id="faFarmList"></datalist></div>
<div><label class="form-label" for="faDepartment">Отделение</label><input id="faDepartment" class="form-control" type="text"></div>
<div><label class="form-label" for="faBrigade">Бригада</label><input id="faBrigade" class="form-control" type="text"></div>
<div><label class="form-label" for="faAnimalType">Вид скота</label><input id="faAnimalType" class="form-control" type="text"></div>
<div><label class="form-label" for="faResponsible">Ответственный</label><input id="faResponsible" class="form-control" type="text"></div>
<div><label class="form-label" for="faZootech">Зоотехник (ФИО)</label><input id="faZootech" class="form-control" type="text"></div>
<div><label class="form-label" for="faWarehouse">Кладовщик (ФИО)</label><input id="faWarehouse" class="form-control" type="text"></div>
</div>
</div>
<div class="fa-tab-panel" data-tab="signatures" hidden>
<p class="text-muted small">Нарисуйте подписи пальцем или стилусом. Сохранение — одной кнопкой «Сохранить всё» внизу окна.</p>
<div class="fa-sig-block">
<label class="form-label">Зоотехник</label>
<canvas id="faSigZootech" class="fa-sig-canvas" width="320" height="80"></canvas>
<div class="fa-sig-actions"><button type="button" class="btn btn-sm btn-outline-secondary" id="clear_faSigZootech">Очистить</button></div>
</div>
<div class="fa-sig-block">
<label class="form-label">Кладовщик</label>
<canvas id="faSigWarehouse" class="fa-sig-canvas" width="320" height="80"></canvas>
<div class="fa-sig-actions"><button type="button" class="btn btn-sm btn-outline-secondary" id="clear_faSigWarehouse">Очистить</button></div>
</div>
<div class="fa-sig-block">
<label class="form-label">Получатель</label>
<canvas id="faSigRecipient" class="fa-sig-canvas" width="320" height="80"></canvas>
<div class="fa-sig-actions"><button type="button" class="btn btn-sm btn-outline-secondary" id="clear_faSigRecipient">Очистить</button></div>
</div>
</div>
<div class="fa-tab-panel" data-tab="export" hidden>
<div class="fa-field mb-3">
<label class="form-label" for="faExportMonth">Месяц для СП-20 и журнала</label>
<input id="faExportMonth" class="form-control" type="month" style="max-width: 14rem;">
</div>
<p id="faSp20Status" class="text-muted small mb-3">Загрузка сводки…</p>
<p class="text-muted small">Остатки и потребление — за период «С / По» на странице.</p>
<div class="fa-export-grid">
<button type="button" class="btn btn-outline-success" id="faExportConsumptionXlsx"><i class="fas fa-file-excel"></i> Excel: потребление</button>
<button type="button" class="btn btn-outline-danger" id="faExportConsumptionPdf"><i class="fas fa-file-pdf"></i> PDF: потребление</button>
<button type="button" class="btn btn-outline-success" id="faExportStock"><i class="fas fa-file-excel"></i> Excel: остатки</button>
<button type="button" class="btn btn-outline-success" id="faExportSp20Xlsx"><i class="fas fa-file-excel"></i> Excel: СП-20</button>
<button type="button" class="btn btn-outline-danger" id="faExportSp20Pdf"><i class="fas fa-file-pdf"></i> PDF: СП-20</button>
<button type="button" class="btn btn-outline-success" id="faExportJournalXlsx"><i class="fas fa-file-excel"></i> Excel: журнал</button>
<button type="button" class="btn btn-outline-danger" id="faExportJournalPdf"><i class="fas fa-file-pdf"></i> PDF: журнал</button>
<button type="button" class="btn btn-primary" id="faExportZip"><i class="fas fa-file-archive"></i> Скачать всё (ZIP)</button>
</div>
</div>
</div>
<div class="fa-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeFeedAccountingModal()">Закрыть</button>
<button type="button" class="btn btn-primary" id="faSaveAllBtn"><i class="fas fa-save"></i> Сохранить всё</button>
</div>
</div>
</div>
<script src="/static/js/feed-accounting-modal.js?v=5"></script>
<script>
const notyf = window.WespZootechNotify.createAdapter();
function getDefaultMonthDates() {
var now = new Date();
var from = new Date(now.getFullYear(), now.getMonth(), 1);
var to = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
}
function setDefaultConsumptionDates() {
var d = getDefaultMonthDates();
var elFrom = document.getElementById('consumptionDateFrom');
var elTo = document.getElementById('consumptionDateTo');
if (elFrom && !elFrom.value) elFrom.value = d.from;
if (elTo && !elTo.value) elTo.value = d.to;
}
// Дебаунс для загрузки списка при смене дат — один запрос вместо нескольких при быстром изменении или инициализации
var loadConsumptionComponentsDebounceTimer = null;
function loadConsumptionComponentsDebounced() {
if (loadConsumptionComponentsDebounceTimer) clearTimeout(loadConsumptionComponentsDebounceTimer);
loadConsumptionComponentsDebounceTimer = setTimeout(function() {
loadConsumptionComponentsDebounceTimer = null;
loadConsumptionComponents();
}, 150);
}
// Потребление по компонентам через модуль склад: остатки из recipes.db, расход из reports.db (считаем, не пишем)
function loadConsumptionComponents() {
var dateFrom = document.getElementById('consumptionDateFrom') && document.getElementById('consumptionDateFrom').value;
var dateTo = document.getElementById('consumptionDateTo') && document.getElementById('consumptionDateTo').value;
var url = '/api/sklad';
if (dateFrom && dateTo) {
url += '?date_from=' + encodeURIComponent(dateFrom) + '&date_to=' + encodeURIComponent(dateTo);
}
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
var list = document.getElementById('consumptionComponentsList');
if (!list) return;
var items = data.items;
if (data.error || !Array.isArray(items)) {
list.innerHTML = '<div class="text-danger py-3"><i class="fas fa-exclamation-triangle"></i> ' + (data.error || data.message || 'Ошибка загрузки') + '</div>';
return;
}
if (items.length === 0) {
list.innerHTML = '<div class="text-muted py-5 text-center"><i class="fas fa-box-open fa-3x mb-3"></i><p>Нет данных о расходе за выбранный период.</p></div>';
return;
}
function fmtKg(x) {
var n = Number(x);
return (Number.isFinite(n) ? n.toFixed(1) : '—') + ' кг';
}
function fmtDaysLabel(label, daysVal) {
if (label) return label;
if (daysVal != null && Number.isFinite(Number(daysVal))) {
var d = Number(daysVal);
if (d >= 9999) return '≥9999';
if (d < 1) return '~' + Math.round(d * 24) + ' ч';
return '~' + (d === Math.floor(d) ? String(Math.floor(d)) : d.toFixed(1)) + ' дн';
}
return '—';
}
list.innerHTML = items.map(function(item, index) {
var rawName = (item.name || '—');
var name = rawName.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
var cid = (item.component_id || '').replace(/"/g, '&quot;');
var forecastKey = (item.forecast_key || item.component_id || item.name_key || '').replace(/"/g, '&quot;');
var totalVal = Number(item.total_kg);
var totalStr = Number.isFinite(totalVal) ? totalVal.toFixed(1) : '';
var consumedVal = item.consumed_total_kg != null ? Number(item.consumed_total_kg) : Number(item.consumed_kg);
if (!Number.isFinite(consumedVal)) consumedVal = 0;
var consumed = fmtKg(consumedVal);
var remainingVal = Number(item.remaining_kg);
if (!Number.isFinite(remainingVal)) remainingVal = 0;
var remaining = fmtKg(remainingVal);
var plannedPerDay = Number(item.planned_consumption_per_day_kg);
var plannedPerDayStr = (Number.isFinite(plannedPerDay) && plannedPerDay > 0) ? (plannedPerDay.toFixed(2) + ' кг/сут') : '—';
var daysLeftPlan = item.days_left_plan != null ? Number(item.days_left_plan) : null;
var daysLeftStr = (daysLeftPlan != null && Number.isFinite(daysLeftPlan)) ? (daysLeftPlan >= 9999 ? '≥9999' : daysLeftPlan.toFixed(1) + ' дн.') : '—';
var daysLeftAdj = item.days_left_adjusted != null ? Number(item.days_left_adjusted) : (item.daysLeftAdjusted != null ? Number(item.daysLeftAdjusted) : null);
var daysLeftAdjStr = fmtDaysLabel(item.days_left_adjusted_label || item.daysLeftAdjustedLabel, daysLeftAdj);
var adjWarnClass = (daysLeftAdj != null && Number.isFinite(daysLeftAdj) && daysLeftAdj <= 2) ? ' info-item--warn' : '';
var explanation = (item.explanation || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var explanationHidden = explanation ? '' : ' hidden';
var lowStockClass = ((daysLeftPlan != null && Number.isFinite(daysLeftPlan) && daysLeftPlan <= 1.5) || (daysLeftAdj != null && Number.isFinite(daysLeftAdj) && daysLeftAdj <= 1.5)) ? ' component-card--low-stock' : '';
var moveUpDisabled = index === 0 ? ' disabled' : '';
var moveDownDisabled = index === items.length - 1 ? ' disabled' : '';
var inflowVal = item.inflow_kg != null ? Number(item.inflow_kg) : 0;
var inflowStr = Number.isFinite(inflowVal) ? inflowVal.toFixed(1) : '0';
var hasStock = item.has_stock !== false;
var cardClass = 'component-card component-card--clickable' + (hasStock ? '' : ' component-card--report-only') + lowStockClass;
var actionsHtml = hasStock
? '<div class="component-card-actions">' +
'<button type="button" class="btn btn-sm btn-outline-secondary component-card-move-up"' + moveUpDisabled + ' title="Выше" aria-label="Выше"><i class="fas fa-arrow-up"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary component-card-move-down"' + moveDownDisabled + ' title="Ниже" aria-label="Ниже"><i class="fas fa-arrow-down"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-danger component-card-remove-btn" data-component-id="' + cid + '" data-component-name="' + rawName.replace(/"/g, '&quot;') + '" title="Убрать со склада" aria-label="Убрать со склада"><i class="fas fa-trash"></i></button>' +
'</div>'
: '<span class="badge bg-secondary">расход из отчётов</span>';
return '<div class="' + cardClass + '" data-component-id="' + cid + '" data-forecast-key="' + forecastKey + '" data-has-stock="' + (hasStock ? '1' : '0') + '" data-sort-index="' + index + '" data-component-name="' + rawName.replace(/"/g, '&quot;') + '" data-consumed="' + consumed.replace(/"/g, '&quot;') + '" data-total-kg="' + totalStr + '" data-inflow-kg="' + inflowStr + '">' +
'<div class="component-header">' +
'<h3 class="component-name">' + name + '</h3>' +
actionsHtml +
'</div>' +
'<div class="component-info">' +
'<div class="info-item"><span class="info-label"><i class="fas fa-box-open me-1"></i> Осталось:</span><span class="info-value">' + remaining + '</span></div>' +
'<div class="info-item"><span class="info-label"><i class="fas fa-history me-1"></i> Расход /24ч (план):</span><span class="info-value">' + plannedPerDayStr + '</span></div>' +
'<div class="info-item"><span class="info-label"><i class="fas fa-calendar-alt me-1"></i> Хватит по рецепту:</span><span class="info-value">' + daysLeftStr + '</span></div>' +
'<div class="info-item' + adjWarnClass + '"><span class="info-label"><i class="fas fa-calendar-check me-1"></i> Хватит (с учётом плана):</span><span class="info-value" data-stock-days-adjusted>' + daysLeftAdjStr + '</span></div>' +
'<p class="stock-card-explanation text-muted small mb-0" data-stock-explanation' + explanationHidden + '>' + explanation + '</p>' +
'</div></div>';
}).join('');
if (window.WespStockForecast && typeof window.WespStockForecast.refresh === 'function') {
window.WespStockForecast.refresh();
}
})
.catch(function(err) {
var list = document.getElementById('consumptionComponentsList');
if (list) list.innerHTML = '<div class="text-danger py-3"><i class="fas fa-exclamation-triangle"></i> Ошибка загрузки данных</div>';
console.error('loadConsumptionComponents:', err);
});
}
function moveComponent(componentId, toIndex) {
var list = document.getElementById('consumptionComponentsList');
if (list) list.classList.add('components-move-pending');
return fetch('/api/sklad/' + encodeURIComponent(componentId) + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to_index: toIndex })
}).then(function(r) { return r.json(); }).then(function(payload) {
if (payload.success) {
loadConsumptionComponents();
} else {
notyf.error(payload.error, { fallback: 'Ошибка перемещения' });
}
}).catch(function() {
notyf.error('Ошибка перемещения');
}).finally(function() {
if (list) list.classList.remove('components-move-pending');
});
}
function moveComponentUp(componentId, currentIndex) {
if (!Number.isFinite(currentIndex) || currentIndex <= 0) return;
moveComponent(componentId, currentIndex - 1);
}
function moveComponentDown(componentId, currentIndex) {
var list = document.getElementById('consumptionComponentsList');
var total = list ? list.querySelectorAll('.component-card').length : 0;
if (!Number.isFinite(currentIndex) || currentIndex >= total - 1) return;
moveComponent(componentId, currentIndex + 1);
}
function filterAddToSkladList() {
var searchEl = document.getElementById('addToSkladSearch');
var listEl = document.getElementById('addToSkladList');
var noMatchEl = document.getElementById('addToSkladNoMatch');
if (!searchEl || !listEl) return;
var q = (searchEl.value || '').trim().toLowerCase();
var buttons = listEl.querySelectorAll('.list-group-item');
var visibleCount = 0;
buttons.forEach(function(btn) {
var searchText = (btn.dataset.search || btn.textContent || '').toLowerCase();
var show = !q || searchText.indexOf(q) !== -1;
btn.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
if (noMatchEl) noMatchEl.style.display = (q && buttons.length > 0 && visibleCount === 0) ? 'block' : 'none';
}
function openAddToSkladModal() {
var modal = document.getElementById('addToSkladModal');
var listEl = document.getElementById('addToSkladList');
var emptyEl = document.getElementById('addToSkladEmpty');
var noMatchEl = document.getElementById('addToSkladNoMatch');
var searchEl = document.getElementById('addToSkladSearch');
if (!modal || !listEl) return;
listEl.innerHTML = '<div class="text-center text-muted py-3">Загрузка...</div>';
if (emptyEl) emptyEl.style.display = 'none';
if (noMatchEl) noMatchEl.style.display = 'none';
if (searchEl) searchEl.value = '';
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
Promise.all([fetch('/api/sklad').then(function(r) { return r.json(); }), fetch('/api/components').then(function(r) { return r.json(); })])
.then(function(results) {
var skladData = results[0];
var components = results[1];
var items = Array.isArray(skladData.items) ? skladData.items : [];
var existingIds = new Set(items.map(function(it) { return String(it.component_id || ''); }));
var available = Array.isArray(components) ? components.filter(function(c) { return c && c.id && !existingIds.has(String(c.id)); }) : [];
listEl.innerHTML = '';
if (available.length === 0) {
if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = existingIds.length ? 'Все компоненты уже на складе' : 'Нет компонентов'; }
return;
}
available.forEach(function(c) {
var label = (c.name || '') + (c.type ? ' (' + c.type + ')' : '');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action text-start';
btn.textContent = label;
btn.dataset.componentId = c.id;
btn.dataset.componentName = c.name || '';
btn.dataset.search = label.toLowerCase();
listEl.appendChild(btn);
});
listEl.querySelectorAll('button').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.componentId;
if (!id) return;
btn.disabled = true;
fetch('/api/sklad/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ component_id: id })
}).then(function(r) { return r.json(); }).then(function(payload) {
if (payload.success) {
closeAddToSkladModal();
loadConsumptionComponents();
} else {
notyf.error(payload.error, { fallback: 'Ошибка добавления' });
btn.disabled = false;
}
}).catch(function() {
notyf.error('Ошибка добавления');
btn.disabled = false;
});
});
});
filterAddToSkladList();
})
.catch(function(e) {
listEl.innerHTML = '<div class="text-center text-danger py-3">Ошибка загрузки</div>';
if (searchEl) searchEl.style.display = 'none';
});
}
function closeAddToSkladModal() {
var modal = document.getElementById('addToSkladModal');
if (modal) { modal.style.display = 'none'; document.body.style.overflow = ''; }
}
function openComponentCardModal(card) {
var modal = document.getElementById('componentCardModal');
var titleEl = document.getElementById('componentCardModalTitle');
var consumedEl = document.getElementById('componentCardModalConsumed');
var totalInput = document.getElementById('componentCardModalTotalKg');
var inflowInput = document.getElementById('componentCardModalInflowKg');
if (!modal || !titleEl || !consumedEl || !totalInput) return;
var name = (card.dataset.componentName || '').replace(/&quot;/g, '"');
var consumed = (card.dataset.consumed || '—').replace(/&quot;/g, '"');
var totalKg = card.dataset.totalKg || '';
var inflowKg = card.dataset.inflowKg || '0';
modal.dataset.componentId = card.dataset.componentId || '';
modal.dataset.hasStock = card.dataset.hasStock || '1';
modal.dataset.forecastKey = card.dataset.forecastKey || card.dataset.componentId || '';
titleEl.textContent = name || '—';
consumedEl.textContent = 'Израсходовано: ' + consumed;
totalInput.value = totalKg;
if (inflowInput) inflowInput.value = inflowKg;
var explEl = document.getElementById('componentCardModalExplanation');
var fc = window.WespStockForecast && window.WespStockForecast.getForecast
? window.WespStockForecast.getForecast(card.dataset.forecastKey || card.dataset.componentId || '')
: null;
if (explEl) {
if (fc && fc.explanation) {
explEl.textContent = fc.explanation;
explEl.hidden = false;
} else {
explEl.textContent = '';
explEl.hidden = true;
}
}
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
requestAnimationFrame(function() { modal.classList.add('component-card-modal-show'); });
totalInput.focus();
}
function closeComponentCardModal() {
var modal = document.getElementById('componentCardModal');
if (!modal) return;
modal.classList.remove('component-card-modal-show');
setTimeout(function() {
modal.style.display = 'none';
document.body.style.overflow = '';
}, 200);
}
// Один делегированный обработчик клика по списку карточек (не добавлять при каждой загрузке — иначе накапливаются запросы)
function bindConsumptionComponentsListClick() {
var list = document.getElementById('consumptionComponentsList');
if (!list || list._componentsClickBound) return;
list._componentsClickBound = true;
list.addEventListener('click', function(e) {
var removeBtn = e.target && e.target.closest && e.target.closest('.component-card-remove-btn');
if (removeBtn) {
e.preventDefault();
e.stopPropagation();
var cid = removeBtn.dataset.componentId;
var name = (removeBtn.dataset.componentName || '').replace(/&quot;/g, '"');
void (async function() {
if (!cid || !(await WespDialog.confirm('Убрать «' + (name || cid) + '» со склада?'))) return;
removeBtn.disabled = true;
fetch('/api/sklad/' + encodeURIComponent(cid), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(payload) {
removeBtn.disabled = false;
if (payload.success) { notyf.success('Убрано со склада'); loadConsumptionComponents(); }
else notyf.error(payload.error, { fallback: 'Ошибка' });
})
.catch(function() { removeBtn.disabled = false; notyf.error('Ошибка'); });
})();
return;
}
var moveUp = e.target && e.target.closest && e.target.closest('.component-card-move-up');
if (moveUp && !moveUp.disabled) {
e.preventDefault();
e.stopPropagation();
var card = moveUp.closest('.component-card');
if (card) moveComponentUp(card.dataset.componentId, parseInt(card.dataset.sortIndex, 10));
return;
}
var moveDown = e.target && e.target.closest && e.target.closest('.component-card-move-down');
if (moveDown && !moveDown.disabled) {
e.preventDefault();
e.stopPropagation();
var card = moveDown.closest('.component-card');
if (card) moveComponentDown(card.dataset.componentId, parseInt(card.dataset.sortIndex, 10));
return;
}
var card = e.target && e.target.closest && e.target.closest('.component-card.component-card--clickable');
if (!card) return;
e.preventDefault();
openComponentCardModal(card);
});
}
// Проверяем авторизацию при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
setDefaultConsumptionDates();
bindConsumptionComponentsListClick();
loadConsumptionComponents();
var elFrom = document.getElementById('consumptionDateFrom');
var elTo = document.getElementById('consumptionDateTo');
if (elFrom) elFrom.addEventListener('change', loadConsumptionComponentsDebounced);
if (elTo) elTo.addEventListener('change', loadConsumptionComponentsDebounced);
var addBtn = document.getElementById('addToSkladBtn');
if (addBtn) addBtn.addEventListener('click', openAddToSkladModal);
var addToSkladSearchEl = document.getElementById('addToSkladSearch');
if (addToSkladSearchEl) addToSkladSearchEl.addEventListener('input', filterAddToSkladList);
var addModal = document.getElementById('addToSkladModal');
if (addModal) addModal.addEventListener('click', function(e) { if (e.target === addModal) closeAddToSkladModal(); });
var cardModal = document.getElementById('componentCardModal');
if (cardModal) cardModal.addEventListener('click', function(e) { if (e.target === cardModal) closeComponentCardModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('componentCardModal');
if (m && m.style.display === 'block') closeComponentCardModal();
var panel = document.getElementById('componentsPanel');
if (panel && panel.classList.contains('open')) closeComponentsPanel();
}
});
var componentsPanelTab = document.getElementById('componentsPanelTab');
var componentsPanelOverlay = document.getElementById('componentsPanelOverlay');
var componentsPanel = document.getElementById('componentsPanel');
var componentsPanelClose = document.getElementById('componentsPanelClose');
if (componentsPanelTab) componentsPanelTab.addEventListener('click', openComponentsPanel);
if (componentsPanelClose) componentsPanelClose.addEventListener('click', closeComponentsPanel);
if (componentsPanelOverlay) componentsPanelOverlay.addEventListener('click', closeComponentsPanel);
var listEl = document.getElementById('componentsPanelList');
if (listEl) listEl.addEventListener('click', function(e) {
var item = e.target && e.target.closest && e.target.closest('.components-panel-item--clickable');
if (!item) return;
var id = item.dataset.componentId;
var name = item.dataset.componentName || '—';
if (id) openComponentsChartView(id, name, item);
});
var chartAreaCloseBtn = document.getElementById('componentsChartAreaClose');
if (chartAreaCloseBtn) chartAreaCloseBtn.addEventListener('click', closeComponentsChartView);
var chartBuildBtn = document.getElementById('componentsPanelChartBuild');
if (chartBuildBtn) chartBuildBtn.addEventListener('click', function() { loadComponentsChart(); });
var saveBtn = document.getElementById('componentCardModalSaveBtn');
if (saveBtn) saveBtn.addEventListener('click', function() {
var modal = document.getElementById('componentCardModal');
var cid = modal && modal.dataset.componentId;
var hasStock = modal && modal.dataset.hasStock !== '0';
var totalInput = document.getElementById('componentCardModalTotalKg');
var inflowInput = document.getElementById('componentCardModalInflowKg');
if (!totalInput) return;
var v = Number(totalInput.value);
if (!Number.isFinite(v) || v < 0) { notyf.error('Введите число ≥ 0'); return; }
var inflowVal = inflowInput ? Math.max(0, Number(inflowInput.value) || 0) : 0;
function saveStockTotal(componentId) {
saveBtn.disabled = true;
return fetch('/api/sklad/' + encodeURIComponent(componentId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ total_kg: v, inflow_kg: inflowVal })
}).then(function(r) { return r.json(); }).then(function(payload) {
saveBtn.disabled = false;
if (payload.success) { notyf.success('Сохранено'); closeComponentCardModal(); loadConsumptionComponents(); }
else notyf.error(payload.error, { fallback: 'Ошибка' });
}).catch(function() { saveBtn.disabled = false; notyf.error('Ошибка сохранения'); });
}
if (!hasStock) {
if (!cid) { notyf.error('Компонент не найден в справочнике — добавьте его в «Компоненты»'); return; }
saveBtn.disabled = true;
fetch('/api/sklad/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ component_id: cid })
}).then(function(r) { return r.json(); }).then(function(payload) {
if (!payload.success) {
saveBtn.disabled = false;
notyf.error(payload.error, { fallback: 'Ошибка добавления на склад' });
return;
}
return saveStockTotal(cid);
}).catch(function() { saveBtn.disabled = false; notyf.error('Ошибка добавления на склад'); });
return;
}
if (!cid) return;
saveStockTotal(cid);
});
});
function openComponentsPanel() {
var overlay = document.getElementById('componentsPanelOverlay');
var wrap = document.getElementById('componentsPanelWrap');
var panel = document.getElementById('componentsPanel');
if (overlay) overlay.classList.add('open');
if (wrap) wrap.style.display = 'flex';
if (panel) panel.classList.add('open');
loadComponentsPanelList();
}
function closeComponentsPanel() {
var overlay = document.getElementById('componentsPanelOverlay');
var wrap = document.getElementById('componentsPanelWrap');
var panel = document.getElementById('componentsPanel');
if (overlay) overlay.classList.remove('open');
if (wrap) wrap.style.display = 'none';
if (panel) panel.classList.remove('open');
closeComponentsChartView();
}
function loadComponentsPanelList() {
var listEl = document.getElementById('componentsPanelList');
var emptyEl = document.getElementById('componentsPanelEmpty');
var errorEl = document.getElementById('componentsPanelError');
if (!listEl) return;
listEl.innerHTML = '';
if (emptyEl) emptyEl.style.display = 'none';
if (errorEl) { errorEl.style.display = 'none'; errorEl.textContent = ''; }
fetch('/api/sklad').then(function(r) { return r.json(); })
.then(function(data) {
var items = data.items;
if (data.error || !Array.isArray(items)) {
if (errorEl) { errorEl.textContent = window.WespUserMessages?.userFacingMessage?.(data.error || data.message, 'Не удалось загрузить прогноз') || 'Не удалось загрузить прогноз'; errorEl.style.display = 'block'; }
return;
}
if (items.length === 0) {
if (emptyEl) emptyEl.style.display = 'block';
return;
}
items.forEach(function(item) {
var div = document.createElement('div');
div.className = 'components-panel-item components-panel-item--clickable';
div.textContent = item.name || '—';
div.dataset.componentId = item.component_id || '';
div.dataset.componentName = item.name || '—';
listEl.appendChild(div);
});
})
.catch(function(err) {
if (errorEl) { errorEl.textContent = 'Ошибка загрузки данных'; errorEl.style.display = 'block'; }
});
}
var componentsPanelChartInstance = null;
function openComponentsChartView(componentId, componentName, listItem) {
var chartArea = document.getElementById('componentsChartArea');
var titleEl = document.getElementById('componentsChartAreaTitle');
var chartFrom = document.getElementById('chartDateFrom');
var chartTo = document.getElementById('chartDateTo');
var mainFrom = document.getElementById('consumptionDateFrom');
var mainTo = document.getElementById('consumptionDateTo');
if (!chartArea) return;
chartArea.dataset.chartComponentId = componentId || '';
if (titleEl) titleEl.textContent = componentName || 'График остатка';
chartArea.classList.add('open');
var to = new Date();
var from = new Date(to);
from.setDate(from.getDate() - 30);
if (mainFrom && mainTo && mainFrom.value && mainTo.value) {
if (chartFrom) chartFrom.value = mainFrom.value;
if (chartTo) chartTo.value = mainTo.value;
} else {
if (chartFrom) chartFrom.value = from.toISOString().slice(0, 10);
if (chartTo) chartTo.value = to.toISOString().slice(0, 10);
}
var list = document.getElementById('componentsPanelList');
if (list) {
list.querySelectorAll('.components-panel-item--selected').forEach(function(el) { el.classList.remove('components-panel-item--selected'); });
if (listItem) listItem.classList.add('components-panel-item--selected');
}
loadComponentsChart();
}
function closeComponentsChartView() {
var chartArea = document.getElementById('componentsChartArea');
if (!chartArea) return;
chartArea.classList.remove('open');
chartArea.removeAttribute('data-chart-component-id');
var list = document.getElementById('componentsPanelList');
if (list) list.querySelectorAll('.components-panel-item--selected').forEach(function(el) { el.classList.remove('components-panel-item--selected'); });
if (componentsPanelChartInstance) {
componentsPanelChartInstance.destroy();
componentsPanelChartInstance = null;
}
}
function loadComponentsChart() {
var chartArea = document.getElementById('componentsChartArea');
var componentId = chartArea && chartArea.dataset.chartComponentId;
var chartFrom = document.getElementById('chartDateFrom');
var chartTo = document.getElementById('chartDateTo');
var canvas = document.getElementById('componentsPanelChartCanvas');
var emptyEl = document.getElementById('componentsPanelChartEmpty');
if (!componentId || !canvas) return;
var dateFrom = chartFrom && chartFrom.value ? chartFrom.value : '';
var dateTo = chartTo && chartTo.value ? chartTo.value : '';
var url = '/api/sklad/' + encodeURIComponent(componentId) + '/chart';
if (dateFrom && dateTo) url += '?date_from=' + encodeURIComponent(dateFrom) + '&date_to=' + encodeURIComponent(dateTo);
if (emptyEl) emptyEl.style.display = 'none';
fetch(url).then(function(r) { return r.json(); })
.then(function(data) {
if (componentsPanelChartInstance) { componentsPanelChartInstance.destroy(); componentsPanelChartInstance = null; }
if (data.error) {
if (emptyEl) { emptyEl.textContent = data.error; emptyEl.style.display = 'block'; }
return;
}
var points = data.points || [];
if (!points || points.length === 0) {
if (emptyEl) { emptyEl.textContent = 'Нет данных за выбранный период. Добавьте отчёты загрузки с расходом этого компонента.'; emptyEl.style.display = 'block'; }
return;
}
var labels = points.map(function(p) { return p.date; });
var values = points.map(function(p) { return p.remaining_kg; });
if (componentsPanelChartInstance) componentsPanelChartInstance.destroy();
var ctx = canvas.getContext('2d');
var isDark = document.documentElement.dataset.theme === 'dark';
componentsPanelChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Остаток (кг)',
data: values,
borderColor: isDark ? 'rgb(33, 150, 243)' : 'rgb(33, 150, 243)',
backgroundColor: isDark ? 'rgba(var(--primary-rgb), 0.1)' : 'rgba(var(--primary-rgb), 0.1)',
fill: true,
tension: 0.2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
title: { display: true, text: 'Дата' },
ticks: { maxRotation: 45, minRotation: 0 }
},
y: {
title: { display: true, text: 'Остаток (кг)' },
beginAtZero: true
}
}
}
});
})
.catch(function(err) {
if (componentsPanelChartInstance) { componentsPanelChartInstance.destroy(); componentsPanelChartInstance = null; }
if (emptyEl) { emptyEl.textContent = 'Ошибка загрузки графика. Проверьте подключение.'; emptyEl.style.display = 'block'; }
});
}
</script>
<!-- Настройки: сверху карточка пользователя, снизу выбор темы (общий стиль на всех страницах) -->
<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=4';
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', () => {
initFeedQualitySettingsModal(notyf);
authSettingsController.checkAuth();
authSettingsController.setupMobileMenu();
});
</script>
<!-- Правая закладка «Выбранные компоненты»: слева от панели — график, в панели — список -->
<button type="button" class="sync-panel-tab" id="componentsPanelTab" title="Выбранные компоненты" aria-label="Открыть панель выбранных компонентов">
<span></span><span></span><span></span>
</button>
<div class="sync-panel-overlay" id="componentsPanelOverlay"></div>
<div class="components-panel-wrap" id="componentsPanelWrap" style="display: none;">
<div class="sync-panel" id="componentsPanel">
<div class="sync-panel-header">
<h3 id="componentsPanelTitle">Выбранные компоненты</h3>
<button type="button" class="sync-panel-close" id="componentsPanelClose" aria-label="Закрыть">
<i class="fas fa-times"></i>
</button>
</div>
<div class="sync-panel-body">
<div id="componentsPanelList"></div>
<div id="componentsPanelEmpty" class="components-panel-empty" style="display: none;">Нет компонентов на складе</div>
<div id="componentsPanelError" class="components-panel-empty" style="display: none;"></div>
</div>
</div>
<div class="components-chart-area" id="componentsChartArea">
<div class="components-chart-area-header">
<h3 id="componentsChartAreaTitle">График остатка</h3>
<button type="button" class="components-chart-area-close" id="componentsChartAreaClose" aria-label="Закрыть график"><i class="fas fa-times"></i></button>
</div>
<div class="components-chart-area-body">
<div class="components-panel-chart-filters">
<label for="chartDateFrom">С:</label>
<input type="date" id="chartDateFrom" aria-label="Дата начала">
<label for="chartDateTo">По:</label>
<input type="date" id="chartDateTo" aria-label="Дата окончания">
<button type="button" class="btn btn-sm btn-primary" id="componentsPanelChartBuild"><i class="fas fa-chart-line"></i> Построить</button>
</div>
<div class="components-panel-chart-canvas-wrap">
<canvas id="componentsPanelChartCanvas"></canvas>
</div>
<div id="componentsPanelChartEmpty" class="components-panel-empty" style="display: none;">Нет данных за период</div>
</div>
</div>
</div>
<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>