3670 lines
149 KiB
HTML
3670 lines
149 KiB
HTML
<!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> |