Files
site/apps/web/public/wesp/recipes_selection.html

3670 lines
149 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<script>
(function () {
try {
var t = (localStorage.getItem("theme") || "").trim().toLowerCase();
document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "dark");
} catch (e) {}
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Выбор рецепта</title>
<link rel="stylesheet" href="/static/css/inter-fonts.css">
<link href="/static/css/wesp-recipes-skeleton.css" rel="stylesheet">
<link href="/static/css/wesp-kiosk-skeleton.css" rel="stylesheet">
<style>
:root {
--primary: #2563eb;
--primary-light: #3b82f6;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-600: #4b5563;
--gray-900: #111827;
--white: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
body {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--gray-50);
color: var(--gray-900);
}
.card {
background: var(--white);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 20px;
margin: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
white-space: normal;
word-break: break-word;
hyphens: auto;
max-width: 100%;
box-sizing: border-box;
}
.btn-primary {
background: var(--primary);
color: var(--white);
}
.btn-primary:hover {
background: var(--primary-light);
}
.btn-success {
background: var(--success);
color: var(--white);
}
.btn-danger {
background: var(--danger);
color: var(--white);
}
.btn-warning {
background: var(--warning);
color: var(--white);
}
.grid {
display: grid;
gap: 12px;
}
.flex {
display: flex;
}
/* Секция выбора кормораздатчика */
.dispenser-section {
flex: 1;
padding: 20px;
background: var(--white);
overflow-y: auto;
}
.dispenser-section h1 {
font-size: 24px;
margin-bottom: 20px;
color: var(--gray-900);
text-align: center;
font-weight: 600;
}
.dispenser-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.dispenser-card {
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 12px;
padding: 28px 24px;
min-height: 148px;
cursor: pointer;
transition: all 0.2s;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
}
.dispenser-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dispenser-card:hover h3 {
color: var(--primary);
}
.dispenser-card h3 {
color: var(--gray-900);
margin: 0 0 14px;
font-size: 32px;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.5px;
overflow-wrap: anywhere;
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
flex: 1 1 auto;
}
.dispenser-info {
color: var(--gray-600);
font-size: 18px;
line-height: 1.45;
overflow-wrap: anywhere;
word-break: break-word;
flex-shrink: 0;
margin-top: auto;
}
.dispenser-info p {
margin: 0 0 8px;
font-size: 18px;
line-height: 1.45;
}
/* Секция периодов */
.periods-section {
display: none;
flex: 1;
padding: 20px;
background: var(--white);
overflow-y: auto;
}
.periods-section h1 {
font-size: 24px;
margin-bottom: 20px;
color: var(--gray-900);
text-align: center;
font-weight: 600;
}
/* Секция рейсов периода */
.period-recipes-section {
display: none;
flex: 1;
padding: 20px;
background: var(--white);
overflow-y: auto;
}
.period-recipes-section h1 {
font-size: 24px;
margin-bottom: 20px;
color: var(--gray-900);
text-align: center;
font-weight: 600;
}
/* Секция веса - главные изменения здесь */
.weight-section {
display: none;
flex: 1;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
background: var(--white);
padding: 20px;
position: relative;
width: 50%; /* Фиксированная ширина для каждой секции */
height: 100vh;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
}
.home-btn {
position: absolute;
top: 20px;
left: 20px;
padding: 8px 12px;
background: var(--primary);
color: var(--white);
text-decoration: none;
border-radius: 6px;
font-size: 14px;
z-index: 10;
}
.weight-section > .back-btn {
flex-shrink: 0;
align-self: flex-start;
margin: 0 0 12px 0;
}
.weight-content > .component-name {
flex-shrink: 0;
width: 100%;
max-width: none;
margin: 0;
padding: 10px 14px;
}
.weight-content {
margin-top: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
flex: 1 1 auto;
min-height: 0;
width: 100%;
text-align: center;
gap: 8px;
box-sizing: border-box;
}
.weight-main {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
min-height: min(58vh, 560px);
width: 100%;
gap: 6px;
overflow: hidden;
}
.weight-reset-wrap {
flex-shrink: 0;
width: 100%;
max-width: none;
margin: 0;
padding-top: 0;
}
.weight-reset-wrap .reset-component-btn {
width: 100%;
margin: 0;
min-height: 48px;
}
.component-name {
font-size: 28px;
font-weight: 600;
margin-bottom: 0px;
color: var(--gray-900);
text-transform: uppercase;
letter-spacing: 1px;
display: none;
background: var(--gray-50);
padding: 16px 20px;
border-radius: 8px;
border: none;
width: 100%;
max-width: 500px;
box-sizing: border-box;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.2;
max-height: 30vh;
overflow: hidden;
}
.component-name:empty {
display: none !important;
padding: 0;
border: none;
background: transparent;
}
.remaining-weight-label {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--gray-600);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0;
width: 100%;
}
.weight-display {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
width: 100%;
max-width: 100%;
min-height: 0;
margin: 0;
text-align: center;
box-sizing: border-box;
overflow: hidden;
container-type: size;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
flex: 1 1 0;
}
.weight-value-area {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
width: 100%;
height: 100%;
container-type: size;
}
.remaining-weight {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 0.1em;
font-size: min(56cqi, 88cqh, 420px);
font-weight: 700;
margin: 0;
color: var(--primary);
line-height: 0.82;
transition: color 0.3s;
width: 100%;
max-width: 100%;
white-space: nowrap;
}
.remaining-weight.negative-weight {
color: var(--danger);
}
.weight-unit {
font-size: min(12cqi, 18cqh, 80px);
color: var(--gray-600);
margin-left: 0;
font-weight: 500;
flex-shrink: 0;
}
.weight-info {
display: block;
flex-shrink: 0;
margin-top: auto;
font-size: 13px;
color: var(--gray-900);
background: var(--gray-50);
padding: 10px 14px;
border-radius: 8px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.weight-info-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: inherit;
}
.weight-info-table tbody tr:not(:last-child) {
border-bottom: 1px solid var(--gray-200);
}
.weight-info-table th {
text-align: left;
font-weight: 500;
color: var(--gray-600);
padding: 5px 8px 5px 0;
width: 58%;
vertical-align: middle;
line-height: 1.25;
overflow-wrap: break-word;
hyphens: auto;
}
.weight-info-table td {
text-align: right;
font-weight: 600;
color: var(--gray-900);
padding: 5px 0 5px 8px;
vertical-align: middle;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.weight-info-table .weight-num {
color: var(--primary);
}
.weight-info-table .weight-info-unit {
color: var(--gray-600);
font-weight: 500;
font-size: 0.88em;
margin-left: 4px;
}
.overload-info {
margin-top: 12px;
font-weight: 600;
color: var(--danger);
padding: 12px 20px;
background: rgba(239, 68, 68, 0.1);
border-radius: 6px;
width: 100%;
text-align: center;
font-size: 18px;
}
.reset-component-btn {
padding: 12px 24px;
background-color: var(--danger);
color: var(--white);
border-radius: 6px;
font-weight: 500;
font-size: 16px;
}
/* Секция рецептов */
.recipes-section {
display: none;
flex: 1;
flex-direction: column;
padding: 20px;
background: var(--white);
overflow: hidden;
min-width: 0;
min-height: 0;
height: 100vh;
box-sizing: border-box;
}
.section-title {
font-size: 24px;
margin-bottom: 20px;
color: var(--gray-900);
text-align: center;
font-weight: 600;
}
.recipes-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.recipe-card {
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: all 0.2s;
}
.recipe-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.recipe-card h3 {
font-size: 16px;
margin-bottom: 8px;
color: var(--gray-900);
font-weight: 600;
}
.recipe-card p {
font-size: 14px;
color: var(--gray-600);
line-height: 1.5;
}
/* Детали рецепта */
#recipeDetails {
display: none;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
margin-top: 0;
overflow: hidden;
}
#ingredientsTableContainer {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
width: 100%;
}
.recipe-action-bar {
flex-shrink: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
width: 100%;
padding-top: 12px;
align-items: stretch;
}
.recipe-action-bar .nav-btn {
margin: 0;
width: 100%;
min-height: 48px;
min-width: 0;
flex: none;
}
.recipe-action-bar .unload-btn {
grid-column: 1 / -1;
}
.recipe-title {
font-size: 24px;
margin-bottom: 12px;
flex-shrink: 0;
color: var(--gray-900);
text-align: center;
font-weight: 600;
width: 100%;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.25;
}
/* Таблица ингредиентов */
.ingredients-table {
width: 100%;
border-collapse: collapse;
}
.ingredients-table thead th {
position: sticky;
top: 0;
background: #fff;
z-index: 2;
}
.scrollable-tbody {
display: block;
max-height: none;
overflow-y: visible;
}
.ingredients-table thead,
.ingredients-table tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
.ingredients-table tbody {
width: 100%;
}
.ingredients-table th,
.ingredients-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--gray-200);
}
.ingredients-table th {
background: var(--gray-50);
font-weight: 600;
color: var(--gray-900);
}
.ingredients-table tbody tr {
transition: background-color 0.2s;
}
.ingredients-table tbody tr:hover {
background: var(--gray-50);
}
.nav-btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
transition: all 0.2s;
flex: 1 1 auto;
min-width: min(120px, 100%);
max-width: 100%;
white-space: normal;
word-break: break-word;
box-sizing: border-box;
}
.nav-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.prev-btn {
background: var(--primary);
color: white;
}
.next-btn {
background: var(--success);
color: white;
}
.unload-btn {
background: #dc3545 !important;
color: white !important;
font-weight: 600 !important;
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3) !important;
border: 2px solid #dc3545 !important;
}
.unload-btn:hover {
background: #c82333 !important;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4) !important;
transform: translateY(-1px);
}
/* Адаптивность для планшетов */
@media (max-width: 1100px) {
.nav-btn {
padding: 10px 20px;
font-size: 14px;
}
}
/* Таймер модальное окно */
.timer-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.timer-content {
background: var(--white);
padding: 40px;
border-radius: 16px;
text-align: center;
max-width: 400px;
width: 90%;
}
.timer-message {
font-size: 20px;
margin-bottom: 20px;
color: var(--gray-900);
font-weight: 500;
}
#timerValue {
font-size: 96px;
font-weight: 700;
color: var(--primary);
margin: 20px 0;
line-height: 1;
}
.timer-progress-container {
width: 100%;
height: 12px;
background: var(--gray-200);
border-radius: 6px;
margin: 30px 0;
overflow: hidden;
}
.timer-progress-bar {
height: 100%;
background: var(--danger);
width: 0%;
transition: width 0.5s ease;
border-radius: 6px;
}
.finish-mixing-btn {
padding: 16px 32px;
background: var(--success);
color: var(--white);
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 600;
transition: all 0.2s;
}
.finish-mixing-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.back-btn {
display: inline-block;
padding: 10px 20px;
background: var(--primary);
color: var(--white);
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.2s;
text-decoration: none;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.back-btn:hover {
background: var(--primary-light);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.back-btn:active {
transform: translateY(0);
}
/* Стили для активных и завершенных компонентов */
.active-component {
background-color: rgba(37, 99, 235, 0.1) !important;
}
.completed-component {
background-color: rgba(16, 185, 129, 0.1) !important;
}
.completed-component td:first-child::before {
content: "✓ ";
color: var(--success);
margin-right: 8px;
font-weight: bold;
}
/* Адаптивные стили */
@media (max-width: 768px) {
body {
flex-direction: column;
}
.weight-section,
.recipes-section {
width: 100%;
height: auto;
min-height: 50vh;
}
.dispenser-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.recipes-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.dispenser-card {
padding: 22px 20px;
min-height: 0;
}
.dispenser-card h3 {
font-size: 26px;
margin-bottom: 10px;
}
.dispenser-info,
.dispenser-info p {
font-size: 16px;
}
.nav-buttons {
flex-direction: column;
}
#timerValue {
font-size: 64px;
}
.component-name {
font-size: 20px;
padding: 16px 20px;
}
}
@media (min-width: 769px) {
body {
flex-direction: row;
}
.weight-section,
.recipes-section {
width: 50%;
height: 100vh;
overflow-y: auto;
}
.dispenser-section,
.periods-section,
.period-recipes-section {
width: 100%;
height: 100vh;
overflow-y: auto;
}
}
/* --- Адаптив для планшетов (768px - 1100px) --- */
@media (min-width: 768px) and (max-width: 1100px) {
.dispenser-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.recipes-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.dispenser-card {
padding: 24px 18px;
min-height: 132px;
}
.dispenser-card h3 {
font-size: 28px;
margin-bottom: 10px;
}
.dispenser-info,
.dispenser-info p {
font-size: 16px;
}
.dispenser-section h1,
.periods-section h1,
.period-recipes-section h1,
.section-title {
font-size: 24px;
margin-bottom: 16px;
}
.weight-section {
padding: 32px 16px;
}
.component-name {
font-size: 24px;
padding: 16px 20px;
}
.weight-info {
font-size: 22px;
padding: 28px;
}
.nav-buttons {
flex-direction: row;
flex-wrap: wrap;
}
.nav-btn {
font-size: 18px;
padding: 16px 24px;
}
}
/* Убираем декоративные элементы */
.decorative-corner {
display: none;
}
/* Стили для больших экранов */
@media (min-width: 1101px) {
.dispenser-grid {
gap: 24px;
}
.dispenser-card {
padding: 32px 28px;
min-height: 168px;
}
.dispenser-card h3 {
font-size: 36px;
margin-bottom: 14px;
}
.dispenser-info,
.dispenser-info p {
font-size: 20px;
}
}
/* Стили для очень маленьких экранов */
@media (max-width: 480px) {
.dispenser-card h3 {
font-size: 24px;
margin-bottom: 8px;
}
.dispenser-info,
.dispenser-info p {
font-size: 15px;
}
}
/* Стили для экранов с высокой плотностью пикселей (Retina) */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.dispenser-card h3 {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
}
/* Стили для полноэкранного режима секции веса */
.weight-section.fullscreen {
position: relative;
width: 50%;
height: 100vh;
z-index: 1;
margin: 0;
border-radius: 0;
}
/* Кнопка показа/скрытия рецепта */
.toggle-recipe-btn { display: none !important; }
/* Стили для кнопки в weight-section */
.weight-section .toggle-recipe-btn {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
/* Стили для кнопки в recipeDetails */
#recipeDetails .toggle-recipe-btn {
position: static;
margin: 0 0 20px auto;
}
/* Адаптивные стили для кнопки */
@media (max-width: 768px) {
.toggle-recipe-btn {
padding: 8px 12px;
font-size: 12px;
}
.weight-section .toggle-recipe-btn {
top: 10px;
right: 10px;
}
}
/* Стили для навигационных кнопок в полноэкранном режиме */
.weight-section.fullscreen .nav-buttons-overlay {
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12px;
z-index: 10;
}
.nav-buttons-overlay .nav-btn {
padding: 12px 24px;
font-size: 16px;
min-width: 150px;
}
/* Кнопка скрытия рецепта в секции рецептов */
.hide-recipe-btn {
position: absolute;
top: 20px;
right: 20px;
padding: 8px 16px;
background: var(--warning);
color: var(--white);
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
z-index: 10;
}
.hide-recipe-btn:hover {
opacity: 0.9;
}
/* --- Адаптивные стили для блока веса и кнопок --- */
@media (max-width: 767px) {
.weight-section {
padding: 10px 2vw;
max-width: 100vw;
min-width: 0;
}
.weight-content {
padding: 0;
}
.recipe-action-bar {
grid-template-columns: 1fr 1fr;
}
.weight-display {
max-width: 100%;
}
.weight-info {
font-size: 12px;
padding: 8px 12px;
max-width: 100%;
}
.reset-component-btn,
.nav-btn,
.finish-mixing-btn {
font-size: 18px;
padding: 18px 0;
width: 100%;
min-height: 56px;
box-sizing: border-box;
}
}
@media (min-width: 768px) and (max-width: 1100px) {
.weight-section {
padding: 24px 2vw;
max-width: 98vw;
min-width: 0;
}
.weight-content {
padding: 0;
}
.weight-display {
max-width: 100%;
}
.weight-info {
font-size: 13px;
padding: 10px 14px;
max-width: 100%;
}
.reset-component-btn,
.nav-btn,
.finish-mixing-btn {
font-size: 20px;
padding: 16px 0;
width: 95%;
min-height: 48px;
box-sizing: border-box;
}
}
@media (min-width: 1101px) {
.weight-section {
padding: 16px 20px;
max-width: 600px;
min-width: 0;
}
.weight-content {
padding: 0;
}
.weight-display {
max-width: 100%;
}
.weight-info {
font-size: 14px;
padding: 10px 14px;
max-width: 100%;
}
.reset-component-btn,
.nav-btn,
.finish-mixing-btn {
font-size: 22px;
padding: 14px 0;
width: 80%;
min-height: 44px;
box-sizing: border-box;
}
}
/* --- Тема: синхронизация с localStorage.theme (как на других киоск-страницах), без новых кнопок --- */
html[data-theme="light"] body {
background: #f9fafb !important;
color: #111827 !important;
}
html[data-theme="dark"] body {
background: #1a1a1a !important;
color: #e6e6e6 !important;
}
html[data-theme="dark"] .card,
html[data-theme="dark"] .dispenser-section,
html[data-theme="dark"] .periods-section,
html[data-theme="dark"] .period-recipes-section,
html[data-theme="dark"] .weight-section,
html[data-theme="dark"] .recipes-section {
background: #242424 !important;
color: #e6e6e6 !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35) !important;
}
html[data-theme="dark"] .dispenser-section h1,
html[data-theme="dark"] .periods-section h1,
html[data-theme="dark"] .period-recipes-section h1,
html[data-theme="dark"] .section-title,
html[data-theme="dark"] .recipe-title {
color: #f3f4f6 !important;
}
html[data-theme="dark"] .dispenser-card {
background: #2d2d2d !important;
border-color: #4b5563 !important;
color: #e6e6e6 !important;
}
html[data-theme="dark"] .dispenser-card h3 {
color: #f3f4f6 !important;
}
html[data-theme="dark"] .dispenser-info {
color: #9ca3af !important;
}
html[data-theme="dark"] .recipe-card {
background: #2d2d2d !important;
border-color: #4b5563 !important;
color: #e6e6e6 !important;
}
html[data-theme="dark"] .recipe-card h3 {
color: #f3f4f6 !important;
}
html[data-theme="dark"] .recipe-card p {
color: #9ca3af !important;
}
html[data-theme="dark"] .component-name {
background: #1f1f1f !important;
color: #f3f4f6 !important;
border: none !important;
}
html[data-theme="dark"] .component-name:empty {
display: none !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
}
html[data-theme="dark"] .remaining-weight {
color: #60a5fa !important;
}
html[data-theme="dark"] .remaining-weight.negative-weight {
color: #f87171 !important;
}
html[data-theme="dark"] .weight-unit {
color: #9ca3af !important;
}
html[data-theme="dark"] .weight-info {
background: #1f1f1f !important;
color: #e5e7eb !important;
}
html[data-theme="dark"] .weight-info-table tbody tr:not(:last-child) {
border-bottom-color: #3a3a3a !important;
}
html[data-theme="dark"] .weight-info-table th {
color: #9ca3af !important;
}
html[data-theme="dark"] .weight-info-table td {
color: #f3f4f6 !important;
}
html[data-theme="dark"] .weight-info-table .weight-num {
color: #60a5fa !important;
}
html[data-theme="dark"] .weight-info-table .weight-info-unit {
color: #9ca3af !important;
}
html[data-theme="dark"] .remaining-weight-label {
color: #9ca3af !important;
}
html[data-theme="dark"] .overload-info {
background: rgba(239, 68, 68, 0.2) !important;
color: #fca5a5 !important;
}
html[data-theme="dark"] .ingredients-table thead th {
background: #1f1f1f !important;
color: #f3f4f6 !important;
border-bottom-color: #3a3a3a !important;
}
html[data-theme="dark"] .ingredients-table th,
html[data-theme="dark"] .ingredients-table td {
border-bottom-color: #3a3a3a !important;
color: #e5e7eb !important;
}
html[data-theme="dark"] .ingredients-table tbody tr:hover {
background: #2d2d2d !important;
}
html[data-theme="dark"] .active-component {
background-color: rgba(59, 130, 246, 0.22) !important;
}
html[data-theme="dark"] .completed-component {
background-color: rgba(16, 185, 129, 0.22) !important;
}
html[data-theme="dark"] .timer-content {
background: #242424 !important;
color: #f3f4f6 !important;
}
html[data-theme="dark"] .timer-message {
color: #e5e7eb !important;
}
html[data-theme="dark"] .timer-progress-container {
background: #374151 !important;
}
html[data-theme="dark"] #timerValue {
color: #60a5fa !important;
}
</style>
</head>
<body>
<!-- Секция выбора кормораздатчика -->
<div class="dispenser-section card" id="dispenserSection">
<div class="decorative-corner"></div>
<button class="back-btn btn" id="backToMain">← Назад</button>
<h1>Выберите оборудование</h1>
<div class="dispenser-grid grid" id="dispensersList">
<!-- Кормораздатчики будут загружены здесь -->
</div>
</div>
<!-- Секция периодов -->
<div class="periods-section card" id="periodsSection">
<div class="decorative-corner"></div>
<button class="back-btn btn" id="backToDispensers">← Назад</button>
<h1>Периоды кормления</h1>
<div class="dispenser-grid grid" id="periodsList">
<!-- Периоды будут загружены здесь -->
</div>
</div>
<!-- Секция рейсов периода -->
<div class="period-recipes-section card" id="periodRecipesSection" style="display: none;">
<div class="decorative-corner"></div>
<button class="back-btn btn" id="backToPeriods">← Назад</button>
<h1 id="periodRecipesTitle">Рейсы</h1>
<div class="dispenser-grid grid" id="periodRecipesList">
<!-- Рейсы периода будут загружены здесь -->
</div>
</div>
<!-- Секция веса -->
<div class="weight-section card">
<div class="weight-content">
<div class="component-name" id="componentName"></div>
<div class="weight-main">
<p class="remaining-weight-label" id="remainingWeightLabel">Осталось загрузить</p>
<div class="weight-display">
<div class="weight-value-area">
<div class="remaining-weight" id="remainingWeight">0<span class="weight-unit"> кг</span></div>
</div>
</div>
</div>
<div class="weight-info">
<table class="weight-info-table" aria-label="Показатели веса загрузки">
<tbody>
<tr>
<th scope="row">Загружено</th>
<td><span id="currentLoaded" class="weight-num">0</span><span class="weight-info-unit"> кг</span></td>
</tr>
<tr>
<th scope="row">Задано</th>
<td><span id="totalComponent" class="weight-num">0</span><span class="weight-info-unit"> кг</span></td>
</tr>
<tr>
<th scope="row">В миксере всего</th>
<td><span id="totalMixtureWeight" class="weight-num">0</span><span class="weight-info-unit"> кг</span></td>
</tr>
</tbody>
</table>
</div>
<div class="weight-reset-wrap">
<button class="reset-component-btn btn" id="resetComponentBtn" type="button" style="display: none;">
Обнулить текущий компонент
</button>
</div>
<!-- Навигация в полноэкранном режиме веса -->
<div class="nav-buttons-overlay" id="navButtonsOverlay" style="display: none;">
<button class="nav-btn prev-btn btn" id="prevBtnOverlay" type="button">Предыдущий</button>
<button class="nav-btn next-btn btn" id="nextBtnOverlay" type="button">Следующий</button>
<button class="nav-btn unload-btn btn" id="unloadBtnOverlay" type="button">Смешивание</button>
</div>
</div>
</div>
<!-- Секция рецептов -->
<div class="recipes-section card">
<div class="decorative-corner"></div>
<h1 class="section-title">Выберите рецепт</h1>
<div class="recipes-grid grid" id="recipesList">
<!-- Кнопки рецептов будут загружены через JavaScript -->
</div>
<div id="recipeDetails">
<h2 class="recipe-title" id="recipeName"></h2>
<div id="ingredientsTableContainer"></div>
<div class="recipe-action-bar" id="recipeActionBar">
<button class="nav-btn prev-btn btn" id="prevBtn" type="button" style="display: none;">Предыдущий</button>
<button class="nav-btn next-btn btn" id="nextBtn" type="button" style="display: none;">Следующий</button>
<button class="nav-btn unload-btn btn" id="unloadBtn" type="button" style="display: none;">Смешивание</button>
</div>
</div>
</div>
<!-- Модальное окно таймера -->
<div class="timer-modal" id="timerModal">
<div class="timer-content card">
<div class="timer-message">Идет процесс смешивания</div>
<div id="timerValue">00:00</div>
<div class="timer-progress-container">
<div class="timer-progress-bar" id="timerProgressBar"></div>
</div>
<button class="finish-mixing-btn btn" id="finishMixingBtn">
Завершить смешивание
</button>
</div>
</div>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script src="/static/js/kiosk-dialog.js"></script>
<script type="module">
import { AppState } from '/static/js/modules/app-state.js';
import { CrossTabSync } from '/static/js/modules/cross-tab-sync.js';
import { ReconnectingEventSource } from '/static/js/modules/reconnecting-event-source.js';
import {
buildDispenserGridSkeletonHtml,
buildPeriodRecipeGridSkeletonHtml,
buildGridSkeletonCards,
buildRecipeIngredientsSkeletonHtml,
mountKioskSkeleton,
replaceKioskContent,
clearStaleKioskSkeleton,
} from '/static/js/modules/kiosk/kiosk-skeleton.js';
let dispenserSelectToken = 0;
let periodSelectToken = 0;
let recipeDetailToken = 0;
const appState = new AppState({
currentDispenser: null,
currentPeriod: null,
currentRecipe: null,
currentWeight: 0
});
const crossTab = new CrossTabSync('recipes_selection_channel');
const KIOSK_API_HEADERS = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Wesp-Kiosk': '1',
};
function kioskFetch(url, options = {}) {
const headers = { ...KIOSK_API_HEADERS, ...(options.headers || {}) };
return fetch(url, { ...options, headers });
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function visibleKioskRecipes(recipes) {
return (recipes || []).filter((recipe) => !recipe.skippedToday);
}
// Глобальные переменные
let currentDispenser = null;
let currentPeriod = null;
let currentRecipe = null;
let currentComponentIndex = 0;
let currentWeight = 0;
let componentWeights = [];
let componentNames = [];
let weightCheckInterval = null;
let totalLoadedWeight = 0;
let previousComponentsWeight = 0;
let completedComponents = [];
let componentLoadingTimes = [];
let currentComponentStartTime = null;
// Переменные для таймера
let mixingTime = 300; // Значение по умолчанию
let timerSecondsLeft = 0;
let timerInterval;
// Добавляем массив для хранения фактических весов компонентов
let actualComponentWeights = [];
// Добавляем переменную для хранения начальной точки отсчета текущего компонента
let componentStartWeight = 0;
// Функция для округления веса до кратного 5 кг
function roundToStep5(value) {
return Math.round(value / 5) * 5;
}
/** SSE /stream_weight: data может быть JSON {"weight": n} или строкой числа. */
function parseWeightFromSsePayload(data) {
if (data == null || data === '') return 0;
const raw = String(data).trim();
try {
const obj = JSON.parse(raw);
if (obj != null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, 'weight')) {
const w = obj.weight;
const n = typeof w === 'number' ? w : parseFloat(w);
return Number.isFinite(n) ? n : 0;
}
} catch (_) {
/* не JSON */
}
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}
function setRemainingWeightDisplay(el, value) {
if (!el) return;
const n = value == null ? '' : String(value);
el.innerHTML = `${n}<span class="weight-unit"> кг</span>`;
scheduleLayoutFit();
}
function syncComponentNameElement(text) {
const el = document.getElementById('componentName');
if (!el) return;
el.style.fontSize = '';
const t = (text == null ? '' : String(text)).trim();
if (!t) {
el.textContent = '';
el.style.display = 'none';
scheduleLayoutFit();
return;
}
el.textContent = t;
el.style.display = 'block';
scheduleLayoutFit();
}
let layoutFitScheduled = false;
function scheduleLayoutFit() {
if (layoutFitScheduled) return;
layoutFitScheduled = true;
requestAnimationFrame(() => {
layoutFitScheduled = false;
applyLayoutTextFit();
});
}
function elementIsVisible(el) {
if (!el) return false;
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function shrinkFontToFit(el, minPx, maxPx) {
if (!el || !el.isConnected) return;
let max = maxPx;
if (!Number.isFinite(max)) {
const p = parseFloat(getComputedStyle(el).fontSize);
max = Number.isFinite(p) ? p : 16;
}
let min = minPx;
if (!Number.isFinite(min)) min = 8;
const maxHeight = getComputedStyle(el).maxHeight;
const hasHeightLimit = maxHeight && maxHeight !== 'none' && maxHeight !== '0px';
let size = max;
el.style.fontSize = `${size}px`;
let guard = 0;
while (size > min && guard < 160) {
const wOk = el.scrollWidth <= el.clientWidth + 1;
const hOk = !hasHeightLimit || el.scrollHeight <= el.clientHeight + 1;
if (wOk && hOk) break;
size -= 0.5;
el.style.fontSize = `${size}px`;
guard++;
}
}
function applyRemainingWeightFontSize(sizePx) {
const rw = document.getElementById('remainingWeight');
if (!rw) return;
const unit = rw.querySelector('.weight-unit');
rw.style.fontSize = `${sizePx}px`;
if (unit) unit.style.fontSize = `${Math.max(12, sizePx * 0.32)}px`;
}
function remainingWeightFitsArea(sizePx, maxWidth, maxHeight) {
applyRemainingWeightFontSize(sizePx);
const rw = document.getElementById('remainingWeight');
if (!rw) return true;
return rw.scrollWidth <= maxWidth && rw.scrollHeight <= maxHeight;
}
function fitRemainingWeightToContainer() {
const rw = document.getElementById('remainingWeight');
const valueArea = rw && rw.closest('.weight-value-area');
if (!rw || !valueArea || rw.style.display === 'none' || !elementIsVisible(rw)) return;
const maxWidth = Math.max(48, valueArea.clientWidth);
const maxHeight = Math.max(48, valueArea.clientHeight);
const minMain = 22;
const maxMain = Math.max(
160,
Math.min(420, Math.floor(Math.min(maxWidth * 0.78, maxHeight * 0.96))),
);
rw.style.fontSize = '';
const unit = rw.querySelector('.weight-unit');
if (unit) unit.style.fontSize = '';
rw.style.whiteSpace = 'nowrap';
let lo = minMain;
let hi = maxMain;
let best = minMain;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (remainingWeightFitsArea(mid, maxWidth, maxHeight)) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
applyRemainingWeightFontSize(best);
}
function applyLayoutTextFit() {
document.querySelectorAll('.dispenser-card h3, .dispenser-card .dispenser-info').forEach((el) => {
el.style.fontSize = '';
});
const cn = document.getElementById('componentName');
if (cn && cn.textContent.trim() && cn.style.display !== 'none') {
cn.style.fontSize = '';
const m = parseFloat(getComputedStyle(cn).fontSize) || 28;
shrinkFontToFit(cn, 11, m);
}
document.querySelectorAll('.nav-btn, .back-btn, .reset-component-btn, .finish-mixing-btn').forEach((btn) => {
if (!elementIsVisible(btn)) return;
btn.style.fontSize = '';
const m = parseFloat(getComputedStyle(btn).fontSize) || 16;
shrinkFontToFit(btn, 9, m);
});
const rt = document.getElementById('recipeName');
if (rt && rt.textContent.trim() && elementIsVisible(rt)) {
rt.style.fontSize = '';
const m = parseFloat(getComputedStyle(rt).fontSize) || 24;
shrinkFontToFit(rt, 12, m);
}
const st = document.querySelector('.section-title');
if (st && st.textContent.trim() && elementIsVisible(st) && st.style.display !== 'none') {
st.style.fontSize = '';
const m = parseFloat(getComputedStyle(st).fontSize) || 24;
shrinkFontToFit(st, 12, m);
}
fitRemainingWeightToContainer();
}
let mixingStartTime = null;
let actualMixingDuration = 0;
// Звук выгрузки: циклическое воспроизведение при ~95% веса и при окончании таймера смешивания
let unloadingSound = null;
let unloadingSoundPlaying = false;
let isMixingModalOpen = false;
let unloadingSoundReason = null; // 'weight' | 'timer' | null
function startUnloadingSoundLoop(reason) {
unloadingSoundReason = reason || unloadingSoundReason || 'weight';
if (unloadingSoundPlaying) return;
if (!unloadingSound) {
unloadingSound = new Audio('/sounds/unloading.mp3');
unloadingSound.loop = true;
}
unloadingSound.play().then(() => { unloadingSoundPlaying = true; }).catch(e => console.warn('Unloading sound play failed:', e));
}
function stopUnloadingSoundLoop() {
if (!unloadingSound) return;
unloadingSound.pause();
unloadingSound.currentTime = 0;
unloadingSoundPlaying = false;
unloadingSoundReason = null;
}
let eventSource = null;
let syncTimer = null;
let navigationCommandsInterval = null;
document.addEventListener('DOMContentLoaded', function() {
// Загрузка кормораздатчиков
loadDispensers();
setupDelegatedUiEvents();
// Настройка SSE для весов
// --- BroadcastChannel и мгновенная синхронизация ---
eventSource = new ReconnectingEventSource('/stream_weight');
eventSource.onmessage = e => {
// Сервер шлёт JSON: {"weight": 12.3}; раньше parseFloat ломал вес (всегда 0).
currentWeight = parseWeightFromSsePayload(e.data);
window.currentWeight = currentWeight;
appState.set({ currentWeight });
updateWeightDisplay();
// Отправляем обновление веса сразу же
crossTab.post('weight', { weight: currentWeight });
// Периодически синхронизируем состояние с сервером (каждые 2 секунды)
if (!syncTimer) {
syncTimer = setInterval(() => {
if (currentRecipe && currentRecipe.id) {
syncStateWithServer();
}
}, 2000);
}
};
// Обработчики кнопок навигации
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const unloadBtn = document.getElementById('unloadBtn');
if (prevBtn) prevBtn.addEventListener('click', prevComponent);
if (nextBtn) nextBtn.addEventListener('click', nextComponent);
if (unloadBtn) unloadBtn.addEventListener('click', goToUnload);
// Добавляем обработчик для кнопки завершения смешивания
document.getElementById('finishMixingBtn').addEventListener('click', function() {
console.log('Finish mixing button clicked');
clearInterval(timerInterval);
isMixingModalOpen = false;
stopUnloadingSoundLoop();
// Сбрасываем флаг активности таймера
fetch('/api/set_mixing_timer', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ active: false })
}).catch(err => console.error('Ошибка сброса флага таймера:', err));
// Сохраняем фактическое время смешивания
const endTime = new Date();
actualMixingDuration = Math.round((endTime - mixingStartTime) / 1000);
localStorage.setItem('actualMixingTime', actualMixingDuration.toString());
console.log('Saved actual mixing time on manual finish:', actualMixingDuration);
window.location.href = `/unloading?recipe_id=${currentRecipe.id}&total_weight=${currentWeight}`;
});
// Добавляем обработчик для кнопки сброса
document.getElementById('resetComponentBtn').addEventListener('click', resetCurrentComponent);
// Добавляем обработчики для кнопок "Назад"
document.getElementById('backToMain').addEventListener('click', function() {
window.location.href = '/scales';
});
document.getElementById('backToDispensers').addEventListener('click', function() {
dispenserSelectToken += 1;
periodSelectToken += 1;
clearStaleKioskSkeleton(document.getElementById('periodsList'));
clearStaleKioskSkeleton(document.getElementById('periodRecipesList'));
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('dispenserSection').style.display = 'block';
});
document.getElementById('backToPeriods').addEventListener('click', function() {
const weightSection = document.querySelector('.weight-section');
const recipeWorkspaceOpen =
weightSection &&
weightSection.style.display !== 'none' &&
document.getElementById('recipeDetails').style.display !== 'none';
if (recipeWorkspaceOpen) {
restoreToTripListView();
return;
}
periodSelectToken += 1;
recipeDetailToken += 1;
clearStaleKioskSkeleton(document.getElementById('periodRecipesList'));
// Если это кормоцех (нет периода), возвращаемся к выбору кормораздатчика
if (currentPeriod && currentPeriod.id === null) {
document.getElementById('periodRecipesSection').style.display = 'none';
document.getElementById('dispenserSection').style.display = 'block';
} else {
// Иначе возвращаемся к периодам
document.getElementById('periodRecipesSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'block';
}
scheduleLayoutFit();
});
let resizeLayoutDebounce;
window.addEventListener('resize', () => {
clearTimeout(resizeLayoutDebounce);
resizeLayoutDebounce = setTimeout(scheduleLayoutFit, 120);
});
scheduleLayoutFit();
});
function setupDelegatedUiEvents() {
const dispensersList = document.getElementById('dispensersList');
const periodsList = document.getElementById('periodsList');
const periodRecipesList = document.getElementById('periodRecipesList');
if (dispensersList) {
dispensersList.addEventListener('click', (event) => {
const card = event.target.closest('.dispenser-card[data-dispenser-id]');
if (!card) return;
selectDispenser(card.dataset.dispenserId);
});
}
if (periodsList) {
periodsList.addEventListener('click', (event) => {
const card = event.target.closest('.dispenser-card[data-period-id]');
if (!card) return;
selectPeriod(card.dataset.periodId);
});
}
if (periodRecipesList) {
periodRecipesList.addEventListener('click', (event) => {
const card = event.target.closest('.dispenser-card[data-recipe-id]');
if (!card) return;
showRecipeDetails(card.dataset.recipeId);
});
}
}
async function loadDispensers() {
const container = document.getElementById('dispensersList');
const startedAt = mountKioskSkeleton(
container,
buildDispenserGridSkeletonHtml(4),
'Загрузка оборудования'
);
try {
const response = await fetch('/api/feed_dispensers', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const dispensers = await response.json();
await renderDispensers(dispensers, startedAt);
} catch (error) {
console.error('Ошибка при загрузке кормораздатчиков:', error);
clearStaleKioskSkeleton(container);
await window.WespKioskDialog.alert('Не удалось загрузить список кормораздатчиков', { variant: 'danger' });
}
}
function buildDispensersHtml(dispensers) {
return dispensers.map(dispenser => `
<div class="dispenser-card" data-dispenser-id="${dispenser.id}">
<h3>${dispenser.name}</h3>
<div class="dispenser-info">
<p>Ферма: ${dispenser.farm || 'Не указана'}</p>
<p>Оператор: ${dispenser.operator}</p>
</div>
</div>
`).join('');
}
async function renderDispensers(dispensers, startedAt) {
const container = document.getElementById('dispensersList');
await replaceKioskContent(container, buildDispensersHtml(dispensers), { startedAt });
scheduleLayoutFit();
}
async function selectDispenser(dispenserId) {
const token = ++dispenserSelectToken;
const periodsContainer = document.getElementById('periodsList');
document.getElementById('dispenserSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'block';
document.getElementById('periodRecipesSection').style.display = 'none';
let startedAt = mountKioskSkeleton(
periodsContainer,
buildPeriodRecipeGridSkeletonHtml(4),
'Загрузка периодов'
);
try {
console.log('Выбор кормораздатчика:', dispenserId);
const response = await kioskFetch(`/api/feed_dispensers/${dispenserId}`, {
method: 'GET',
});
if (!response.ok) {
const errorText = await response.text();
console.error('Ошибка ответа:', response.status, errorText);
throw new Error(`Ошибка загрузки кормораздатчика: ${response.status}`);
}
const dispenser = await response.json();
if (token !== dispenserSelectToken) {
clearStaleKioskSkeleton(periodsContainer);
return;
}
console.log('Получены данные кормораздатчика:', dispenser);
localStorage.setItem('selectedDispenserId', dispenserId);
const equipmentType = dispenser.type || 'dispenser';
localStorage.setItem('equipmentType', equipmentType);
if (equipmentType === 'mill' || equipmentType === 'кормоцех') {
console.log('Обнаружен кормоцех, загружаем рецепты напрямую');
clearStaleKioskSkeleton(periodsContainer);
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'block';
const recipesContainer = document.getElementById('periodRecipesList');
startedAt = mountKioskSkeleton(
recipesContainer,
buildPeriodRecipeGridSkeletonHtml(4),
'Загрузка рецептов'
);
const recipesResponse = await kioskFetch(`/api/feed_dispensers/${dispenserId}/recipes`, {
method: 'GET',
});
if (!recipesResponse.ok) {
throw new Error(`HTTP error! status: ${recipesResponse.status}`);
}
const recipes = visibleKioskRecipes(await recipesResponse.json());
if (token !== dispenserSelectToken) {
clearStaleKioskSkeleton(recipesContainer);
return;
}
console.log('Получены рецепты кормоцеха:', recipes);
currentPeriod = { id: null, recipes: recipes };
appState.set({ currentPeriod });
await renderPeriodRecipes(recipes, null, startedAt);
document.getElementById('dispenserSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'block';
} else {
await renderPeriods(dispenser.periods, startedAt);
document.getElementById('dispenserSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'block';
}
} catch (error) {
console.error('Ошибка при выборе кормораздатчика:', error);
if (token === dispenserSelectToken) {
clearStaleKioskSkeleton(periodsContainer);
clearStaleKioskSkeleton(document.getElementById('periodRecipesList'));
}
await window.WespKioskDialog.alert('Ошибка при загрузке кормораздатчика. Пожалуйста, попробуйте еще раз.', { variant: 'danger' });
}
}
function buildPeriodsHtml(periods) {
return periods.map(period => `
<div class="dispenser-card" data-period-id="${period.id}">
<h3>${period.name}</h3>
<div class="dispenser-info">
<p>Рецептов: ${period.recipes ? period.recipes.length : 0}</p>
</div>
</div>
`).join('');
}
async function renderPeriods(periods, startedAt) {
const container = document.getElementById('periodsList');
await replaceKioskContent(container, buildPeriodsHtml(periods), { startedAt });
scheduleLayoutFit();
}
async function selectPeriod(periodId) {
const token = ++periodSelectToken;
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'block';
const container = document.getElementById('periodRecipesList');
const startedAt = mountKioskSkeleton(
container,
buildPeriodRecipeGridSkeletonHtml(4),
'Загрузка рейсов'
);
try {
console.log('Выбран период:', periodId);
const response = await kioskFetch(`/api/periods/${periodId}/recipes`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const recipes = visibleKioskRecipes(await response.json());
if (token !== periodSelectToken) {
clearStaleKioskSkeleton(container);
return;
}
console.log('Получены рецепты периода:', recipes);
currentPeriod = { id: periodId, recipes: recipes };
appState.set({ currentPeriod });
await renderPeriodRecipes(recipes, periodId, startedAt);
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'block';
} catch (error) {
console.error('Ошибка при выборе периода:', error);
if (token === periodSelectToken) {
clearStaleKioskSkeleton(container);
}
await window.WespKioskDialog.alert('Ошибка при загрузке рецептов периода. Пожалуйста, попробуйте еще раз.', { variant: 'danger' });
}
}
function buildPeriodRecipesHtml(recipes, periodId) {
if (recipes.length === 0) {
if (periodId === null) {
return '<p>Нет доступных рецептов для кормоцеха</p>';
}
return '<p>В этом периоде нет рецептов</p>';
}
return recipes.map(recipe => `
<div class="dispenser-card" data-recipe-id="${recipe.id}">
<h3>${recipe.name}</h3>
<div class="dispenser-info">
<p>Общий вес: ${roundToStep5(recipe.total_weight || 0)} кг</p>
<p>Время смешивания: ${recipe.mixing_time || 0} мин</p>
</div>
</div>
`).join('');
}
async function renderPeriodRecipes(recipes, periodId, startedAt) {
const container = document.getElementById('periodRecipesList');
const titleElement = document.getElementById('periodRecipesTitle');
const backButton = document.getElementById('backToPeriods');
if (periodId === null) {
titleElement.textContent = `Рецепты`;
backButton.textContent = '← Назад';
} else {
titleElement.textContent = `Рейсы`;
backButton.textContent = '← Назад';
}
const html = buildPeriodRecipesHtml(recipes, periodId);
if (startedAt != null) {
await replaceKioskContent(container, html, { startedAt });
} else {
container.innerHTML = html;
}
scheduleLayoutFit();
}
function mountTripBackButtonInWeightSection() {
const btn = document.getElementById('backToPeriods');
const weightSection = document.querySelector('.weight-section');
const anchor = weightSection && weightSection.querySelector('.weight-content');
if (!btn || !weightSection || !anchor || btn.parentElement === weightSection) {
return;
}
weightSection.insertBefore(btn, anchor);
btn.style.display = 'inline-block';
btn.style.fontSize = '';
}
function restoreTripBackButtonToPeriodSection() {
const btn = document.getElementById('backToPeriods');
const section = document.getElementById('periodRecipesSection');
const title = document.getElementById('periodRecipesTitle');
if (!btn || !section || !title || btn.parentElement === section) {
return;
}
section.insertBefore(btn, title);
btn.style.display = 'inline-block';
btn.style.fontSize = '';
}
function openRecipeWorkspaceLoading() {
document.getElementById('dispenserSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'none';
const weightSection = document.querySelector('.weight-section');
const recipesSection = document.querySelector('.recipes-section');
weightSection.style.display = 'flex';
recipesSection.style.display = 'flex';
mountTripBackButtonInWeightSection();
document.querySelector('.section-title').style.display = 'none';
document.getElementById('recipesList').style.display = 'none';
document.getElementById('recipeDetails').style.display = 'flex';
document.getElementById('recipeName').textContent = 'Загрузка рецепта…';
document.getElementById('remainingWeight').style.display = 'block';
document.querySelector('.weight-info').style.display = 'block';
syncComponentNameElement('');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const unloadBtn = document.getElementById('unloadBtn');
if (prevBtn) prevBtn.style.display = 'none';
if (nextBtn) nextBtn.style.display = 'none';
if (unloadBtn) unloadBtn.style.display = 'none';
}
async function restoreToTripListView() {
recipeDetailToken += 1;
clearStaleKioskSkeleton(document.getElementById('ingredientsTableContainer'));
backToRecipesList();
document.getElementById('dispenserSection').style.display = 'none';
document.getElementById('periodsSection').style.display = 'none';
document.getElementById('periodRecipesSection').style.display = 'block';
const weightSection = document.querySelector('.weight-section');
const recipesSection = document.querySelector('.recipes-section');
weightSection.style.display = 'none';
recipesSection.style.display = 'none';
restoreTripBackButtonToPeriodSection();
const listContainer = document.getElementById('periodRecipesList');
if (!listContainer.querySelector('.dispenser-card[data-recipe-id]') && currentPeriod?.recipes?.length) {
await renderPeriodRecipes(currentPeriod.recipes, currentPeriod.id, null);
}
scheduleLayoutFit();
}
async function showRecipeDetails(recipeId) {
const token = ++recipeDetailToken;
openRecipeWorkspaceLoading();
const ingredientsContainer = document.getElementById('ingredientsTableContainer');
const startedAt = mountKioskSkeleton(
ingredientsContainer,
buildRecipeIngredientsSkeletonHtml(6),
'Загрузка рецепта'
);
try {
const response = await fetch(
`/api/recipes/${recipeId}?date=${encodeURIComponent(todayIso())}`,
{
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Wesp-Kiosk': '1',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const recipe = await response.json();
if (token !== recipeDetailToken) {
clearStaleKioskSkeleton(ingredientsContainer);
return;
}
currentRecipe = recipe;
window.currentRecipe = currentRecipe;
appState.set({ currentRecipe });
try {
await fetch('/api/set_current_recipe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipe_id: recipe.id
})
});
console.log('Рецепт успешно установлен на сервере для дублера');
} catch (error) {
console.error('Ошибка при установке рецепта на сервере:', error);
}
mixingTime = parseInt(recipe.mixing_time || recipe.mixingTime || 5) * 60;
console.log('Mixing time (seconds):', mixingTime);
localStorage.setItem('targetMixingTime', mixingTime.toString());
await replaceKioskContent(ingredientsContainer, '', { startedAt, minMs: 0 });
document.getElementById('recipeName').textContent = recipe.name;
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const unloadBtn = document.getElementById('unloadBtn');
if (prevBtn) prevBtn.style.display = 'block';
if (nextBtn) nextBtn.style.display = 'block';
if (unloadBtn) unloadBtn.style.display = 'none';
displayIngredientsTable();
startRecipe();
crossTab.post('recipe', { recipe: currentRecipe });
} catch (error) {
console.error('Ошибка при загрузке рецепта:', error);
if (token === recipeDetailToken) {
await restoreToTripListView();
await window.WespKioskDialog.alert(
'Ошибка при загрузке рецепта. Пожалуйста, попробуйте еще раз.',
{ variant: 'danger' }
);
}
}
}
function updateNavigationButtons() {
console.log('updateNavigationButtons вызван', {
currentComponentIndex,
totalComponents: componentNames.length
});
// Получаем кнопки
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const unloadBtn = document.getElementById('unloadBtn');
if (!prevBtn || !nextBtn || !unloadBtn) {
console.error('Не найдены необходимые кнопки навигации');
return;
}
// Показываем все кнопки навигации
prevBtn.style.display = 'block';
nextBtn.style.display = 'block';
unloadBtn.style.display = 'none';
// Проверяем возможность навигации
const isFirstComponent = currentComponentIndex <= 0;
const isLastComponent = currentComponentIndex >= componentNames.length - 1;
console.log('Состояние навигации:', {
isFirstComponent,
isLastComponent,
currentIndex: currentComponentIndex
});
// Управляем состоянием кнопок
prevBtn.disabled = isFirstComponent;
prevBtn.style.opacity = isFirstComponent ? '0.5' : '1';
nextBtn.disabled = isLastComponent;
nextBtn.style.opacity = isLastComponent ? '0.5' : '1';
// Если текущий компонент последний, показываем кнопку смешивания
if (isLastComponent) {
nextBtn.style.display = 'none';
unloadBtn.style.display = 'block';
unloadBtn.textContent = 'Смешивание';
unloadBtn.style.backgroundColor = '#dc3545';
unloadBtn.style.color = '#ffffff';
unloadBtn.style.fontWeight = '600';
unloadBtn.style.boxShadow = '0 2px 8px rgba(220, 53, 69, 0.3)';
}
scheduleLayoutFit();
}
// Функция синхронизации состояния с сервером (асинхронная, не блокирует UI)
function syncStateWithServer() {
if (!currentRecipe) return;
// Асинхронная синхронизация без блокировки интерфейса
fetch('/api/sync_loading_state', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipe_id: currentRecipe.id,
component_index: currentComponentIndex,
component_start_weight: componentStartWeight,
current_weight: currentWeight
})
}).catch(error => {
// Молчаливо логируем ошибки синхронизации, не блокируем UI
console.warn('Синхронизация с сервером недоступна:', error);
});
}
function startRecipe() {
// Инициализация данных
isMixingModalOpen = false;
currentComponentIndex = 0;
componentWeights = currentRecipe.ingredients.map(ing => Number(ing.amount) || 0);
componentNames = currentRecipe.ingredients.map((ing, idx) => ingredientDisplayName(ing, idx));
completedComponents = new Array(componentNames.length).fill(false);
totalLoadedWeight = 0;
previousComponentsWeight = 0;
componentStartWeight = 0;
componentLoadingTimes = [];
currentComponentStartTime = new Date();
// Синхронизируем начальное состояние с сервером
syncStateWithServer();
// Показываем элементы левой части
const rwEl = document.getElementById('remainingWeight');
if (rwEl) rwEl.style.display = 'block';
document.querySelector('.weight-info').style.display = 'block';
// Обновляем отображение первого компонента
if (componentNames.length > 0) {
syncComponentNameElement(componentNames[0]);
setRemainingWeightDisplay(rwEl, roundToStep5(componentWeights[0]));
totalComponent.textContent = roundToStep5(componentWeights[0]);
currentLoaded.textContent = '0';
totalMixtureWeight.textContent = '0';
// Подсвечиваем первую строку в таблице
const rows = document.querySelectorAll('.ingredients-table tbody tr');
if (rows.length > 0) {
rows[0].classList.add('active-component');
}
} else {
syncComponentNameElement('');
}
// Обновляем состояние кнопок навигации
updateNavigationButtons();
// Показываем кнопку сброса
document.getElementById('resetComponentBtn').style.display = 'block';
// Запускаем проверку веса
if (weightCheckInterval) {
clearInterval(weightCheckInterval);
}
weightCheckInterval = setInterval(checkWeightThreshold, 1000);
// Запускаем проверку команд навигации с дублера
startNavigationCommandsListener();
scheduleLayoutFit();
if (currentRecipe && currentRecipe.ingredients && currentRecipe.ingredients.length) {
syncIngredientsTotalsRow();
}
}
// Функция для проверки команд навигации с дублера
function startNavigationCommandsListener() {
// Проверяем команды навигации каждые 500мс
setInterval(() => {
// Здесь можно добавить логику для проверки команд навигации
// Пока что функция пустая, чтобы избежать ошибки
}, 500);
}
function updateWeightDisplay() {
if (currentComponentIndex < componentNames.length) {
const componentWeight = componentWeights[currentComponentIndex];
// Вычисляем текущий вес компонента относительно начальной точки
const currentComponentWeight = currentWeight - componentStartWeight;
// Вычисляем оставшийся вес для текущего компонента
const remaining = componentWeight - currentComponentWeight;
console.log('Weight display update:', {
componentWeight,
currentComponentWeight,
remaining,
totalWeight: currentWeight
});
// Обновляем отображение
setRemainingWeightDisplay(document.getElementById('remainingWeight'), roundToStep5(Math.abs(remaining)));
document.getElementById('currentLoaded').textContent = roundToStep5(currentComponentWeight);
document.getElementById('totalMixtureWeight').textContent = roundToStep5(currentWeight);
// Отправляем данные о состоянии загрузки на сервер для дублера
if (currentRecipe && currentRecipe.id) {
fetch('/api/update_loading_state', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
component_name: componentNames[currentComponentIndex],
component_start_weight: componentStartWeight,
target_weight: componentWeight,
total_mixture_weight: currentWeight
})
}).catch(error => {
console.error('Ошибка при отправке состояния загрузки:', error);
});
}
// Добавляем информацию о перегрузе, если она есть
const weightInfo = document.querySelector('.weight-info');
const overloadInfo = weightInfo.querySelector('.overload-info') || document.createElement('div');
overloadInfo.className = 'overload-info';
if (remaining < 0) {
overloadInfo.textContent = `Перегруз: ${Math.round(Math.abs(remaining))} кг`;
overloadInfo.style.color = '#ff0000';
if (!weightInfo.contains(overloadInfo)) {
weightInfo.appendChild(overloadInfo);
}
} else {
if (weightInfo.contains(overloadInfo)) {
weightInfo.removeChild(overloadInfo);
}
}
if (remaining < 0) {
document.getElementById('remainingWeight').classList.add('negative-weight');
} else {
document.getElementById('remainingWeight').classList.remove('negative-weight');
}
requestAnimationFrame(() => fitRemainingWeightToContainer());
// Звук выгрузки:
// - по весу: стартуем циклически примерно за 5% до цели
// - в окне смешивания: звук по весу не запускаем и не глушим звук таймера
if (isMixingModalOpen) {
if (unloadingSoundReason !== 'timer') {
stopUnloadingSoundLoop();
}
} else {
const weightThreshold = componentWeight * 0.95;
if (currentComponentWeight >= weightThreshold) {
startUnloadingSoundLoop('weight');
} else if (unloadingSoundReason === 'weight') {
stopUnloadingSoundLoop();
}
}
syncActiveIngredientTableRow();
if (currentRecipe && currentRecipe.ingredients && currentRecipe.ingredients.length) {
syncIngredientsTotalsRow();
}
} else {
stopUnloadingSoundLoop();
if (currentRecipe && currentRecipe.ingredients && currentRecipe.ingredients.length) {
syncIngredientsTotalsRow();
}
}
}
function updateCurrentComponentDisplay() {
console.log('updateCurrentComponentDisplay вызван', {
currentComponentIndex,
componentNames,
currentWeight,
componentStartWeight,
previousComponentsWeight
});
if (currentComponentIndex < 0 || currentComponentIndex >= componentNames.length) {
console.error('Некорректный индекс компонента:', currentComponentIndex);
return;
}
const componentName = document.getElementById('componentName');
const remainingWeight = document.getElementById('remainingWeight');
const currentLoaded = document.getElementById('currentLoaded');
const totalComponent = document.getElementById('totalComponent');
const totalMixtureWeight = document.getElementById('totalMixtureWeight');
if (!componentName || !remainingWeight || !currentLoaded || !totalComponent || !totalMixtureWeight) {
console.error('Не найдены необходимые элементы интерфейса');
return;
}
const component = currentRecipe.ingredients[currentComponentIndex];
if (!component) {
console.error('Не найден компонент с индексом:', currentComponentIndex);
return;
}
// Обновляем отображение текущего компонента
syncComponentNameElement(ingredientDisplayName(component, currentComponentIndex));
// Вычисляем текущий вес компонента
const loadedWeight = currentWeight - componentStartWeight;
const remainingWeightValue = component.amount - loadedWeight;
console.log('Расчет весов:', {
componentName: ingredientDisplayName(component, currentComponentIndex),
targetWeight: component.amount,
loadedWeight,
remainingWeight: remainingWeightValue,
totalWeight: currentWeight
});
// Обновляем отображение весов
setRemainingWeightDisplay(remainingWeight, roundToStep5(Math.abs(remainingWeightValue)));
currentLoaded.textContent = roundToStep5(Math.max(0, loadedWeight));
totalComponent.textContent = roundToStep5(component.amount);
totalMixtureWeight.textContent = roundToStep5(currentWeight);
// Обновляем стили для отрицательного веса
if (remainingWeightValue < 0) {
remainingWeight.classList.add('negative-weight');
} else {
remainingWeight.classList.remove('negative-weight');
}
// Обновляем стили строк в таблице
const rows = document.querySelectorAll('.ingredients-table tbody tr');
rows.forEach((row, index) => {
row.classList.remove('active-component');
if (index === currentComponentIndex) {
row.classList.add('active-component');
}
});
// --- Добавляем автоскролл ---
scrollToActiveComponent();
// Обновляем навигационные кнопки
updateNavigationButtons();
scheduleLayoutFit();
}
function scrollToActiveComponent() {
// Находим tbody с прокруткой и активную строку
const tbody = document.querySelector('.ingredients-table .scrollable-tbody');
const activeRow = tbody ? tbody.querySelector('tr.active-component') : null;
if (tbody && activeRow) {
const rowTop = activeRow.offsetTop;
const rowHeight = activeRow.offsetHeight;
const tbodyHeight = tbody.clientHeight;
// Скроллим так, чтобы строка была на 1/3 высоты области
const targetScroll = rowTop - tbodyHeight / 3 + rowHeight / 2;
tbody.scrollTo({
top: targetScroll,
behavior: 'smooth'
});
}
}
function checkWeightThreshold() {
if (currentComponentIndex < componentWeights.length) {
const componentWeight = componentWeights[currentComponentIndex];
// Вычисляем текущий вес компонента относительно начальной точки
const currentComponentWeight = currentWeight - componentStartWeight;
const remaining = componentWeight - currentComponentWeight;
// Если есть перегруз, показываем информацию о нем
if (remaining < 0) {
const overload = Math.abs(remaining);
console.log(`Компонент ${componentNames[currentComponentIndex]} перегружен на ${overload.toFixed(1)} кг`);
// Обновляем информацию о перегрузе в интерфейсе
const weightInfo = document.querySelector('.weight-info');
let overloadInfo = weightInfo.querySelector('.overload-info');
if (!overloadInfo) {
overloadInfo = document.createElement('div');
overloadInfo.className = 'overload-info';
weightInfo.appendChild(overloadInfo);
}
overloadInfo.textContent = `Перегруз: ${roundToStep5(overload)} кг`;
overloadInfo.style.color = '#ff0000';
// Ячейки таблицы обновляет syncActiveIngredientTableRow() из updateWeightDisplay (SSE)
}
}
}
function prevComponent() {
stopUnloadingSoundLoop();
console.log('prevComponent вызван', {
currentComponentIndex,
componentNames,
previousComponentsWeight,
componentStartWeight,
currentWeight,
actualComponentWeights
});
if (currentComponentIndex <= 0) {
console.log('Невозможно перейти к предыдущему компоненту: уже на первом компоненте');
return;
}
// Сохраняем данные текущего компонента перед переключением
const currentTargetWeight = componentWeights[currentComponentIndex];
const currentActualWeight = currentWeight - componentStartWeight;
const currentOverload = currentActualWeight - currentTargetWeight;
console.log('Текущий компонент перед переключением:', {
index: currentComponentIndex,
targetWeight: currentTargetWeight,
actualWeight: currentActualWeight,
overload: currentOverload,
startWeight: componentStartWeight
});
// ФИКСИРУЕМ вес текущего компонента перед переключением
actualComponentWeights[currentComponentIndex] = {
name: componentNames[currentComponentIndex],
component_id: currentRecipe?.ingredients?.[currentComponentIndex]?.component_id ?? null,
target_weight: currentTargetWeight,
actual_weight: currentActualWeight,
overload: currentOverload
};
// Обновляем отображение в таблице для текущего компонента
const rows = document.querySelectorAll('.ingredients-table tbody tr');
if (rows[currentComponentIndex]) {
const row = rows[currentComponentIndex];
const actualWeightCell = row.querySelector('.actual-weight');
const overloadCell = row.querySelector('.overload');
if (actualWeightCell) {
actualWeightCell.textContent = roundToStep5(currentActualWeight);
actualWeightCell.style.color = currentOverload > 0 ? '#ff0000' : currentOverload < 0 ? '#2c7be5' : '';
}
if (overloadCell) {
if (currentOverload > 0) {
overloadCell.textContent = `+${Math.round(currentOverload)}`;
overloadCell.style.color = '#ff0000';
} else if (currentOverload < 0) {
overloadCell.textContent = `${Math.round(currentOverload)}`;
overloadCell.style.color = '#2c7be5';
} else {
overloadCell.textContent = '0';
overloadCell.style.color = '';
}
}
}
// Сохраняем данные в localStorage
localStorage.setItem('componentData', JSON.stringify(actualComponentWeights));
// Обновляем индекс
currentComponentIndex--;
// Вычисляем previousComponentsWeight для нового индекса
previousComponentsWeight = 0;
for (let i = 0; i < currentComponentIndex; i++) {
if (actualComponentWeights[i]) {
previousComponentsWeight += actualComponentWeights[i].actual_weight;
console.log(`Добавлен вес компонента ${i}:`, actualComponentWeights[i].actual_weight);
}
}
// Устанавливаем новую точку отсчета
componentStartWeight = previousComponentsWeight;
currentComponentStartTime = new Date();
// ВАЖНО: если этот компонент уже был частично загружен,
// корректируем componentStartWeight на основе зафиксированного веса
if (actualComponentWeights[currentComponentIndex]) {
const fixedComponentData = actualComponentWeights[currentComponentIndex];
// Вычисляем вес без этого компонента
let weightWithoutCurrentComponent = previousComponentsWeight;
// Корректируем стартовый вес так, чтобы currentWeight - componentStartWeight = зафиксированный вес компонента
componentStartWeight = currentWeight - fixedComponentData.actual_weight;
console.log('Восстановлен частично загруженный компонент:', {
componentIndex: currentComponentIndex,
fixedActualWeight: fixedComponentData.actual_weight,
correctedStartWeight: componentStartWeight,
shouldShowLoaded: currentWeight - componentStartWeight
});
}
console.log('После обновления индексов:', {
newIndex: currentComponentIndex,
previousComponentsWeight,
componentStartWeight,
componentName: componentNames[currentComponentIndex],
currentlyLoadedWeight: currentWeight - componentStartWeight
});
// Обновляем отображение
updateCurrentComponentDisplay();
updateWeightDisplay();
updateNavigationButtons();
// Синхронизируем состояние с сервером
syncStateWithServer();
// Сообщаем серверу о переключении на предыдущий компонент
fetch('/api/navigate_component', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ direction: 'prev' })
}).catch(err => {
console.error('Ошибка навигации (prev):', err);
void window.WespKioskDialog.alert('Ошибка при переключении на предыдущий компонент', { variant: 'danger' });
});
}
function nextComponent() {
stopUnloadingSoundLoop();
console.log('nextComponent called', {
currentComponentIndex,
maxIndex: componentNames.length - 1,
currentWeight,
componentStartWeight
});
if (currentComponentIndex < componentNames.length - 1) {
// Сохраняем время загрузки текущего компонента
const endTime = new Date();
const loadingDuration = (endTime - currentComponentStartTime) / 1000; // в секундах
componentLoadingTimes.push({
component_name: componentNames[currentComponentIndex],
start_time: currentComponentStartTime.toISOString(),
end_time: endTime.toISOString(),
loading_duration: loadingDuration,
loading_order: currentComponentIndex + 1
});
// Сохраняем времена загрузки в localStorage
localStorage.setItem('componentLoadingTimes', JSON.stringify(componentLoadingTimes));
// Вычисляем фактический вес и перегруз для текущего компонента
const targetWeight = componentWeights[currentComponentIndex];
const actualWeight = currentWeight - componentStartWeight;
const overload = actualWeight - targetWeight;
console.log('Saving component data:', {
name: componentNames[currentComponentIndex],
targetWeight,
actualWeight,
overload,
loadingDuration
});
// Сохраняем информацию о компоненте
actualComponentWeights[currentComponentIndex] = {
name: componentNames[currentComponentIndex],
component_id: currentRecipe?.ingredients?.[currentComponentIndex]?.component_id ?? null,
target_weight: targetWeight,
actual_weight: actualWeight,
overload: overload
};
// Обновляем отображение в таблице
const rows = document.querySelectorAll('.ingredients-table tbody tr');
if (rows[currentComponentIndex]) {
const row = rows[currentComponentIndex];
const actualWeightCell = row.querySelector('.actual-weight');
const overloadCell = row.querySelector('.overload');
if (actualWeightCell) {
actualWeightCell.textContent = roundToStep5(actualWeight);
actualWeightCell.style.color = overload > 0 ? '#ff0000' : overload < 0 ? '#2c7be5' : '';
}
if (overloadCell) {
if (overload > 0) {
overloadCell.textContent = `+${roundToStep5(overload)}`;
overloadCell.style.color = '#ff0000'; // красный для перегруза
} else if (overload < 0) {
overloadCell.textContent = `${roundToStep5(overload)}`;
overloadCell.style.color = '#2c7be5'; // синий для недогруза
} else {
overloadCell.textContent = '0';
overloadCell.style.color = ''; // сброс цвета
}
}
}
// Сохраняем данные компонентов в localStorage
localStorage.setItem('componentData', JSON.stringify(actualComponentWeights));
console.log('Updated component data in localStorage:', actualComponentWeights);
// Обновляем индексы и веса
currentComponentIndex++;
previousComponentsWeight = currentWeight;
componentStartWeight = currentWeight;
currentComponentStartTime = new Date();
// ВАЖНО: если новый компонент уже был частично загружен,
// корректируем componentStartWeight на основе зафиксированного веса
if (actualComponentWeights[currentComponentIndex]) {
const fixedComponentData = actualComponentWeights[currentComponentIndex];
// Корректируем стартовый вес так, чтобы currentWeight - componentStartWeight = зафиксированный вес компонента
componentStartWeight = currentWeight - fixedComponentData.actual_weight;
console.log('Восстановлен частично загруженный компонент при переходе вперед:', {
componentIndex: currentComponentIndex,
fixedActualWeight: fixedComponentData.actual_weight,
correctedStartWeight: componentStartWeight,
shouldShowLoaded: currentWeight - componentStartWeight
});
}
// Отмечаем компонент как завершенный
completedComponents[currentComponentIndex - 1] = true;
// Обновляем отображение
updateCurrentComponentDisplay();
updateNavigationButtons();
syncIngredientsTotalsRow();
// Синхронизируем состояние с сервером
syncStateWithServer();
// Сообщаем серверу о переключении на следующий компонент
fetch('/api/navigate_component', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ direction: 'next' })
}).catch(err => console.error('Ошибка навигации (next):', err));
} else {
finishLoading();
}
}
function finishLoading() {
stopUnloadingSoundLoop();
syncComponentNameElement('ЗАГРУЗКА ЗАВЕРШЕНА');
document.getElementById('remainingWeight').style.display = 'none';
document.querySelector('.weight-info').style.display = 'none';
// Обновляем кнопки
document.getElementById('prevBtn').style.display = 'none';
document.getElementById('nextBtn').style.display = 'none';
document.getElementById('unloadBtn').style.display = 'block';
if (weightCheckInterval) {
clearInterval(weightCheckInterval);
weightCheckInterval = null;
}
// Скрываем кнопку сброса при завершении
document.getElementById('resetComponentBtn').style.display = 'none';
}
function goToUnload() {
// Сохраняем фактический вес последнего компонента
const lastIndex = componentNames.length - 1;
if (lastIndex >= 0) {
// ФИКСИРУЕМ последний компонент только если он еще не был зафиксирован
if (!actualComponentWeights[lastIndex]) {
// Сохраняем время загрузки последнего компонента
const endTime = new Date();
const loadingDuration = (endTime - currentComponentStartTime) / 1000; // в секундах
componentLoadingTimes.push({
component_name: componentNames[lastIndex],
start_time: currentComponentStartTime.toISOString(),
end_time: endTime.toISOString(),
loading_duration: loadingDuration,
loading_order: lastIndex + 1
});
// Обновляем времена загрузки в localStorage
localStorage.setItem('componentLoadingTimes', JSON.stringify(componentLoadingTimes));
const targetWeight = componentWeights[lastIndex];
const actualWeight = currentWeight - componentStartWeight;
const overload = actualWeight - targetWeight;
console.log('Фиксируем последний компонент при нажатии на Смешивание:', {
name: componentNames[lastIndex],
targetWeight,
actualWeight,
overload
});
actualComponentWeights[lastIndex] = {
name: componentNames[lastIndex],
component_id: currentRecipe?.ingredients?.[lastIndex]?.component_id ?? null,
target_weight: targetWeight,
actual_weight: actualWeight,
overload: overload
};
// ФИКСИРУЕМ отображение последнего компонента в таблице
const rows = document.querySelectorAll('.ingredients-table tbody tr');
if (rows[lastIndex]) {
const row = rows[lastIndex];
const actualWeightCell = row.querySelector('.actual-weight');
const overloadCell = row.querySelector('.overload');
if (actualWeightCell) {
actualWeightCell.textContent = roundToStep5(actualWeight);
actualWeightCell.style.color = overload > 0 ? '#ff0000' : overload < 0 ? '#2c7be5' : '';
}
if (overloadCell) {
if (overload > 0) {
overloadCell.textContent = `+${roundToStep5(overload)}`;
overloadCell.style.color = '#ff0000'; // красный для перегруза
} else if (overload < 0) {
overloadCell.textContent = `${roundToStep5(overload)}`;
overloadCell.style.color = '#2c7be5'; // синий для недогруза
} else {
overloadCell.textContent = '0';
overloadCell.style.color = ''; // сброс цвета
}
}
console.log('Зафиксирован последний компонент в таблице:', {
componentIndex: lastIndex,
actualWeight: roundToStep5(actualWeight),
overload: roundToStep5(overload)
});
}
} else {
console.log('Последний компонент уже был зафиксирован ранее:', actualComponentWeights[lastIndex]);
}
syncIngredientsTotalsRow();
// Обновляем данные компонентов в localStorage
localStorage.setItem('componentData', JSON.stringify(actualComponentWeights));
console.log('Updated component data in localStorage:', actualComponentWeights);
// Сохраняем заданное время смешивания в секундах
const targetMixingTime = mixingTime; // mixingTime уже в секундах
localStorage.setItem('targetMixingTime', targetMixingTime.toString());
console.log('Saved target mixing time (seconds):', targetMixingTime);
// Показываем модальное окно таймера
const timerModal = document.getElementById('timerModal');
if (timerModal) {
console.log('Showing timer modal');
// При открытии окна смешивания звук должен остановиться
isMixingModalOpen = true;
unloadingSoundReason = null;
stopUnloadingSoundLoop();
timerModal.style.display = 'flex';
timerSecondsLeft = targetMixingTime;
mixingStartTime = new Date();
startUnloadTimer();
} else {
console.error('Timer modal not found');
}
}
}
function startUnloadTimer() {
console.log('Starting timer with duration:', mixingTime);
mixingStartTime = new Date();
console.log('Mixing start time:', mixingStartTime.toISOString());
// Устанавливаем флаг активности таймера
fetch('/api/set_mixing_timer', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ active: true })
}).catch(err => console.error('Ошибка установки флага таймера:', err));
updateTimerProgress();
if (timerInterval) {
clearInterval(timerInterval);
}
timerInterval = setInterval(() => {
timerSecondsLeft--;
// Форматируем время в MM:SS
const minutes = Math.floor(timerSecondsLeft / 60);
const seconds = timerSecondsLeft % 60;
const timerDisplay = document.getElementById('timerValue');
if (timerDisplay) {
timerDisplay.textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
console.log('Timer update:', {
timeLeft: timerSecondsLeft,
display: `${minutes}:${seconds}`
});
updateTimerProgress();
if (timerSecondsLeft <= 0) {
console.log('Timer finished - waiting for manual finish');
clearInterval(timerInterval);
timerInterval = null;
// Сбрасываем флаг активности таймера
fetch('/api/set_mixing_timer', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ active: false })
}).catch(err => console.error('Ошибка сброса флага таймера:', err));
// Циклически проигрываем звук выгрузки до нажатия "Завершить смешивание"
startUnloadingSoundLoop('timer');
// Обновляем отображение таймера на 00:00
const timerDisplay = document.getElementById('timerValue');
if (timerDisplay) {
timerDisplay.textContent = '00:00';
}
// Обновляем прогресс-бар на 100%
const timerProgressBar = document.querySelector('.timer-progress-bar');
if (timerProgressBar) {
timerProgressBar.style.width = '100%';
}
}
}, 1000);
}
function updateTimerProgress() {
const progress = ((mixingTime - timerSecondsLeft) / mixingTime) * 100;
const timerProgressBar = document.querySelector('.timer-progress-bar');
if (timerProgressBar) {
timerProgressBar.style.width = `${progress}%`;
console.log('Timer progress updated:', {
progress,
timeLeft: timerSecondsLeft,
totalTime: mixingTime
});
}
}
function resetComponentSelection() {
stopUnloadingSoundLoop();
currentComponentIndex = 0;
currentWeight = 0;
componentWeights = [];
componentNames = [];
totalLoadedWeight = 0;
previousComponentsWeight = 0;
completedComponents = [];
syncComponentNameElement('');
document.getElementById('remainingWeight').style.display = 'none';
document.querySelector('.weight-info').style.display = 'none';
document.getElementById('resetComponentBtn').style.display = 'none';
document.getElementById('prevBtn').style.display = 'none';
document.getElementById('nextBtn').style.display = 'none';
document.getElementById('unloadBtn').style.display = 'none';
if (weightCheckInterval) {
clearInterval(weightCheckInterval);
weightCheckInterval = null;
}
scheduleLayoutFit();
}
/** Имя строки рецепта для таблицы: из API или запасной вариант. */
function ingredientDisplayName(ing, index) {
const n = ing && ing.name != null ? String(ing.name).trim() : '';
if (n) return n;
return `Компонент ${index + 1}`;
}
/** Обновляет колонки «Факт» и «Отклонение» для активной строки (по SSE веса). */
function syncActiveIngredientTableRow() {
if (!currentRecipe || currentComponentIndex < 0 || currentComponentIndex >= componentNames.length) {
return;
}
const rows = document.querySelectorAll('.ingredients-table tbody tr');
const row = rows[currentComponentIndex];
if (!row || row.classList.contains('total-row')) return;
const target = componentWeights[currentComponentIndex];
const currentComponentWeight = currentWeight - componentStartWeight;
const overload = currentComponentWeight - target;
const actualCell = row.querySelector('.actual-weight');
const overloadCell = row.querySelector('.overload');
if (actualCell) {
actualCell.textContent = roundToStep5(Math.max(0, currentComponentWeight));
actualCell.style.color = overload > 0 ? '#ff0000' : '';
}
if (overloadCell) {
if (overload > 0) {
overloadCell.textContent = `+${roundToStep5(overload)}`;
overloadCell.style.color = '#ff0000';
} else {
overloadCell.textContent = '-';
overloadCell.style.color = '';
}
}
}
const TOTAL_ROW_LOAD_EPS_KG = 0.05;
/** Суммы по строке «Итого»: план, факт, отклонение, % факта к плану. */
function syncIngredientsTotalsRow() {
if (!currentRecipe || !currentRecipe.ingredients || !currentRecipe.ingredients.length) {
return;
}
const totalRow = document.querySelector('.ingredients-table tr.total-row');
if (!totalRow) return;
const n = currentRecipe.ingredients.length;
let sumPlan = 0;
let sumActual = 0;
for (let i = 0; i < n; i++) {
sumPlan += Number(componentWeights[i]) || 0;
if (actualComponentWeights[i]) {
sumActual += Number(actualComponentWeights[i].actual_weight) || 0;
} else if (i === currentComponentIndex) {
sumActual += Math.max(0, currentWeight - componentStartWeight);
}
}
const tds = totalRow.querySelectorAll('td');
if (tds.length < 5) return;
tds[1].textContent = roundToStep5(sumPlan);
tds[1].classList.add('weight-value');
tds[2].classList.add('actual-weight');
tds[3].classList.add('overload');
// Пока в миксер ничего не загружено — не показываем «недогруз» на весь план.
if (sumActual <= TOTAL_ROW_LOAD_EPS_KG) {
tds[2].textContent = '-';
tds[2].style.color = '';
tds[3].textContent = '-';
tds[3].style.color = '';
tds[4].textContent = sumPlan > 0 ? '0.0%' : '—';
return;
}
const deviation = sumActual - sumPlan;
tds[2].textContent = roundToStep5(sumActual);
tds[2].style.color = deviation > 0 ? '#ff0000' : deviation < 0 ? '#2c7be5' : '';
if (deviation > TOTAL_ROW_LOAD_EPS_KG) {
tds[3].textContent = `+${roundToStep5(deviation)}`;
tds[3].style.color = '#ff0000';
} else if (deviation < -TOTAL_ROW_LOAD_EPS_KG) {
tds[3].textContent = `${roundToStep5(deviation)}`;
tds[3].style.color = '#2c7be5';
} else {
tds[3].textContent = '0';
tds[3].style.color = '';
}
if (sumPlan > 0) {
const pct = (sumActual / sumPlan) * 100;
tds[4].textContent = `${pct.toFixed(1)}%`;
} else {
tds[4].textContent = '—';
}
}
function displayIngredientsTable() {
const totalWeight = currentRecipe.ingredients.reduce((sum, ing) => sum + (Number(ing.amount) || 0), 0);
const table = document.createElement('table');
table.className = 'ingredients-table';
table.innerHTML = `
<thead>
<tr>
<th>Компонент</th>
<th>План (кг)</th>
<th>Факт (кг)</th>
<th>Отклонение</th>
<th>%</th>
</tr>
</thead>
<tbody class="scrollable-tbody">
${currentRecipe.ingredients.map((ing, idx) => {
const amt = Number(ing.amount) || 0;
const pct = totalWeight > 0 ? (amt / totalWeight) * 100 : 0;
const pctStr = (typeof pct === 'number' && !isNaN(pct)) ? pct.toFixed(1) : '0.0';
const label = ingredientDisplayName(ing, idx);
return `
<tr>
<td>${label}</td>
<td class="weight-value">${roundToStep5(amt)}</td>
<td class="actual-weight">-</td>
<td class="overload">-</td>
<td>${pctStr}%</td>
</tr>
`}).join('')}
<tr class="total-row">
<td>Итого</td>
<td class="weight-value">${roundToStep5(totalWeight)}</td>
<td class="actual-weight">-</td>
<td class="overload">-</td>
<td>100%</td>
</tr>
</tbody>
`;
ingredientsTableContainer.innerHTML = '';
ingredientsTableContainer.appendChild(table);
}
function updateComponentDisplay() {
if (currentComponentIndex >= 0 && currentComponentIndex < currentRecipe.ingredients.length) {
const component = currentRecipe.ingredients[currentComponentIndex];
// Обновляем отображение текущего компонента
syncComponentNameElement(ingredientDisplayName(component, currentComponentIndex));
setRemainingWeightDisplay(document.getElementById('remainingWeight'), roundToStep5(component.amount));
totalComponent.textContent = roundToStep5(component.amount);
// Обновляем отображение загруженного веса
const loadedWeight = currentWeight - previousComponentsWeight;
currentLoaded.textContent = roundToStep5(Math.max(0, loadedWeight));
// Обновляем общий вес смеси
totalMixtureWeight.textContent = roundToStep5(currentWeight);
// Обновляем стили строк в таблице
document.querySelectorAll('.ingredients-table tbody tr').forEach((row, index) => {
row.classList.remove('active-component');
if (index === currentComponentIndex) {
row.classList.add('active-component');
}
if (completedComponents[index]) {
row.classList.add('completed-component');
} else {
row.classList.remove('completed-component');
}
});
// --- Добавляем автоскролл ---
scrollToActiveComponent();
scheduleLayoutFit();
}
}
function resetCurrentComponent() {
if (currentComponentIndex < componentNames.length) {
// Обновляем начальную точку отсчета для текущего компонента
componentStartWeight = currentWeight;
// ОЧИЩАЕМ зафиксированные данные для текущего компонента
if (actualComponentWeights[currentComponentIndex]) {
delete actualComponentWeights[currentComponentIndex];
localStorage.setItem('componentData', JSON.stringify(actualComponentWeights));
console.log('Сброшены данные компонента:', currentComponentIndex);
}
// Сбрасываем отображение в таблице
const rows = document.querySelectorAll('.ingredients-table tbody tr');
if (rows[currentComponentIndex]) {
const row = rows[currentComponentIndex];
const actualWeightCell = row.querySelector('.actual-weight');
const overloadCell = row.querySelector('.overload');
if (actualWeightCell) {
actualWeightCell.textContent = '-';
actualWeightCell.style.color = '';
}
if (overloadCell) {
overloadCell.textContent = '-';
overloadCell.style.color = '';
}
}
// Сбрасываем отображение перегруза
const weightInfo = document.querySelector('.weight-info');
const overloadInfo = weightInfo.querySelector('.overload-info');
if (overloadInfo) {
weightInfo.removeChild(overloadInfo);
}
// Обновляем отображение
setRemainingWeightDisplay(document.getElementById('remainingWeight'), roundToStep5(componentWeights[currentComponentIndex]));
document.getElementById('currentLoaded').textContent = '0';
document.getElementById('remainingWeight').classList.remove('negative-weight');
// Синхронизируем состояние с сервером
syncStateWithServer();
// Уведомляем сервер о сбросе текущего компонента
fetch('/api/reset_component', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}).catch(err => console.error('Ошибка сброса компонента:', err));
syncIngredientsTotalsRow();
requestAnimationFrame(() => fitRemainingWeightToContainer());
}
}
async function saveReport() {
const endTime = new Date();
// Сохраняем время загрузки последнего компонента
if (currentComponentStartTime) {
const loadingDuration = (endTime - currentComponentStartTime) / 1000;
componentLoadingTimes.push({
component_name: componentNames[currentComponentIndex],
start_time: currentComponentStartTime.toISOString(),
end_time: endTime.toISOString(),
loading_duration: loadingDuration,
loading_order: currentComponentIndex + 1
});
}
const equipmentType = localStorage.getItem('equipmentType') || 'dispenser';
const dispenserType = (equipmentType === 'mill' || equipmentType === 'кормоцех') ? 'mill' : 'dispenser';
const reportData = {
recipe_id: currentRecipe.id,
total_weight: currentWeight,
target_mixing_time: currentRecipe.mixingTime * 60, // переводим в секунды
actual_mixing_time: currentRecipe.mixingTime * 60, // пока используем то же значение, что и целевое
components: actualComponentWeights,
component_loading_times: componentLoadingTimes,
dispenser_type: dispenserType
};
try {
const response = await fetch('/api/save_report', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(reportData)
});
if (!response.ok) {
throw new Error('Ошибка при сохранении отчета');
}
const result = await response.json();
if (result.status === 'success') {
console.log('Отчет успешно сохранен');
// Переходим на страницу выгрузки
window.location.href = `/unloading?recipe_id=${currentRecipe.id}&total_weight=${currentWeight}`;
} else {
throw new Error(result.message || 'Ошибка при сохранении отчета');
}
} catch (error) {
console.error('Ошибка:', error);
await window.WespKioskDialog.alert('Не удалось сохранить отчёт. Попробуйте ещё раз.', { variant: 'danger' });
}
}
// Добавляем обработчик события beforeunload
window.addEventListener('beforeunload', async function(e) {
// Останавливаем интервалы
if (weightCheckInterval) clearInterval(weightCheckInterval); weightCheckInterval = null;
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
if (navigationCommandsInterval) { clearInterval(navigationCommandsInterval); navigationCommandsInterval = null; }
// Закрываем SSE
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Сбрасываем рецепт на сервере для дублера
fetch('/api/set_current_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: null })
}).catch(() => {});
// Сбрасываем флаг активности таймера
fetch('/api/set_mixing_timer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: false })
}).catch(() => {});
});
// Функции для управления видимостью рецепта
function toggleRecipeVisibility() {
const weightSection = document.querySelector('.weight-section');
const recipesSection = document.querySelector('.recipes-section');
const toggleBtn = document.getElementById('toggleRecipeBtn');
if (weightSection.classList.contains('fullscreen')) {
showRecipe();
} else {
hideRecipe();
}
}
function hideRecipe() {
const weightSection = document.querySelector('.weight-section');
const recipesSection = document.querySelector('.recipes-section');
const toggleBtn = document.getElementById('toggleRecipeBtn');
const weightInfo = document.querySelector('.weight-info');
// Переключаем в полноэкранный режим
weightSection.classList.add('fullscreen');
recipesSection.style.display = 'none';
// Скрываем weight-info в полноэкранном режиме
if (weightInfo) {
weightInfo.style.display = 'none';
}
// Перемещаем кнопку в weight-section
if (toggleBtn && weightSection) {
weightSection.appendChild(toggleBtn);
}
// Обновляем кнопки
toggleBtn.textContent = 'Показать рецепт';
toggleBtn.style.display = 'block';
}
function showRecipe() {
const weightSection = document.querySelector('.weight-section');
const recipesSection = document.querySelector('.recipes-section');
const toggleBtn = document.getElementById('toggleRecipeBtn');
const recipeDetails = document.getElementById('recipeDetails');
const weightInfo = document.querySelector('.weight-info');
// Возвращаем обычный режим
weightSection.classList.remove('fullscreen');
recipesSection.style.display = 'flex';
// Показываем weight-info
if (weightInfo) {
weightInfo.style.display = 'block';
}
// Перемещаем кнопку в recipeDetails
if (toggleBtn && recipeDetails) {
recipeDetails.insertBefore(toggleBtn, recipeDetails.firstChild);
}
// Обновляем текст кнопки
toggleBtn.textContent = 'Скрыть рецепт';
}
// Функция навигации для полноэкранного режима
function navigateComponent(direction) {
if (direction === 'prev') {
document.getElementById('prevBtn').click();
} else if (direction === 'next') {
document.getElementById('nextBtn').click();
}
}
// Функция запуска смешивания для полноэкранного режима
function startMixing() {
document.getElementById('unloadBtn').click();
}
// Функция возврата к списку рецептов
function backToRecipesList() {
// Останавливаем интервал проверки веса
if (weightCheckInterval) {
clearInterval(weightCheckInterval);
weightCheckInterval = null;
}
// Сбрасываем рецепт на сервере для дублера
fetch('/api/set_current_recipe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipe_id: null
})
}).catch(error => {
console.error('Ошибка при сбросе рецепта на сервере:', error);
});
// Сбрасываем состояние рецепта
currentRecipe = null;
appState.set({ currentRecipe: null });
currentComponentIndex = 0;
currentWeight = 0;
componentWeights = [];
componentNames = [];
totalLoadedWeight = 0;
previousComponentsWeight = 0;
completedComponents = [];
const weightSection = document.querySelector('.weight-section');
weightSection.classList.remove('fullscreen');
document.getElementById('recipesList').style.display = 'block';
document.querySelector('.section-title').style.display = 'block';
document.getElementById('recipeDetails').style.display = 'none';
document.getElementById('ingredientsTableContainer').innerHTML = '';
const toggleBtn = document.getElementById('toggleRecipeBtn');
if (toggleBtn) toggleBtn.style.display = 'none';
const hideBtn = document.getElementById('hideRecipeBtn');
if (hideBtn) hideBtn.style.display = 'none';
// Скрываем навигационные кнопки
document.getElementById('navButtonsOverlay').style.display = 'none';
// Скрываем элементы интерфейса веса
syncComponentNameElement('');
document.getElementById('remainingWeight').style.display = 'none';
document.querySelector('.weight-info').style.display = 'none';
document.getElementById('resetComponentBtn').style.display = 'none';
}
// Функция синхронизации с дублером (упрощена, основная логика через SSE)
let syncInterval = null;
function startSyncWithDuplicator() {
console.log('Запуск синхронизации с дублером через SSE');
// Основная синхронизация происходит через SSE, здесь только логирование
}
function stopSyncWithDuplicator() {
console.log('Остановка синхронизации с дублером');
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
}
function showSyncNotification(message) {
// Удаляем предыдущие уведомления синхронизации
const existingNotifications = document.querySelectorAll('.sync-notification');
existingNotifications.forEach(notification => notification.remove());
// Создаем новое уведомление
const notification = document.createElement('div');
notification.className = 'sync-notification';
notification.textContent = `🔄 ${message}`;
// Стили для уведомления
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 20px;
background: rgba(37, 99, 235, 0.9);
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadeInOut 3s ease-in-out;
`;
document.body.appendChild(notification);
// Автоматически удаляем уведомление через 3 секунды
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 3000);
}
// Обновляем функцию showRecipeDetails для запуска синхронизации
const originalShowRecipeDetails = showRecipeDetails;
showRecipeDetails = function(recipe) {
originalShowRecipeDetails.call(this, recipe);
// Запускаем синхронизацию после выбора рецепта
startSyncWithDuplicator();
};
// Обновляем функцию backToRecipesList для остановки синхронизации
const originalBackToRecipesList = backToRecipesList;
backToRecipesList = function() {
// Останавливаем синхронизацию
stopSyncWithDuplicator();
originalBackToRecipesList.call(this);
};
// Функция перехода на главную страницу с сбросом рецепта
async function goToMainPage() {
window.location.href = '/recipes';
}
// Добавляем CSS для анимации уведомлений
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(-20px); }
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-20px); }
}
`;
document.head.appendChild(style);
// Синхронизация теперь происходит только через API
function stopNavigationCommandsListener() {
if (navigationCommandsInterval) {
clearInterval(navigationCommandsInterval);
navigationCommandsInterval = null;
}
}
function startNavigationCommandsPolling() {
stopNavigationCommandsListener();
navigationCommandsInterval = setInterval(checkNavigationCommands, 500);
}
// Функция прослушивания команд навигации с дублера
startNavigationCommandsPolling();
console.log('Запущен слушатель команд навигации с дублера');
function checkNavigationCommands() {
// Получаем команды с сервера
fetch('/api/get_navigation_commands')
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.commands.length > 0) {
// Выполняем каждую команду
data.commands.forEach(commandObj => {
executeNavigationCommand(commandObj.command);
});
}
})
.catch(error => {
// Молчаливо игнорируем ошибки, чтобы не засорять консоль
});
}
function executeNavigationCommand(command) {
console.log(`Выполняем команду навигации с дублера: ${command}`);
if (command === 'prev') {
// Имитируем клик по кнопке "Предыдущий"
const prevBtn = document.getElementById('prevBtn');
if (prevBtn && prevBtn.style.display !== 'none') {
console.log('Выполняем клик по кнопке "Предыдущий" от дублера');
prevBtn.click();
}
} else if (command === 'next') {
// Имитируем клик по кнопке "Следующий"
const nextBtn = document.getElementById('nextBtn');
if (nextBtn && nextBtn.style.display !== 'none') {
console.log('Выполняем клик по кнопке "Следующий" от дублера');
nextBtn.click();
}
}
}
// Останавливаем опрос команд при выходе из загрузки рецепта (на список рейсов)
const originalBackToRecipesListNav = backToRecipesList;
backToRecipesList = function() {
originalBackToRecipesListNav.call(this);
};
// Останавливаем слушатель при закрытии страницы
window.addEventListener('beforeunload', function() {
stopNavigationCommandsListener();
if (eventSource && typeof eventSource.close === 'function') {
eventSource.close();
}
crossTab.close();
});
</script>
<script src="/static/js/kiosk-theme.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.WespKioskTheme) {
window.WespKioskTheme.setup({ withToggle: false });
}
});
</script>
<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) {
WespUpdateNotifier.init({ dialog: window.WespKioskDialog || window.WespDialog });
}
});
</script>
</body>
</html>