Files
2026-07-17 12:57:18 +03:00

5474 lines
256 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#2196F3">
<meta name="msapplication-navbutton-color" content="#2196F3">
<title>Рецепты</title>
<script src="/static/js/wesp-theme-boot.js"></script>
<script>document.documentElement.classList.add('wesp-page-enter-pending');</script>
<script src="/static/js/wesp-page-enter.js?v=2"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=2"></script>
<script src="/static/js/wesp-zootech-nav.js?v=6"></script>
<style>
html { background: #f5f7fa; }
html[data-theme="dark"] { background: #12181b; }
html[data-theme="organic"] { background: #f6f6f4; }
</style>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell.css?v=3" rel="stylesheet">
<link href="/static/css/wesp-feed-quality-settings.css?v=3" rel="stylesheet">
<link href="/static/css/inter-fonts.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=17" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell-modals.css?v=4" rel="stylesheet">
<link href="/static/css/wesp-zootech-k-hub.css?v=28" rel="stylesheet">
<link href="/static/css/wesp-recipes-skeleton.css" rel="stylesheet">
<link href="/static/css/wesp-logo-loader.css" rel="stylesheet">
<link href="/static/css/wesp-recipes-mobile.css?v=17" rel="stylesheet">
<link href="/static/css/wesp-recipes-editor.css?v=12" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<script src="/static/js/wesp-zootech-notification-messages.js?v=1"></script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script src="/static/js/wesp-zootech-notify.js?v=6"></script>
<script src="/static/js/wesp-dialog.js"></script>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<style>
:root {
--surface-page: var(--bg-page, #f5f7fa);
--surface-card: var(--bg-white, #ffffff);
--surface-muted: #f2f5f9;
--border-subtle: var(--border-light, #e2e8f0);
--border-strong: #cbd5e1;
--text-primary: var(--text-dark, #0f172a);
--text-secondary: var(--text-muted, #64748b);
--font-ui: var(--zootech-font);
--font-numeric: var(--zootech-font);
--box-shadow-panel: var(--box-shadow-hover);
--locked-field-bg: #eef2f7;
--locked-field-border: #d8dee8;
--locked-field-text: #334155;
--route-icon-ratio: 0.7; /* MD location_on: 14/20 после обрезки viewBox */
}
html[data-theme="dark"] {
--surface-page: var(--bg-page);
--surface-card: var(--bg-white);
--surface-muted: #252b30;
--border-subtle: var(--border-light);
--border-strong: rgba(255, 255, 255, 0.2);
--text-primary: var(--text-dark);
--text-secondary: var(--text-muted);
--locked-field-bg: var(--dm-field-disabled, #353a40);
--locked-field-border: rgba(255, 255, 255, 0.22);
--locked-field-text: var(--dm-text, #f5f5f5);
}
html[data-theme="organic"] {
--surface-page: var(--bg-page);
--surface-card: var(--bg-white);
--surface-muted: #eff4f2;
--border-subtle: var(--border-light);
--border-strong: #d8ddd8;
--text-primary: var(--text-dark);
--text-secondary: var(--text-muted);
--locked-field-bg: #eff4f2;
--locked-field-border: #e8e8e2;
--locked-field-text: var(--text-dark);
}
.page-content {
flex: 1 0 auto;
}
/* Карточки и списки (enterprise light) */
.card {
background: var(--surface-card);
border-radius: 14px;
box-shadow: var(--box-shadow);
margin-bottom: 1.5rem;
border: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
height: 100%;
}
.card .card-header {
background-color: var(--surface-card) !important;
border-bottom: 1px solid var(--border-subtle);
padding: 1rem 1.25rem;
font-weight: 600;
color: var(--text-primary);
border-radius: 14px 14px 0 0;
}
/* Дашборд: компактная шапка, периметр карточки — у родителя (overflow + radius) */
.dashboard-summary-row .card.dashboard-stat-card > .card-header {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem 0.375rem;
margin: 0;
min-height: 0;
line-height: 1.25;
font-size: 0.875rem;
font-weight: 600;
border-bottom: none !important;
border-radius: 0;
background-color: var(--surface-card) !important;
}
.dashboard-summary-row .card.dashboard-stat-card > .card-header i {
font-size: 0.8125rem;
line-height: 1;
}
.dashboard-summary-row .card.dashboard-stat-card > .card-header .btn-sm {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
line-height: 1.2;
}
/* Появление трёх верхних карточек (контейнеры; keyframes — wesp-recipes-skeleton.css) */
/* Верхний ряд: рейсы — по контенту/выше; оборудование и периоды — компактно */
.dashboard-summary-row {
margin-bottom: 0.25rem;
align-items: flex-start;
}
.dashboard-summary-row > [class*="col-"] {
min-width: 0;
}
#dispensersCol,
#periodsCol {
align-self: flex-start;
}
#periodsCol.dashboard-col--idle {
display: none !important;
}
#dispensersCol .dashboard-stat-card,
#periodsCol .dashboard-stat-card {
height: auto;
}
#dispensersCol .dashboard-stat-card > .card-body,
#periodsCol .dashboard-stat-card > .card-body {
flex: 0 0 auto;
}
#recipesCol {
align-self: flex-start;
}
#recipesCol.dashboard-col--idle {
display: none !important;
}
#recipesCol:not(.dashboard-col--idle) .dashboard-stat-card {
height: auto;
}
#recipesCol:not(.dashboard-col--idle) .dashboard-stat-card > .card-body {
flex: 0 0 auto;
}
.dashboard-summary-row .dashboard-stat-card {
width: 100%;
display: flex;
flex-direction: column;
}
.dashboard-stat-card {
border-radius: 16px;
overflow: hidden;
background: var(--surface-card);
box-shadow: var(--box-shadow);
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.dashboard-summary-row .dashboard-stat-card > .card-body {
padding: 0.375rem 0.75rem 0.75rem;
flex: 0 0 auto;
}
.dashboard-summary-row .list-container {
flex: none;
display: flex;
flex-direction: column;
height: auto !important;
min-height: 0;
max-height: none;
overflow-x: hidden;
overflow-y: visible;
transition: max-height 0.7s cubic-bezier(0.33, 1, 0.68, 1);
padding: 0 2px;
}
.dashboard-summary-row .list-container.dashboard-list-layout-settling {
transition: none !important;
}
.dashboard-stat-card > .card-header i,
.dashboard-stat-card > .card-header .route-icon {
color: var(--primary-color);
opacity: 0.95;
}
/* MD location_on: контент 14×20 в исходном 24×24 — подгонка под FA (1em по высоте) */
.dashboard-stat-card > .card-header .route-icon {
width: calc(1em * var(--route-icon-ratio));
height: 1em;
}
#recipesCol .dashboard-stat-card > .card-header > div:first-child {
display: inline-flex;
align-items: center;
}
/*
* Светлая тема: те же рамка и тень, что у .recipe-edit-panel — не выбиваться из блока редактирования.
*/
html:not([data-theme="dark"]) .dashboard-summary-row .dashboard-stat-card {
border: 1px solid var(--border-subtle);
box-shadow: var(--box-shadow-panel);
}
.card-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.list-container {
max-height: 70vh;
overflow-y: auto;
min-height: 200px;
padding: 8px 10px;
flex: 1;
}
/*
* Три верхних списка: по умолчанию без внутренней прокрутки (высота по контенту).
* На десктопе: до 4 карточек в каждом списке; дальше прокрутка (JS: dashboard-list-scroll-cap).
* padding — в блоке .dashboard-summary-row .list-container выше.
*/
.dashboard-summary-row .list-container.list-container--loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 6rem;
}
.dashboard-summary-row .list-container.list-container--loading > .zt-loading-list {
flex: none;
min-height: 0;
width: 100%;
}
.dashboard-summary-row .list-container > .zt-loading-list {
width: 100%;
}
.dashboard-summary-row .list-container.dashboard-list-scroll-cap {
overflow-y: auto;
overscroll-behavior: contain;
}
@media (min-width: 769px) {
#recipesList.dashboard-list-scroll-cap {
overflow-y: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-summary-row .dashboard-stat-card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.dashboard-summary-row .list-container {
transition: none;
}
}
.list-container::-webkit-scrollbar {
width: 6px;
}
.list-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.list-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.list-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.list-item {
background: white;
border-radius: 10px;
padding: 0.875rem 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: var(--transition);
border: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.dashboard-summary-row .list-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 1.75rem;
}
.dashboard-summary-row .list-item-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
min-width: 0;
line-height: 1.3;
}
html[data-theme="organic"] .dashboard-summary-row .list-item-title > i.fas {
color: var(--primary-color);
}
.dashboard-summary-row .list-item-leading {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.dashboard-summary-row .list-item-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: nowrap;
min-height: 2rem;
}
.dashboard-summary-row .list-item-meta--actions {
justify-content: flex-end;
}
.dashboard-summary-row .list-item-meta--actions .btn-group {
flex-wrap: nowrap;
}
.dashboard-summary-row .list-item-meta--actions .btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
white-space: nowrap;
}
.dashboard-summary-row .list-item-meta small {
font-size: 0.8125rem;
}
.dashboard-summary-row .list-item-actions-row,
.dashboard-summary-row .list-item-footer {
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 1.75rem;
}
#dispensersCol .list-container,
#periodsCol .list-container {
padding: 0 2px;
}
#dispensersCol .list-item,
#periodsCol .list-item {
min-height: 5.5rem;
}
.dashboard-summary-row .list-item:last-child {
margin-bottom: 0;
}
.dashboard-stat-card > .card-header .route-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
flex-shrink: 0;
}
.dashboard-stat-card > .card-header .route-icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
.list-item:hover {
transform: none;
box-shadow: var(--box-shadow-hover);
border-color: var(--primary-color);
}
/* Дашборд: без поднятия — подсветка строки как в zt-data-grid */
.dashboard-summary-row .list-container .list-item {
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.dashboard-summary-row .list-container .list-item:hover {
transform: none;
box-shadow: none;
background-color: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary-color);
}
.dashboard-summary-row .list-container .list-item.active {
transform: none;
box-shadow: none;
background-color: rgba(var(--primary-rgb), 0.08);
border-color: var(--primary-color);
}
/* Внутренние карточки в 3 верхних блоках: заметнее граница (светлая тема) */
html:not([data-theme="dark"]) .dashboard-summary-row .list-container .list-item {
border: 1px solid var(--border-strong);
}
.list-item.active {
border-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.05);
}
.badge {
padding: 0.35em 0.75em;
font-weight: 500;
border-radius: 20px;
}
/* SVG иконка для рейсов */
.route-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
.route-icon svg {
display: block;
width: 1em;
height: 1em;
fill: currentColor;
}
/* SVG иконка "корова" для "голов" */
.cow-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: 0.12em;
}
.cow-icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
/* Заблокированные (неизменяемые) поля в режиме замка СВ — сплошная заливка без штриховки */
.mode-locked-field {
background-color: var(--locked-field-bg) !important;
background-image: none !important;
border-color: var(--locked-field-border) !important;
color: var(--locked-field-text) !important;
opacity: 1 !important;
cursor: not-allowed;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2);
}
.mode-locked-field:disabled,
.mode-locked-field[readonly] {
-webkit-text-fill-color: var(--locked-field-text);
}
/*
* Заблокированные поля в форме (в т.ч. «Процент на рейс»): перебиваем Bootstrap :disabled,
* чтобы совпадало с .mode-locked-field в таблицах.
*/
#recipeEdit .form-control.mode-locked-field,
#recipeEdit .form-control.mode-locked-field:disabled,
#recipeEdit .form-control.mode-locked-field[readonly] {
background-color: var(--locked-field-bg) !important;
background-image: none !important;
border-color: var(--locked-field-border) !important;
color: var(--locked-field-text) !important;
opacity: 1 !important;
cursor: not-allowed;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2);
}
#recipeEdit .form-control.mode-locked-field:disabled,
#recipeEdit .form-control.mode-locked-field[readonly] {
-webkit-text-fill-color: var(--locked-field-text);
}
/* Строка с действиями — сверху карточки рейса */
#recipesList .recipe-actions-row {
width: 100%;
gap: 0.5rem;
}
#recipesList .recipe-card-row-start {
gap: 0.35rem;
}
/* Drag-handle — wesp-recipes-editor.css (.recipe-drag-handle) */
#recipesList .list-item.recipe-card-dragging {
opacity: 0.65;
}
#recipesList .list-item.recipe-card-drop-target {
outline: 2px dashed var(--primary-color, #0d6efd);
outline-offset: 2px;
}
/* Перенос рейса: допустимые цели (как drop-target + лёгкий pulse) */
@keyframes recipe-transfer-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(13, 110, 253, 0.2);
}
50% {
box-shadow: 0 4px 14px rgba(13, 110, 253, 0.18);
}
}
body.recipe-period-transfer-active .dashboard-summary-row .recipe-transfer-eligible.list-item {
outline: 2px dashed var(--primary-color, #0d6efd);
outline-offset: 2px;
animation: recipe-transfer-pulse 1.25s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
body.recipe-period-transfer-active .dashboard-summary-row .recipe-transfer-eligible.list-item {
animation: none;
}
}
button.recipe-transfer-slot {
display: block;
width: 100%;
box-sizing: border-box;
border: 1px dashed var(--primary-color, #0d6efd);
background: rgba(13, 110, 253, 0.06);
border-radius: var(--bs-border-radius, 0.375rem);
padding: 0.35rem 0.5rem;
margin: 0.35rem 0;
cursor: pointer;
font-size: 0.85rem;
color: var(--primary-color, #0d6efd);
transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
}
.recipe-transfer-slot-inner {
display: flex;
align-items: center;
justify-content: center;
min-height: 1.25rem;
}
button.recipe-transfer-slot:hover {
background: rgba(13, 110, 253, 0.12);
border-style: solid;
}
html[data-theme="dark"] button.recipe-transfer-slot {
background: rgba(13, 110, 253, 0.14);
color: var(--dm-text, #e9ecef);
border-color: rgba(100, 180, 255, 0.55);
}
#recipesList .recipe-header {
min-width: 0;
}
#recipesList .recipe-title {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
#recipesList .recipe-title-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
text-overflow: ellipsis;
white-space: normal;
}
#recipesList .recipe-plan-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.65rem;
margin-top: 0.3rem;
min-width: 0;
}
.recipe-skip-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--bs-danger, #dc3545);
white-space: nowrap;
}
.recipe-skip-badge__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--bs-danger, #dc3545);
flex-shrink: 0;
}
.recipe-skip-restore-btn {
font-size: 0.6875rem;
padding: 0.1rem 0.4rem;
vertical-align: middle;
}
.dashboard-skip-warning {
display: inline-flex;
align-items: center;
color: var(--bs-warning, #ffc107);
font-size: 0.85rem;
flex-shrink: 0;
}
.dashboard-skip-warning .fa-exclamation-triangle {
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.15));
}
.recipe-row-name-with-skip {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.recipe-row-name-with-skip .form-control,
.recipe-row-name-with-skip .ingredient-select {
flex: 1;
min-width: 0;
}
.recipe-row-skip-dot {
flex-shrink: 0;
}
.recipe-row-adjust-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #d97706;
flex-shrink: 0;
}
.recipe-adjust-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
color: #d97706;
white-space: nowrap;
max-width: 100%;
}
#recipesList .recipe-plan-badges .recipe-skip-badge,
#recipesList .recipe-plan-badges .recipe-adjust-badge {
margin-left: 0 !important;
}
.ingredient-mobile-name,
.unloading-group-mobile-name {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.ingredient-row--skipped,
.unloading-group-row--skipped {
opacity: 0.78;
}
#recipesList .recipe-actions {
flex-shrink: 0;
white-space: nowrap;
}
/* Кнопки действий карточки рейса — wesp-recipes-editor.css (.recipe-table-*) */
/* Перестановка стрелками: FLIP-анимация на карточках (см. recipes-data-controller.js) */
:root {
--recipe-card-flip-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
}
html[data-theme="dark"] {
--recipe-card-flip-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
/* Только блокировка кликов; без opacity/transition — иначе при снятии класса вспыхивает и ломает FLIP */
#recipesList.recipes-list-reorder-pending {
pointer-events: none;
}
#recipesList .list-item.recipe-card-reorder-flip {
border-radius: var(--bs-border-radius, 0.375rem);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* Не transition: all — иначе после снятия inline-стилей карточка на долю секунды «перетягивает» transform и мигает */
#recipesList > .list-item {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease, border-color 0.2s ease,
background-color 0.2s ease;
}
.loading {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.loading::after {
content: '';
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--primary-color);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
margin-left: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.alert {
border-radius: 8px;
border: none;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Dark theme для карточек и списков */
html[data-theme="dark"] .card,
html[data-theme="dark"] .card-body,
html[data-theme="dark"] .list-item {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .card .card-header {
background-color: inherit !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .list-item:hover {
border-color: rgba(255, 255, 255, 0.22) !important;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25) !important;
}
html[data-theme="dark"] .dashboard-summary-row .list-container .list-item:hover {
transform: none;
box-shadow: none !important;
background-color: rgba(var(--primary-rgb), 0.15) !important;
border-color: rgba(255, 255, 255, 0.25) !important;
}
html[data-theme="dark"] .list-item.active {
background-color: rgba(var(--primary-rgb), 0.15) !important;
border-color: rgba(255, 255, 255, 0.25) !important;
}
html[data-theme="dark"] .dashboard-summary-row .list-container .list-item.active {
background-color: rgba(var(--primary-rgb), 0.18) !important;
border-color: rgba(255, 255, 255, 0.28) !important;
}
html[data-theme="dark"] .list-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .list-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
}
html[data-theme="dark"] .list-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
html[data-theme="dark"] .alert {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .alert-info {
background-color: rgba(var(--primary-rgb), 0.15) !important;
color: var(--dm-text) !important;
border-color: rgba(var(--primary-rgb), 0.3) !important;
}
html[data-theme="dark"] .alert-danger {
background-color: rgba(220, 53, 69, 0.15) !important;
color: var(--dm-text) !important;
border-color: rgba(220, 53, 69, 0.3) !important;
}
html[data-theme="dark"] .btn-sm {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .btn-outline-primary {
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .btn-outline-primary:hover {
background-color: rgba(var(--primary-rgb), 0.2) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="dark"] .btn-outline-info {
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .btn-outline-info:hover {
background-color: rgba(23, 162, 184, 0.2) !important;
border-color: #17a2b8 !important;
}
/* Компактные кнопки в dashboard-карточках */
.dashboard-summary-row .btn-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.dashboard-summary-row .btn-group .btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0.25rem 0.625rem;
font-size: 0.8125rem;
line-height: 1.2;
}
.dashboard-summary-row .btn-group .btn i {
font-size: 0.75rem;
}
/* Карточки рейсов: действия — wesp-recipes-editor.css */
/* Общие btn-group (вне dashboard) */
.btn-group:not(.recipe-actions) {
display: flex;
gap: 0.375rem;
}
/* Стиль для маленькой кнопки */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Контурная синяя кнопка "Добавить рейс" — базовые стили в wesp-zootech-components.css */
/* Стили для кнопки "Вставить" когда она заблокирована */
.paste-recipe-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.container { padding: 0 0.5rem; }
.desktop-menu {
display: none !important;
}
.hamburger-menu {
display: flex !important;
}
.user-section {
display: none !important;
}
.col-md-4 {
margin-bottom: 1rem;
}
.card {
margin-bottom: 1rem;
}
.card:not(.dashboard-stat-card) .card-header {
padding: 0.75rem;
font-size: 0.95rem;
}
.card-body {
padding: 1rem;
}
.list-item {
padding: 1rem;
margin-bottom: 1rem;
}
.list-item h4 {
font-size: 1.1rem;
}
.list-container {
max-height: 50vh;
}
.dashboard-summary-row .list-container {
max-height: none !important;
}
}
/* Модальное окно настроек */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: #fff;
margin: 5% auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.16), 0 0 0 1px rgba(17, 24, 39, 0.06);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
color: white;
padding: 1.5rem;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
.modal-header h3 i {
margin-right: 0.5rem;
color: #ffd700;
}
.close {
color: white;
font-size: 2rem;
font-weight: bold;
cursor: pointer;
transition: var(--transition);
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close:hover {
background-color: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.modal-body {
padding: 2rem;
}
.modal-body .form-group {
margin-bottom: 1.5rem;
}
.modal-body label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
display: block;
}
.modal-body .form-control {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
transition: var(--transition);
background-color: #fafafa;
}
.modal-body .form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1);
background-color: white;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 1rem;
background-color: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.modal-footer .btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: var(--transition);
min-width: 120px;
}
.modal-footer .btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
}
.modal-footer .btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
transform: translateY(-2px);
}
.modal-footer .btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
border: none;
color: white;
}
.modal-footer .btn-primary:hover {
background: linear-gradient(135deg, var(--primary-dark), var(--primary-darker));
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
@media (max-width: 768px) {
.modal-content {
margin: 10% auto;
width: 95%;
max-width: none;
}
.modal-header {
padding: 1rem;
}
.modal-header h3 {
font-size: 1.1rem;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
flex-direction: column;
}
.modal-footer .btn {
width: 100%;
margin-bottom: 0.5rem;
}
}
/* Стили для формы редактирования рецепта */
#recipeEdit {
margin-top: 2rem;
}
#recipeEdit .recipe-edit-panel {
border-radius: 16px;
border: 1px solid var(--border-subtle);
box-shadow: var(--box-shadow-panel);
overflow: hidden;
}
/* Edit skeleton — wesp-recipes-skeleton.css */
#recipeEdit .recipe-edit-header {
padding: 1.25rem 1.5rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-bottom: 1px solid var(--border-subtle);
}
#recipeEdit .recipe-edit-header-inner {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem 1.25rem;
}
#recipeEdit .recipe-edit-header-text {
flex: 1 1 12rem;
min-width: 0;
}
#recipeEdit .recipe-edit-header-actions {
flex: 1 1 auto;
min-width: 0;
}
#recipeEdit .recipe-edit-header-actions .d-flex {
justify-content: flex-end;
}
#recipeEdit .recipe-edit-header-icon-btn i {
font-size: 1rem;
}
#recipeEdit .recipe-form-footer-actions {
margin-top: 0.5rem;
padding: 1rem 1.5rem 1.25rem;
border-top: 1px solid var(--border-subtle);
}
#recipeEdit .recipe-form-footer-actions .btn {
min-height: 2.25rem;
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
}
#recipeEdit .recipe-form-footer-actions .recipe-save-exit-btn {
min-width: 10rem;
}
#recipeEdit .recipe-edit-header h3 {
font-weight: 600;
font-size: 1.35rem;
color: var(--text-primary);
letter-spacing: -0.02em;
}
#recipeEdit .recipe-edit-subtitle {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
#recipeEdit .recipe-edit-panel--loading {
position: relative;
min-height: 20rem;
}
#recipeEdit .recipe-edit-panel--loading .recipe-edit-skeleton-panel {
position: absolute;
inset: 0;
z-index: 2;
background: var(--surface, #fff);
min-height: 100%;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-panel--loading .recipe-edit-skeleton-panel {
background: var(--dm-surface);
}
#recipeEdit #recipeForm > .card-body {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 1.5rem 0;
}
/* Единая сетка шапки: заголовок | переключатель | Добавить (колонки совпадают в обеих карточках) */
#recipeEdit .recipe-section-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.5rem;
align-items: center;
}
#recipeEdit .recipe-section-toolbar-title {
min-width: 0;
}
#recipeEdit .recipe-section-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
min-width: 2rem;
min-height: 2rem;
padding: 0;
}
#recipeEdit .recipe-section-toolbar > .btn-primary {
min-height: 2rem;
padding: 0.35rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
}
#recipeEdit .recipe-dry-matter-toggle-wrap {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
#recipeEdit .recipe-help-icon-btn {
cursor: pointer;
line-height: 1;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: var(--primary-color);
border-radius: 50%;
flex-shrink: 0;
align-self: center;
text-decoration: none !important;
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
#recipeEdit .recipe-help-icon-btn i {
font-size: 0.95rem;
}
#recipeEdit .recipe-help-icon-btn:hover,
#recipeEdit .recipe-help-icon-btn:focus {
color: var(--primary-dark);
background: rgba(var(--primary-rgb), 0.14);
outline: none;
}
#recipeEdit .recipe-help-icon-btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
html[data-theme="dark"] #recipeEdit .recipe-help-icon-btn:hover,
html[data-theme="dark"] #recipeEdit .recipe-help-icon-btn:focus {
color: #64b5f6;
background: rgba(100, 181, 246, 0.12);
}
#recipeEdit .recipe-field-label-with-help {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem 0.35rem;
margin-bottom: 0.5rem;
}
#recipeEdit .recipe-field-label-with-help label {
margin-bottom: 0;
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
}
/* Модалка справки (СВ, рейс, группы выгрузки) */
.recipe-help-modal[hidden] {
display: none !important;
}
.recipe-help-modal:not([hidden]) {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
body.recipe-help-modal-open {
overflow: hidden;
}
.recipe-help-modal .recipe-help-modal-panel {
position: relative;
display: flex;
flex-direction: column;
max-height: min(82dvh, 560px);
background: var(--surface-card, #fff);
border-radius: 16px 16px 0 0;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(15, 23, 42, 0.15);
}
.recipe-help-modal-body .zt-notice--static {
box-shadow: none;
}
@media (min-width: 769px) {
.recipe-help-modal:not([hidden]) {
display: none !important;
}
}
@media (max-width: 575.98px) {
#recipeEdit .recipe-section-toolbar {
grid-template-columns: 1fr auto;
}
#recipeEdit .recipe-section-toolbar-title {
grid-column: 1 / -1;
}
#recipeEdit .recipe-dry-matter-toggle-wrap {
justify-self: start;
}
#recipeEdit .recipe-section-toolbar > .btn-primary {
justify-self: end;
}
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
display: block;
font-size: 0.875rem;
}
.form-control {
border-radius: 10px;
border: 1px solid var(--border-strong);
padding: 0.55rem 0.9rem;
transition: var(--transition);
background: var(--surface-card);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25);
outline: none;
}
.btn-outline-primary.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.text-primary {
color: var(--primary-color) !important;
}
/* Bootstrap badge: тот же акцент, что и в зоотех-оболочке (не дефолтный --bs-primary) */
.badge.bg-primary {
background-color: var(--primary-color) !important;
}
/* Dark theme для формы */
html[data-theme="dark"] .form-group label {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .form-control {
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .form-control:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25) !important;
}
/* В темной теме делаем карточки более "плоскими" (без теней) */
html[data-theme="dark"] .card,
html[data-theme="dark"] #recipeEdit .card {
box-shadow: none !important;
}
html[data-theme="dark"] .table th {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
border-color: transparent !important;
border-bottom: none !important;
}
html[data-theme="dark"] .table td {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
border-color: transparent !important;
}
/* Убираем линии таблиц в темной теме (делаем блоки "без границ") */
html[data-theme="dark"] .table > :not(caption) > * > * {
border-bottom: none !important;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-header {
background: var(--dm-surface) !important;
border-bottom-color: var(--dm-border) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-header h3 {
color: var(--dm-text) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-subtitle {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-form-footer-actions {
border-top-color: var(--dm-border) !important;
}
/* Тёмная тема: любые .mode-locked-field в форме (не только в ячейках) — как в grid */
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field,
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field:disabled,
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field[readonly] {
background-color: #23292f !important;
background-image: none !important;
border: 1px solid rgba(255, 255, 255, 0.38) !important;
color: var(--dm-text) !important;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35) !important;
opacity: 1 !important;
min-height: 2.5rem;
line-height: 1.5;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field:disabled,
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field[readonly] {
-webkit-text-fill-color: var(--dm-text) !important;
}
html[data-theme="dark"] #recipeEdit .form-control.mode-locked-field:focus {
border-color: rgba(255, 255, 255, 0.42) !important;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35) !important;
}
/* Правая закладка и панель «Клиенты синхронизации» */
.sync-panel-tab {
position: fixed;
top: 50%;
right: 0;
transform: translateY(-50%);
z-index: 1001;
width: 32px;
height: 72px;
padding: 0;
border: none;
border-radius: 12px 0 0 12px;
background: var(--primary-color, #2196F3);
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
transition: background 0.2s, width 0.2s;
}
.sync-panel-tab:hover {
background: var(--primary-dark, #1976D2);
width: 36px;
}
.sync-panel-tab span {
display: block;
width: 14px;
height: 2px;
background: currentColor;
border-radius: 1px;
}
html[data-theme="dark"] .sync-panel-tab {
background: var(--dm-field, #5a5959);
color: var(--dm-text);
}
html[data-theme="dark"] .sync-panel-tab:hover {
background: var(--dm-border);
}
.sync-panel-overlay {
position: fixed;
inset: 0;
z-index: 1001;
background: rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sync-panel-overlay.open {
opacity: 1;
pointer-events: auto;
}
html[data-theme="dark"] .sync-panel-overlay {
background: rgba(0, 0, 0, 0.5);
}
.sync-panel {
position: fixed;
top: 0;
right: 0;
width: min(400px, 90vw);
max-width: 100%;
height: 100vh;
background: #fff;
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
z-index: 1002;
transform: translateX(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sync-panel.open {
transform: translateX(0);
}
html[data-theme="dark"] .sync-panel {
background: var(--dm-bg);
color: var(--dm-text);
box-shadow: -4px 0 20px rgba(0,0,0,0.4);
}
.sync-panel-header {
flex-shrink: 0;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0,0,0,0.08);
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fa;
}
html[data-theme="dark"] .sync-panel-header {
border-color: var(--dm-border);
background: var(--dm-surface);
}
.sync-panel-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.sync-panel-close {
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
transition: background 0.2s, color 0.2s;
}
.sync-panel-close:hover {
background: rgba(0,0,0,0.06);
color: #333;
}
html[data-theme="dark"] .sync-panel-close {
color: var(--dm-muted);
}
html[data-theme="dark"] .sync-panel-close:hover {
background: var(--dm-border);
color: var(--dm-text);
}
.sync-panel-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.sync-client-card {
display: block;
width: 100%;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s;
}
.sync-client-card:hover {
border-color: var(--primary-color, #2196F3);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.15);
}
html[data-theme="dark"] .sync-client-card {
background: var(--dm-surface);
border-color: var(--dm-border);
}
html[data-theme="dark"] .sync-client-card:hover {
border-color: var(--primary-color);
}
.sync-client-card .name {
font-weight: 600;
margin-bottom: 0.35rem;
}
html[data-theme="dark"] .sync-client-card .name {
color: #fff;
}
.sync-client-card .meta {
font-size: 0.85rem;
color: #666;
}
html[data-theme="dark"] .sync-client-card .meta {
color: var(--dm-muted);
}
.sync-deliveries-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.sync-deliveries-back {
padding: 0.35rem 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
html[data-theme="dark"] .sync-deliveries-back {
background: var(--dm-field);
border-color: var(--dm-border);
color: var(--dm-text);
}
.sync-deliveries-list {
list-style: none;
padding: 0;
margin: 0;
}
.sync-deliveries-list li {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-radius: 10px;
background: #f5f5f5;
font-size: 0.9rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
html[data-theme="dark"] .sync-deliveries-list li {
background: var(--dm-field);
}
.sync-deliveries-list .sync-delivery-context {
font-size: 0.8rem;
color: #555;
font-variant-numeric: tabular-nums;
}
html[data-theme="dark"] .sync-deliveries-list .sync-delivery-context {
color: var(--dm-muted);
}
.sync-deliveries-list .sync-delivery-name {
font-weight: 600;
color: #111;
}
html[data-theme="dark"] .sync-deliveries-list .sync-delivery-name {
color: var(--dm-text);
}
.sync-deliveries-list .sync-delivery-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.8rem;
color: #666;
}
html[data-theme="dark"] .sync-deliveries-list .sync-delivery-footer {
color: var(--dm-muted);
}
.sync-deliveries-list .sync-delivery-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sync-deliveries-list .table-badge {
padding: 0.15rem 0.4rem;
border-radius: 6px;
background: var(--primary-color);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
}
.sync-deliveries-list .action-badge {
font-size: 0.75rem;
color: #666;
}
html[data-theme="dark"] .sync-deliveries-list .action-badge {
color: var(--dm-muted);
}
.sync-no-data {
padding: 1.5rem 1rem;
text-align: center;
color: #666;
font-size: 0.95rem;
}
html[data-theme="dark"] .sync-no-data {
color: var(--dm-muted);
}
</style>
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-app-body">
<div id="wespZootechNavMount" data-active-page="recipes"></div>
<!-- Основной контент -->
<div class="page-content wesp-page-main">
<div class="container">
<div id="recipesMobileNav" class="recipes-mobile-nav" hidden>
<button type="button" class="btn btn-outline-secondary btn-sm recipes-mobile-nav-back" data-action="mobile-dashboard-back" aria-label="Назад">
<i class="fas fa-arrow-left me-1" aria-hidden="true"></i>
Назад
</button>
<span id="recipesMobileNavTitle" class="recipes-mobile-nav-title"></span>
</div>
<div id="recipesDashboard">
<div class="row dashboard-summary-row g-3">
<!-- Оборудование -->
<div id="dispensersCol" class="col-md-4" data-wesp-enter>
<div class="card dashboard-stat-card w-100">
<div class="card-header">
<i class="fas fa-server"></i>
Оборудование
</div>
<div class="card-body">
<div id="dispensersList" class="list-container" aria-busy="true">
<div class="zt-loading-list dashboard-list-skeleton" data-skeleton-variant="dispensers" aria-label="Загрузка кормораздатчиков">
<div class="zt-loading-placeholder">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">Загрузка кормораздатчиков…</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Периоды кормления -->
<div id="periodsCol" class="col-md-4 dashboard-col--idle" data-wesp-enter>
<div class="card dashboard-stat-card w-100">
<div class="card-header">
<i class="fas fa-calendar me-2"></i>
Периоды кормления
</div>
<div class="card-body">
<div id="periodsList" class="list-container"></div>
</div>
</div>
</div>
<!-- Рецепты / Рейсы -->
<div id="recipesCol" class="col-md-4 dashboard-col--idle" data-wesp-enter>
<div class="card dashboard-stat-card w-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<span id="recipesCardIcon" class="route-icon me-2">
<svg viewBox="5 2 14 20" fill="currentColor" aria-hidden="true">
<path d="M12 22q-2.65 0-4.325-.837T6 19q0-.6.363-1.112T7.374 17l1.575 1.475q-.225.1-.488.225t-.412.3q.325.4 1.5.7T12 20t2.463-.3t1.512-.7q-.175-.2-.45-.325T15 18.45l1.55-1.5q.7.4 1.075.913T18 19q0 1.325-1.675 2.163T12 22m0-3q-3.525-2.6-5.262-5.05T5 9.15q0-1.775.638-3.113T7.275 3.8t2.25-1.35T12 2t2.475.45t2.25 1.35t1.638 2.238T19 9.15q0 2.35-1.737 4.8T12 19m0-8q.825 0 1.413-.587T14 9t-.587-1.412T12 7t-1.412.588T10 9t.588 1.413T12 11"/>
</svg>
</span>
<span id="recipesCardHeaderText">Рейсы</span>
</div>
<button id="createRecipeBtn" class="btn btn-sm btn-primary" title="Создать рецепт" style="display: none;">
<i class="fas fa-plus me-1"></i>
Создать рецепт
</button>
</div>
<div class="card-body">
<div id="recipesList" class="list-container"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Скелетоны: в DOM до module-скрипта (единый источник разметки для загрузки) -->
<template id="recipe-card-skeleton-inner">
<div class="dashboard-skeleton-card-inner">
<div class="dashboard-skeleton-flex-row dashboard-skeleton-flex-row--actions">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--actions-left"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--actions-right dashboard-skeleton-bar--narrow"></div>
</div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle dashboard-skeleton-bar--narrow"></div>
<div class="dashboard-skeleton-flex-row dashboard-skeleton-flex-row--meta">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta dashboard-skeleton-bar--narrow"></div>
</div>
</div>
</template>
<template id="recipe-card-skeleton-inner--mill">
<div class="dashboard-skeleton-card-inner dashboard-skeleton-card-inner--mill">
<div class="dashboard-skeleton-flex-row dashboard-skeleton-flex-row--actions-end">
<div class="dashboard-skeleton-pill"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle dashboard-skeleton-bar--narrow"></div>
<div class="dashboard-skeleton-flex-row dashboard-skeleton-flex-row--meta">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta dashboard-skeleton-bar--narrow"></div>
</div>
</div>
</template>
<template id="dashboard-list-skeleton-dispensers">
<div class="dashboard-list-skeleton" data-skeleton-variant="dispensers" aria-busy="true" aria-label="Загрузка кормораздатчиков">
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half dashboard-skeleton-bar--narrow"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half dashboard-skeleton-bar--narrow"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half dashboard-skeleton-bar--narrow"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta-half dashboard-skeleton-bar--narrow"></div>
</div>
</div>
</div>
</template>
<template id="dashboard-list-skeleton-periods">
<div class="dashboard-list-skeleton" data-skeleton-variant="periods" aria-busy="true" aria-label="Загрузка периодов">
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot dashboard-skeleton-row-foot--end">
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot dashboard-skeleton-row-foot--end">
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot dashboard-skeleton-row-foot--end">
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
</div>
</div>
<div class="dashboard-skeleton-row">
<div class="dashboard-skeleton-row-head">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--head-title"></div>
<div class="dashboard-skeleton-pill"></div>
</div>
<div class="dashboard-skeleton-row-foot dashboard-skeleton-row-foot--end">
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
<div class="dashboard-skeleton-pill dashboard-skeleton-pill--wide"></div>
</div>
</div>
</div>
</template>
<!-- Форма редактирования рецепта -->
<div id="recipeEdit" style="display: none;">
<div class="card recipe-edit-panel">
<div id="recipeEditSkeletonPanel" class="recipe-edit-skeleton-panel zt-loading-panel" hidden aria-busy="true" aria-label="Загрузка рецепта">
<div class="zt-spinner zt-spinner--lg" role="status"></div>
<p class="zt-loading-caption">Загрузка рецепта…</p>
</div>
<form id="recipeForm">
<div class="card-header recipe-edit-header">
<div class="recipe-edit-header-inner">
<div class="recipe-edit-header-text">
<h3 class="mb-1">
<i class="fas fa-edit me-2 text-primary"></i>
Редактирование рецепта
</h3>
<p class="recipe-edit-subtitle mb-0"></p>
</div>
<div class="recipe-edit-header-actions">
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary recipe-edit-header-icon-btn" data-action="print-recipe" title="Распечатать" aria-label="Распечатать">
<i class="fas fa-print" aria-hidden="true"></i>
</button>
<button type="submit" class="btn btn-primary recipe-edit-header-icon-btn" title="Сохранить" aria-label="Сохранить">
<i class="fas fa-save" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-outline-secondary recipe-edit-header-icon-btn" data-action="show-recipe-edit-off" title="Отмена" aria-label="Отмена">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="recipe-form-grid">
<div class="form-group">
<label for="recipeName">Название рецепта</label>
<input type="text" class="form-control" id="recipeName" required>
</div>
<div class="form-group">
<label for="headsCount">Количество голов</label>
<input type="number" class="form-control" id="headsCount" min="1" required>
</div>
<div class="form-group">
<label for="mixingTime">Время смешивания (мин)</label>
<input type="number" class="form-control" id="mixingTime" min="0" required>
</div>
<div class="form-group">
<label for="tripPercent" class="zt-form-label-with-help">
Процент на рейс (%)
<button type="button" class="recipe-help-icon-btn btn btn-link" id="tripPercentHelpIcon" data-action="open-recipe-help" aria-expanded="false" aria-label="Справка: поле «Процент на рейс» и таблица групп выгрузки">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</label>
<input type="number" class="form-control" id="tripPercent" min="0" value="100" step="0.1" placeholder="100">
</div>
</div>
<div id="tripPercentPlainHelp" class="recipe-plain-help zt-notice zt-notice--info" role="note" aria-live="polite"></div>
<div class="card mb-4 recipe-section-card">
<div class="card-header">
<div class="recipe-section-toolbar">
<h5 class="mb-0 recipe-section-toolbar-title">
<i class="fas fa-list me-2 text-primary"></i>
Ингредиенты
</h5>
<div class="recipe-dry-matter-toggle-wrap">
<button type="button" class="btn btn-outline-primary recipe-section-toggle-btn" id="toggleDryMatterMode" aria-label="Переключить режим расчёта по сухому веществу (замок)">
<i class="fas fa-unlock" id="dryMatterIcon" aria-hidden="true"></i>
<span id="dryMatterModeText" class="visually-hidden"></span>
</button>
<button type="button" class="recipe-help-icon-btn btn btn-link" id="dryMatterModeHelpIcon" data-action="open-recipe-help" aria-expanded="false" aria-label="Подсказка: что делает замок и столбцы СВ">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn btn-primary" data-action="add-ingredient">
<i class="fas fa-plus me-1"></i>
Добавить
</button>
</div>
</div>
<div class="card-body recipe-section-card__body--grid">
<div id="dryMatterPlainHelp" class="recipe-plain-help zt-notice zt-notice--info recipe-section-card__help" role="note"></div>
<div class="zt-data-grid__scroll">
<div class="zt-data-grid zt-data-grid--ingredients" id="ingredientsTable" role="grid" aria-label="Ингредиенты">
<div class="zt-data-grid__head" role="row">
<div class="zt-data-grid__cell zt-data-grid__cell--head" role="columnheader">Ингредиент</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ, %</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ/гол, кг</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Вес/гол, кг</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Вес, кг</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Вес/рейс, кг</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--actions" role="columnheader">Действия</div>
</div>
<div class="zt-data-grid__body" id="ingredientsTableBody" role="rowgroup"></div>
<div class="zt-data-grid__foot" role="rowgroup">
<div class="zt-data-grid__row zt-data-grid__row--total total-row" role="row">
<div class="zt-data-grid__cell zt-data-grid__cell--span-2">Итого:</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num" id="totalDryMatterPerHead">0</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num" id="totalWeightPerHead">0</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num" id="totalWeightIngredients">0</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num" id="totalTripWeight">0</div>
<div class="zt-data-grid__cell zt-data-grid__cell--actions"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Блок групп выгрузки (для кормораздатчика) -->
<div class="card mb-4 recipe-section-card" id="unloadingGroupsCard" style="display: none;">
<div class="card-header">
<div class="recipe-section-toolbar">
<h5 class="mb-0 recipe-section-toolbar-title">
<i class="fas fa-truck-loading me-2 text-primary"></i>
Группы выгрузки
</h5>
<div class="recipe-dry-matter-toggle-wrap">
<button type="button" class="btn btn-outline-primary recipe-section-toggle-btn" id="toggleUnloadingLinkBtn" data-action="toggle-unloading-link" title="Разорвать/восстановить связь с Процент на рейс (%)">
<i class="fas fa-link" id="unloadingLinkIcon" aria-hidden="true"></i>
</button>
<button type="button" class="recipe-help-icon-btn btn btn-link" id="unloadingLinkHelpIcon" data-action="open-recipe-help" aria-expanded="false" aria-label="Справка: кнопка с цепочкой и поле «Процент на рейс»">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</div>
<button type="button" class="btn btn-primary" data-action="add-unloading-group">
<i class="fas fa-plus me-1"></i>
Добавить
</button>
</div>
</div>
<div class="card-body recipe-section-card__body--grid">
<div id="unloadingLinkPlainHelp" class="recipe-plain-help zt-notice zt-notice--info recipe-section-card__help" role="note"></div>
<div class="zt-data-grid__scroll">
<div class="zt-data-grid zt-data-grid--unloading-groups" id="unloadingGroupsTable" role="grid" aria-label="Группы выгрузки">
<div class="zt-data-grid__head" role="row">
<div class="zt-data-grid__cell zt-data-grid__cell--head" role="columnheader">Название группы</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head" role="columnheader">Тип распределения</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Значение</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">В кг</div>
<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--actions" role="columnheader">Действия</div>
</div>
<div class="zt-data-grid__body" id="unloadingGroupsTableBody" role="rowgroup"></div>
<div class="zt-data-grid__foot" role="rowgroup">
<div class="zt-data-grid__row zt-data-grid__row--total total-row" role="row">
<div class="zt-data-grid__cell zt-data-grid__cell--foot-label">Итого:</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num">
<div class="zt-total-line">
<span class="zt-total-label">Головы</span>
<span class="zt-total-value" id="totalHeads" title="Сумма по группам «Количество голов» / голов в рецепте">0 / 0</span>
</div>
</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num">
<div class="zt-total-line">
<span class="zt-total-label">Проценты</span>
<span class="zt-total-value" id="totalPercent">0%</span>
</div>
</div>
<div class="zt-data-grid__cell zt-data-grid__cell--num">
<div class="zt-total-line">
<span class="zt-total-label">В кг</span>
<span class="zt-total-value" id="totalWeight">0 кг</span>
</div>
</div>
<div class="zt-data-grid__cell zt-data-grid__cell--actions"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Блок выбора компонента (для кормоцеха) -->
<div class="card mb-4 recipe-section-card" id="componentSelectionCard" style="display: none;">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-box me-2 text-primary"></i>
Выбор компонента
</h5>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label for="targetComponentSelect" class="form-label">Компонент для выгрузки</label>
<select class="form-select" id="targetComponentSelect">
<option value="">Выберите компонент...</option>
</select>
<small class="form-text text-muted">Выберите компонент, который будет использоваться для выгрузки в кормоцех</small>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between flex-wrap align-items-center gap-2 recipe-form-footer-actions">
<button type="button" class="btn btn-secondary" data-action="show-recipe-edit-off">
<i class="fas fa-times me-1"></i>
Отмена
</button>
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary" data-action="print-all-recipes">
<i class="fas fa-print me-1"></i>
Распечатать все рейсы
</button>
<button type="button" class="btn btn-outline-secondary" data-action="print-recipe">
<i class="fas fa-print me-1"></i>
Распечатать
</button>
<button type="button" class="btn btn-primary recipe-save-exit-btn" data-action="save-and-exit" title="Сохранить и выйти">
<i class="fas fa-save me-1"></i>
Сохранить и выйти
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="/static/js/wesp-zootech-bootstrap-modals.js?v=3"></script>
<script src="/static/js/modules/daily-plan-skip-duration.js?v=2"></script>
<script src="/static/js/modules/daily-plan-replace-component-modal.js?v=1"></script>
<script src="/static/js/pages/multiserver-panel.js?v=1"></script>
<script src="/static/js/pages/daily-plan-panel.js?v=11"></script>
<script src="/static/js/pages/daily-plan-component-norms-modal.js?v=23"></script>
<script src="/static/js/wesp-zootech-notification-center.js?v=13"></script>
<script type="module">
import { AppState } from '/static/js/modules/app-state.js';
import { initRecipesBootOverlay } from '/static/js/pages/recipes-boot.js';
import { initRecipesPage } from '/static/js/pages/recipes-page.js';
import { createRecipesAuthSettingsController } from '/static/js/pages/recipes-auth-settings.js';
import { initFeedQualitySettingsModal } from '/static/js/pages/feed-quality-settings-modal.js?v=3';
import { createRecipesDataController } from '/static/js/pages/recipes-data-controller.js';
import { createRecipesOperationsController } from '/static/js/pages/recipes-operations-controller.js?v=16';
import { createRecipesEditorController } from '/static/js/pages/recipes-editor-controller.js';
import { attachRecipeListDragDrop } from '/static/js/modules/recipes/recipe-list-dnd.js?v=20';
import { attachRecipePeriodTransfer } from '/static/js/modules/recipes/recipe-period-transfer.js';
import { resetRecipeMobileSheetPresentation } from '/static/js/modules/recipes/recipe-mobile-sheet-gestures.js';
initRecipesBootOverlay();
const notyf = window.WespZootechNotify.createAdapter();
const authSettingsController = createRecipesAuthSettingsController({ notyf });
initFeedQualitySettingsModal(notyf);
// Функции авторизации
async function checkAuth() {
return authSettingsController.checkAuth();
}
async function logout() {
return authSettingsController.logout();
}
// Мобильное меню
function setupMobileMenu() {
return authSettingsController.setupMobileMenu();
}
async function openSettings(event) {
return authSettingsController.openSettings(event);
}
function closeSettings() {
return authSettingsController.closeSettings();
}
function toggleSyncForm() {
return authSettingsController.toggleSyncForm();
}
async function loadSyncClientsSettings() {
return authSettingsController.loadSyncClientsSettings();
}
function editSyncClientSettings(btn) {
return authSettingsController.editSyncClientSettings(btn);
}
function saveSyncClientDisplayName(nodeId) {
return authSettingsController.saveSyncClientDisplayName(nodeId);
}
function deleteSyncClientSettings(btn) {
return authSettingsController.deleteSyncClientSettings(btn);
}
function toggleCredentialsForm() {
return authSettingsController.toggleCredentialsForm();
}
async function changeCredentials() {
return authSettingsController.changeCredentials();
}
// Глобальные переменные
let selectedDispenser = null;
let selectedPeriod = null;
let selectedRecipe = null;
/* Явные id удалённых строк уходят в save; на бэкенде пока не используются — пригодится для корзины/восстановления. */
let deletedIngredientIds = [];
let deletedGroupIds = [];
let componentsCache = null;
let currentDispenserType = 'dispenser'; // Тип устройства: 'dispenser' или 'mill'
let unloadingLinkBroken = false; // Состояние связи между группами выгрузки и Процент на рейс (%)
let lastSaveAt = 0;
let saveDebounceTimer = null;
let isSubmitting = false;
let calculateFromDryMatter = false; // Режим расчета от сухого вещества (замок СВ)
let isRecalculating = false; // Флаг для предотвращения рекурсивных вызовов
let calculationTimeout = null;
let calculationAbortController = null;
const SAVE_COOLDOWN_MS = 1000; // 1 секунда между сохранениями
const SAVE_DEBOUNCE_MS = 300; // 300ms задержка перед отправкой
const pageState = new AppState({
selectedDispenser: null,
selectedPeriod: null,
selectedRecipe: null
});
let recipesDataController = null;
/** Куда вернуть #recipeEdit после мобильного fullscreen (portal на body) */
let recipeEditMobileAnchor = null;
function portalRecipeEditForMobile(recipeEdit) {
if (!recipeEdit || recipeEdit.parentElement === document.body) return;
recipeEditMobileAnchor = recipeEdit.parentElement;
document.body.appendChild(recipeEdit);
}
function restoreRecipeEditFromMobilePortal(recipeEdit) {
if (!recipeEdit || !recipeEditMobileAnchor) return;
if (recipeEdit.parentElement === document.body) {
recipeEditMobileAnchor.appendChild(recipeEdit);
}
recipeEditMobileAnchor = null;
}
let recipesOperationsController = null;
let recipesEditorController = null;
// Нормализация чисел (запятая -> точка)
function normalizeNumber(value) {
if (typeof value === 'string') return value.replace(',', '.');
return value;
}
function formatNumber(num) {
return (parseFloat(num || 0)).toFixed(2);
}
function applyDryMatterModeToIngredientRow(row) {
if (!row) return;
const dryMatterPercentInput = row.querySelector('.dry-matter-percent-input');
const dryMatterPerHeadInput = row.querySelector('.dry-matter-per-head-input');
const weightPerHeadInput = row.querySelector('.weight-per-head-input');
const totalWeightInput = row.querySelector('.total-weight-input');
const setModeDisabled = (el, isDisabled) => {
if (!el) return;
el.disabled = !!isDisabled;
el.classList.toggle('mode-locked-field', !!isDisabled);
};
// СВ,% редактируем всегда, независимо от режима "замка СВ"
setModeDisabled(dryMatterPercentInput, false);
if (calculateFromDryMatter) {
setModeDisabled(dryMatterPerHeadInput, false);
setModeDisabled(weightPerHeadInput, true);
setModeDisabled(totalWeightInput, true);
} else {
setModeDisabled(dryMatterPerHeadInput, true);
setModeDisabled(weightPerHeadInput, false);
setModeDisabled(totalWeightInput, false);
}
}
// Setter режима расчета от сухого вещества (для восстановления из БД)
function setDryMatterMode(isLocked) {
const wasLocked = calculateFromDryMatter;
calculateFromDryMatter = !!isLocked;
const button = document.getElementById('toggleDryMatterMode');
const icon = document.getElementById('dryMatterIcon');
const text = document.getElementById('dryMatterModeText');
if (button && icon && text) {
if (calculateFromDryMatter) {
button.classList.remove('btn-outline-primary');
button.classList.add('btn-primary');
icon.classList.remove('fa-unlock');
icon.classList.add('fa-lock');
text.textContent = '';
} else {
button.classList.remove('btn-primary');
button.classList.add('btn-outline-primary');
icon.classList.remove('fa-lock');
icon.classList.add('fa-unlock');
text.textContent = '';
}
}
// При переключении режима сохраняем текущие значения dry_matter_per_head
// чтобы не потерять константу при включении "замка СВ"
if (calculateFromDryMatter && !wasLocked) {
// Включаем "замок СВ": сохраняем текущие значения СВ/гол
const rows = document.querySelectorAll('#ingredientsTableBody .ingredient-row');
rows.forEach(row => {
const dmPerHeadInput = row.querySelector('.dry-matter-per-head-input');
const dmPercentInput = row.querySelector('.dry-matter-percent-input');
const weightPerHeadInput = row.querySelector('.weight-per-head-input');
if (dmPerHeadInput && dmPercentInput && weightPerHeadInput) {
const currentDryMatter = parseFloat(normalizeNumber(dmPercentInput.value)) || 0;
const currentWeightPerHead = parseFloat(normalizeNumber(weightPerHeadInput.value)) || 0;
// Если СВ/гол не задан, рассчитываем из текущих данных
if (!dmPerHeadInput.value || parseFloat(dmPerHeadInput.value) === 0) {
if (currentDryMatter > 0 && currentWeightPerHead > 0) {
setInputValueWithoutTrigger(dmPerHeadInput, currentWeightPerHead * (currentDryMatter / 100));
}
}
}
});
}
// Применяем режим ко всем строкам
document.querySelectorAll('#ingredientsTableBody .ingredient-row').forEach(row => applyDryMatterModeToIngredientRow(row));
const ingredientsTable = document.getElementById('ingredientsTable');
if (ingredientsTable) {
ingredientsTable.classList.toggle('ingredients-table--dm-locked', calculateFromDryMatter);
ingredientsTable.classList.toggle('ingredients-table--dm-unlocked', !calculateFromDryMatter);
}
syncIngredientSheetDryMatterClass();
// Пересчитываем веса с учетом нового режима (логирование будет в recalculateWeights после ответа API)
recalculateWeights().catch(err => console.error('Ошибка при расчете:', err));
}
function toggleDryMatterMode() {
setDryMatterMode(!calculateFromDryMatter);
}
// СВ,% компонента обновляется только при "Сохранить" (на бэкенде).
function isRecipesMobileViewport() {
return globalThis.matchMedia?.('(max-width: 768px)')?.matches ?? false;
}
// Показать/скрыть форму редактирования (options.loading — скелетон + раскрытие до прихода данных)
function showRecipeEdit(show, options = {}) {
const loading = options.loading === true;
const isMobile = isRecipesMobileViewport();
try {
const recipeEdit = document.getElementById('recipeEdit');
if (recipeEdit) {
const panel = recipeEdit.querySelector('.recipe-edit-panel');
const skel = document.getElementById('recipeEditSkeletonPanel');
if (show) {
recipeEdit.style.display = 'block';
if (isMobile) {
window.WespZootechNavCloseMobileMenu?.();
document.body.classList.add('recipe-edit-mobile-open');
recipeEdit.classList.add('recipe-edit--mobile-active');
portalRecipeEditForMobile(recipeEdit);
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
recipeEdit.scrollTop = 0;
window.scrollTo(0, 0);
});
}
if (loading) {
if (panel) {
panel.classList.add('recipe-edit-panel--loading');
}
skel?.removeAttribute('hidden');
document.getElementById('recipeForm')?.classList.remove('recipe-form--enter');
} else {
if (panel) {
panel.classList.remove('recipe-edit-panel--loading');
}
skel?.setAttribute('hidden', '');
const recipeForm = document.getElementById('recipeForm');
if (recipeForm) {
recipeForm.classList.remove('recipe-form--enter');
if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
void recipeForm.offsetWidth;
recipeForm.classList.add('recipe-form--enter');
}
}
}
} else {
recipeEdit.style.display = 'none';
closeIngredientMobileSheet();
closeUnloadingGroupMobileSheet();
closeRecipeHelpModal();
closeRecipePlainHelp();
document.body.classList.remove('recipe-edit-mobile-open');
recipeEdit.classList.remove('recipe-edit--mobile-active');
restoreRecipeEditFromMobilePortal(recipeEdit);
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
if (isMobile && !options.skipMobileStep && recipesDataController?.setMobileDashboardStep) {
recipesDataController.setMobileDashboardStep('recipes');
}
if (panel) {
panel.classList.remove('recipe-edit-panel--loading');
}
skel?.setAttribute('hidden', '');
selectedRecipe = null;
pageState.set({ selectedRecipe });
const recipeForm = document.getElementById('recipeForm');
if (recipeForm) {
recipeForm.reset();
}
const ingredientsTableBody = document.getElementById('ingredientsTableBody');
if (ingredientsTableBody) {
ingredientsTableBody.innerHTML = '';
}
const unloadingGroupsTableBody = document.getElementById('unloadingGroupsTableBody');
if (unloadingGroupsTableBody) {
unloadingGroupsTableBody.innerHTML = '';
}
unloadingLinkBroken = false;
initializeUnloadingLinkButton();
updateTripPercentFieldState();
deletedIngredientIds = [];
deletedGroupIds = [];
// Сбрасываем "замок СВ" при закрытии формы
setDryMatterMode(false);
// Новый рейс после закрытия редактора — не блокировать save cooldown
lastSaveAt = 0;
if (saveDebounceTimer) {
clearTimeout(saveDebounceTimer);
saveDebounceTimer = null;
}
}
} else {
// Если формы нет, просто показываем уведомление
if (show) {
console.log('Форма редактирования рецепта не найдена, но период выбран:', selectedPeriod);
}
}
} catch (error) {
console.error('Ошибка в showRecipeEdit:', error);
}
}
// Загрузка кормораздатчиков
async function loadDispensers() {
return recipesDataController.loadDispensers();
}
// Выбор кормораздатчика
async function selectDispenser(dispenserId) {
return recipesDataController.selectDispenser(dispenserId);
}
// Загрузка рецептов кормоцеха (без периодов)
async function loadMillRecipes(dispenserId) {
return recipesDataController.loadMillRecipes(dispenserId);
}
// Загрузка периодов
async function loadPeriods(dispenserId) {
return recipesDataController.loadPeriods(dispenserId);
}
// Выбор периода
async function selectPeriod(periodId) {
return recipesDataController.selectPeriod(periodId);
}
// Загрузка рецептов (второй аргумент — опции, напр. { skipRecipesSkeleton: true } при стрелках)
async function loadRecipes(periodId, options) {
return recipesDataController.loadRecipes(periodId, options);
}
// Удаление рейса
async function deleteRecipe(recipeId) {
return recipesOperationsController.deleteRecipe(recipeId);
}
async function reloadAfterSkipRestore() {
await loadDispensers();
if (selectedDispenser) {
await loadPeriods(selectedDispenser);
}
if (selectedPeriod) {
await loadRecipes(selectedPeriod, { skipRecipesSkeleton: true });
} else if (selectedDispenser && currentDispenserType === "mill") {
await loadMillRecipes(selectedDispenser);
}
if (selectedRecipe) {
await loadRecipeDetails(selectedRecipe);
}
}
async function unskipRecipeToday(recipeId) {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const params = new URLSearchParams({ recipe_id: recipeId, date });
const resp = await fetch(`/api/daily-plan/skips?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
notyf.error(err.message, { fallback: "Не удалось вернуть рейс в план" });
return;
}
notyf.success("Рейс снова в плане на сегодня");
await reloadAfterSkipRestore();
}
async function unskipIngredientPartsToday(recipeId) {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const params = new URLSearchParams({ recipe_id: recipeId, date, all: "1" });
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
notyf.error(err.message, { fallback: "Не удалось вернуть компоненты в план" });
return;
}
notyf.success("Компоненты снова в плане на сегодня");
await reloadAfterSkipRestore();
}
async function unskipGroupPartsToday(recipeId) {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const params = new URLSearchParams({ recipe_id: recipeId, date, all: "1" });
const resp = await fetch(`/api/daily-plan/skips/unloading-groups?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
notyf.error(err.message, { fallback: "Не удалось вернуть группы в план" });
return;
}
notyf.success("Группы снова в плане на сегодня");
await reloadAfterSkipRestore();
}
async function moveRecipeUp(recipeId, currentIndex) {
return recipesOperationsController.moveRecipeUp(recipeId, currentIndex);
}
async function moveRecipeDown(recipeId, currentIndex) {
return recipesOperationsController.moveRecipeDown(recipeId, currentIndex);
}
// Выбор рецепта
async function selectRecipe(recipeId) {
return recipesDataController.selectRecipe(recipeId);
}
// Переключение видимости блоков в зависимости от типа устройства
function toggleUnloadingBlocks() {
return recipesEditorController.toggleUnloadingBlocks();
}
// Загрузка компонентов в select для кормоцеха
async function loadComponentsForMill() {
return recipesEditorController.loadComponentsForMill();
}
// Загрузка деталей рецепта
async function loadRecipeDetails(recipeId, options) {
return recipesEditorController.loadRecipeDetails(recipeId, options);
}
async function loadRecipeDetailsWithPlanOverlay() {
if (!selectedRecipe) return;
await loadRecipeDetails(selectedRecipe);
}
// Создание нового рейса для кормоцеха (без периода)
async function createNewRecipeForMill(dispenserId) {
return recipesEditorController.createNewRecipeForMill(dispenserId);
}
// Создание нового рейса
async function createNewRecipe(periodId) {
return recipesEditorController.createNewRecipe(periodId);
}
// Вставка рейса из буфера
async function pasteRecipe(periodId) {
return recipesOperationsController.pasteRecipe(periodId);
}
// Копирование рейса
async function copyRecipe(recipeId) {
return recipesOperationsController.copyRecipe(recipeId);
}
// Обновление состояния кнопок вставки
function updatePasteButtons() {
return recipesOperationsController.updatePasteButtons();
}
// Обновление процента на рейс
function updateTripPercent(value) {
const tripPercentInput = document.getElementById('tripPercent');
// Если поле заблокировано (есть группы выгрузки с типом 'percent'), не обрабатываем ручные изменения
if (tripPercentInput && tripPercentInput.disabled) {
return;
}
// Проверяем корректность введенного значения
const numValue = parseFloat(value);
// Если поле пустое, просто выходим без изменений
if (value === '' || value === null || value === undefined) {
return;
}
// Если значение отрицательное, устанавливаем 0
if (!isNaN(numValue) && numValue < 0) {
if (tripPercentInput) {
tripPercentInput.value = 0;
}
return;
}
// Если введено корректное число, пересчитываем
if (!isNaN(numValue)) {
// Обновляем отображение процента только для текущего редактируемого рейса в списке
if (selectedRecipe) {
const recipeItem = document.querySelector(`[data-recipe-id="${selectedRecipe}"]`);
if (recipeItem) {
const badge = recipeItem.querySelector('.badge.bg-primary');
if (badge) {
const icon = badge.querySelector('i');
badge.innerHTML = icon ? `<i class="fas fa-percentage me-1"></i>${formatNumber(numValue)}%` : `${formatNumber(numValue)}%`;
}
}
}
if (typeof recalculateWeights === 'function') {
recalculateWeights();
}
if (typeof recalculateTotals === 'function') {
recalculateTotals();
}
}
}
// Валидация процента на рейс при потере фокуса
function validateTripPercent(input) {
let value = parseFloat(input.value);
// Если поле пустое, устанавливаем 100%
if (isNaN(value) || input.value.trim() === '') {
input.value = 100;
value = 100;
notyf.success('Установлен процент по умолчанию: 100%');
}
// Ограничиваем только отрицательные значения
if (value < 0) {
input.value = 0;
notyf.error('Процент не может быть отрицательным');
}
// Обновляем отображение процента только для текущего редактируемого рейса в списке
if (selectedRecipe) {
const recipeItem = document.querySelector(`[data-recipe-id="${selectedRecipe}"]`);
if (recipeItem) {
const badge = recipeItem.querySelector('.badge.bg-primary');
if (badge) {
const icon = badge.querySelector('i');
badge.innerHTML = icon ? `<i class="fas fa-percentage me-1"></i>${formatNumber(value)}%` : `${formatNumber(value)}%`;
}
}
}
// Обновляем расчеты
if (typeof recalculateWeights === 'function') {
recalculateWeights();
}
if (typeof recalculateTotals === 'function') {
recalculateTotals();
}
}
// Валидация значения группы выгрузки
function validateGroupValue(input) {
const row = getUnloadingGroupRowFromElement(input);
if (!row) return;
const typeSelect = queryUnloadingGroupRow(row, '.group-type');
if (!typeSelect) return;
const type = typeSelect.value;
const value = parseFloat(input.value) || 0;
// Проверка на отрицательные значения
if (value < 0) {
notyf.error('Значение не может быть отрицательным');
input.value = '0';
return;
}
if (type === 'percent') {
// Валидация процентов убрана при активной связи
// Пользователь может вводить любые значения
} else {
const headsCountInput = document.getElementById('headsCount');
const headsCount = headsCountInput ? parseInt(headsCountInput.value) || 0 : 0;
if (value > headsCount) {
notyf.error(`Количество голов не может быть больше ${headsCount}`);
input.value = headsCount.toString();
}
}
// updateTripPercentFromGroups() вызывается внутри recalculateWeights(),
// поэтому не нужно вызывать его здесь, чтобы избежать двойного обновления
syncUnloadingGroupMobileSummary(row);
updateTotalValues();
}
function recipeRowSkipDotHtml(title = 'Сегодня убран из плана') {
return `<span class="recipe-skip-badge__dot recipe-row-skip-dot" title="${title}" aria-hidden="true"></span>`;
}
function recipeRowAdjustDotHtml(title = 'Сегодня изменили норму в плане') {
return `<span class="recipe-row-adjust-dot" title="${title}" aria-hidden="true"></span>`;
}
function ingredientPlanDotsHtml(opts = {}) {
const skippedToday = Boolean(opts.skippedToday);
const replacedToday = Boolean(opts.replacedToday);
const adjustedToday = Boolean(opts.adjustedToday);
const skipDot = skippedToday ? recipeRowSkipDotHtml('Сегодня убрали ингредиент из плана') : '';
const replaceDot =
!skippedToday && replacedToday
? recipeRowSkipDotHtml('Сегодня заменили компонент в плане')
: '';
const adjustDot =
!skippedToday && !replacedToday && adjustedToday
? recipeRowAdjustDotHtml('Сегодня изменили норму в плане')
: '';
return `${skipDot}${replaceDot}${adjustDot}`;
}
// Группы выгрузки
function buildUnloadingGroupRowHtml(opts = {}) {
const name = opts.name ?? '';
const distributionType = opts.distributionType ?? 'percent';
const value = opts.value ?? '';
const weight = opts.weight ?? '';
const skippedToday = Boolean(opts.skippedToday);
const skipDot = skippedToday ? recipeRowSkipDotHtml('Сегодня убрали группу из плана') : '';
const isPercent = distributionType === 'percent';
const metaText = isPercent ? `${value || '0'}%` : `${value || '0'} гол`;
return `
<div class="zt-data-grid__cell unloading-group-mobile-summary">
<button type="button" class="recipe-mobile-row-summary-btn unloading-group-mobile-summary-btn" data-action="open-unloading-group-sheet">
<span class="unloading-group-mobile-name">${skipDot}<span class="unloading-group-mobile-name-text">${name || 'Название группы'}</span></span>
<span class="unloading-group-mobile-meta">${metaText}</span>
<i class="fas fa-chevron-right recipe-mobile-row-summary-chevron" aria-hidden="true"></i>
</button>
</div>
<div class="zt-data-grid__cell unloading-group-detail-cell" data-label="Название группы">
<div class="recipe-row-name-with-skip">
${skipDot}
<input type="text" class="form-control group-name" placeholder="Название группы" value="${name}" required>
</div>
</div>
<div class="zt-data-grid__cell unloading-group-detail-cell" data-label="Тип распределения">
<select class="form-control group-type" data-action="group-type-change">
<option value="percent" ${isPercent ? 'selected' : ''}>Процент</option>
<option value="heads" ${!isPercent ? 'selected' : ''}>Количество голов</option>
</select>
</div>
<div class="zt-data-grid__cell unloading-group-detail-cell zt-data-grid__cell--num" data-label="Значение">
<input type="number" class="form-control group-value" min="0" step="0.01" required
value="${value}" inputmode="decimal" data-action="group-value-change" placeholder="Значение">
</div>
<div class="zt-data-grid__cell unloading-group-detail-cell zt-data-grid__cell--num" data-label="В кг">
<input type="number" class="form-control group-weight" min="0" step="1"
value="${weight}" inputmode="decimal" placeholder="Авто" readonly style="background-color: #f8f9fa;">
</div>
<div class="zt-data-grid__cell unloading-group-detail-cell zt-data-grid__cell--actions recipe-table-actions-cell" data-label="Действия">
<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions" role="group">
<div class="btn-group recipe-table-move-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm recipe-table-move-btn" data-action="move-group-up" title="Переместить вверх">
<i class="fas fa-arrow-up"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm recipe-table-move-btn" data-action="move-group-down" title="Переместить вниз">
<i class="fas fa-arrow-down"></i>
</button>
</div>
<button type="button" class="btn btn-sm recipe-table-delete-btn" data-action="remove-unloading-group" title="Удалить">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}
function buildIngredientRowHtml(opts = {}) {
const dryMatter = opts.dryMatter ?? '0.00';
const dryMatterPerHead = opts.dryMatterPerHead ?? '0.0000';
const weightPerHead = opts.weightPerHead ?? '0.00';
const totalWeight = opts.totalWeight ?? '0.00';
const tripWeight = opts.tripWeight ?? '0.00';
const planDots = ingredientPlanDotsHtml(opts);
return `
<div class="zt-data-grid__cell ingredient-mobile-summary">
<button type="button" class="recipe-mobile-row-summary-btn ingredient-mobile-summary-btn" data-action="open-ingredient-sheet">
<span class="ingredient-mobile-name">${planDots}<span class="ingredient-mobile-name-text">Выберите ингредиент</span></span>
<span class="ingredient-mobile-trip">${tripWeight} кг</span>
<i class="fas fa-chevron-right recipe-mobile-row-summary-chevron" aria-hidden="true"></i>
</button>
</div>
<div class="zt-data-grid__cell ingredient-detail-cell" data-label="Ингредиент">
<div class="recipe-row-name-with-skip">
${planDots}
<select class="form-control ingredient-select" data-action="ingredient-change">
<option value="">Выберите ингредиент</option>
</select>
</div>
</div>
<div class="zt-data-grid__cell ingredient-detail-cell dry-matter-percent zt-data-grid__cell--num" data-label="СВ, %">
<input type="number" class="form-control dry-matter-percent-input" step="0.01" min="0" value="${dryMatter}" inputmode="decimal" data-action="dry-matter-percent-change">
</div>
<div class="zt-data-grid__cell ingredient-detail-cell dry-matter-per-head zt-data-grid__cell--num" data-label="СВ/гол, кг">
<input type="number" class="form-control dry-matter-per-head-input" step="0.0001" min="0" value="${dryMatterPerHead}" inputmode="decimal" disabled data-action="recalculate-weights">
</div>
<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="Вес/гол, кг">
<input type="text" inputmode="decimal" class="form-control weight-per-head-input" placeholder="0.00" value="${weightPerHead}" data-action="recalculate-weights" title="Вес/гол, кг (до сотых)">
</div>
<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="Вес, кг">
<input type="number" class="form-control total-weight-input" step="0.01" min="0" value="${totalWeight}" inputmode="decimal" data-action="recalculate-total-weight">
</div>
<div class="zt-data-grid__cell ingredient-detail-cell trip-weight zt-data-grid__cell--num" data-label="Вес/рейс, кг">
<span class="text-cell trip-weight-display">${tripWeight}</span>
</div>
<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--actions recipe-table-actions-cell" data-label="Действия">
<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions" role="group">
<div class="btn-group recipe-table-move-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm recipe-table-move-btn" data-action="move-ingredient-up" title="Переместить вверх">
<i class="fas fa-arrow-up"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm recipe-table-move-btn" data-action="move-ingredient-down" title="Переместить вниз">
<i class="fas fa-arrow-down"></i>
</button>
</div>
<button type="button" class="btn btn-sm recipe-table-delete-btn" data-action="remove-ingredient" title="Удалить">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}
let currentIngredientSheetRow = null;
function isRecipesMobileEditorUi() {
return globalThis.matchMedia?.('(max-width: 768px)')?.matches ?? false;
}
function isRecipesMobileIngredientsUi() {
return isRecipesMobileEditorUi();
}
function getIngredientRowFromElement(el) {
if (!el) return null;
const fromRow = el.closest('.ingredient-row');
if (fromRow && document.getElementById('ingredientsTableBody')?.contains(fromRow)) return fromRow;
const sheetBody = document.getElementById('ingredientMobileSheetBody');
if (sheetBody?.contains(el) && currentIngredientSheetRow) {
return currentIngredientSheetRow;
}
return null;
}
function queryIngredientRow(row, selector) {
if (!row) return null;
if (currentIngredientSheetRow === row) {
const sheetBody = document.getElementById('ingredientMobileSheetBody');
const inSheet = sheetBody?.querySelector(selector);
if (inSheet) return inSheet;
}
return row.querySelector(selector);
}
function syncIngredientMobileSummary(row) {
if (!row) return;
const nameEl = row.querySelector('.ingredient-mobile-name-text') || row.querySelector('.ingredient-mobile-name');
const tripEl = row.querySelector('.ingredient-mobile-trip');
const select = queryIngredientRow(row, '.ingredient-select');
const tripDisplay = queryIngredientRow(row, '.trip-weight-display');
if (nameEl) {
const option = select?.selectedOptions?.[0];
nameEl.textContent =
option && option.value ? option.textContent.replace(/\s+/g, ' ').trim() : 'Выберите ингредиент';
}
if (tripEl) {
tripEl.textContent = `${tripDisplay?.textContent?.trim() || '0.00'} кг`;
}
}
function syncAllIngredientMobileSummaries() {
if (!isRecipesMobileIngredientsUi()) return;
document.querySelectorAll('#ingredientsTableBody .ingredient-row').forEach(syncIngredientMobileSummary);
}
function syncIngredientSheetDryMatterClass() {
const sheet = document.getElementById('ingredientMobileSheet');
const table = document.getElementById('ingredientsTable');
if (!sheet || !table) return;
sheet.classList.toggle('ingredients-table--dm-locked', table.classList.contains('ingredients-table--dm-locked'));
sheet.classList.toggle('ingredients-table--dm-unlocked', table.classList.contains('ingredients-table--dm-unlocked'));
}
function getIngredientEditableCells(row) {
return [...row.querySelectorAll('.ingredient-detail-cell')].filter(
(td) => !td.classList.contains('trip-weight')
);
}
function restoreIngredientSheetFieldsToRow(row) {
const body = document.getElementById('ingredientMobileSheetBody');
if (!row || !body) return;
const cells = getIngredientEditableCells(row);
const fields = [...body.querySelectorAll('.ingredient-mobile-sheet-field')];
fields.forEach((field, index) => {
const td = cells[index];
if (!td) return;
const label = field.querySelector('.ingredient-mobile-sheet-label');
if (label) label.remove();
while (field.firstChild) {
td.appendChild(field.firstChild);
}
});
body.innerHTML = '';
}
function closeIngredientMobileSheet() {
const sheet = document.getElementById('ingredientMobileSheet');
if (!sheet || sheet.hidden) return;
const row = currentIngredientSheetRow;
if (row) {
restoreIngredientSheetFieldsToRow(row);
syncIngredientMobileSummary(row);
}
sheet.hidden = true;
document.body.classList.remove('ingredient-mobile-sheet-open');
currentIngredientSheetRow = null;
}
function openIngredientMobileSheet(row) {
if (!row || !isRecipesMobileIngredientsUi()) return;
closeIngredientMobileSheet();
closeUnloadingGroupMobileSheet();
closeRecipeHelpModal();
closeRecipePlainHelp();
const sheet = document.getElementById('ingredientMobileSheet');
const body = document.getElementById('ingredientMobileSheetBody');
if (!sheet || !body) return;
resetRecipeMobileSheetPresentation(sheet);
currentIngredientSheetRow = row;
body.innerHTML = '';
row.querySelectorAll('.ingredient-detail-cell').forEach((td) => {
if (td.classList.contains('trip-weight')) return;
const field = document.createElement('div');
field.className = 'ingredient-mobile-sheet-field';
if (td.classList.contains('dry-matter-percent')) field.classList.add('dry-matter-percent');
if (td.classList.contains('dry-matter-per-head')) field.classList.add('dry-matter-per-head');
if (td.classList.contains('recipe-table-actions-cell')) field.classList.add('ingredient-mobile-sheet-actions');
if (td.dataset.label) {
const label = document.createElement('div');
label.className = 'ingredient-mobile-sheet-label';
label.textContent = td.dataset.label;
field.appendChild(label);
}
while (td.firstChild) {
field.appendChild(td.firstChild);
}
body.appendChild(field);
});
syncIngredientSheetDryMatterClass();
const titleEl = document.getElementById('ingredientMobileSheetTitle');
if (titleEl) {
const nameEl = row.querySelector('.ingredient-mobile-name');
titleEl.textContent = nameEl?.textContent?.trim() || 'Ингредиент';
}
document.body.appendChild(sheet);
sheet.hidden = false;
document.body.classList.add('ingredient-mobile-sheet-open');
}
function removeIngredientFromSheet() {
if (!currentIngredientSheetRow) return;
const row = currentIngredientSheetRow;
closeIngredientMobileSheet();
if (row.dataset?.id) {
deletedIngredientIds.push(row.dataset.id);
}
row.remove();
recalculateWeights();
}
let currentUnloadingGroupSheetRow = null;
function getUnloadingGroupRowFromElement(el) {
if (!el) return null;
const fromRow = el.closest('.unloading-group-row');
if (fromRow && document.getElementById('unloadingGroupsTableBody')?.contains(fromRow)) return fromRow;
const sheetBody = document.getElementById('unloadingGroupMobileSheetBody');
if (sheetBody?.contains(el) && currentUnloadingGroupSheetRow) {
return currentUnloadingGroupSheetRow;
}
return null;
}
function queryUnloadingGroupRow(row, selector) {
if (!row) return null;
if (currentUnloadingGroupSheetRow === row) {
const sheetBody = document.getElementById('unloadingGroupMobileSheetBody');
const inSheet = sheetBody?.querySelector(selector);
if (inSheet) return inSheet;
}
return row.querySelector(selector);
}
function syncUnloadingGroupMobileSummary(row) {
if (!row) return;
const nameEl = row.querySelector('.unloading-group-mobile-name-text') || row.querySelector('.unloading-group-mobile-name');
const metaEl = row.querySelector('.unloading-group-mobile-meta');
const nameInput = queryUnloadingGroupRow(row, '.group-name');
const typeSelect = queryUnloadingGroupRow(row, '.group-type');
const valueInput = queryUnloadingGroupRow(row, '.group-value');
const weightInput = queryUnloadingGroupRow(row, '.group-weight');
if (nameEl) {
nameEl.textContent = nameInput?.value?.trim() || 'Название группы';
}
if (metaEl) {
const type = typeSelect?.value || 'percent';
const val = valueInput?.value?.trim() || '0';
let meta = type === 'percent' ? `${val}%` : `${val} гол`;
const weight = weightInput?.value?.trim();
if (weight) meta += ` · ${weight} кг`;
metaEl.textContent = meta;
}
}
function syncAllUnloadingGroupMobileSummaries() {
if (!isRecipesMobileEditorUi()) return;
document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').forEach(syncUnloadingGroupMobileSummary);
}
function getUnloadingGroupEditableCells(row) {
return [...row.querySelectorAll('.unloading-group-detail-cell')];
}
function restoreUnloadingGroupSheetFieldsToRow(row) {
const body = document.getElementById('unloadingGroupMobileSheetBody');
if (!row || !body) return;
const cells = getUnloadingGroupEditableCells(row);
const fields = [...body.querySelectorAll('.unloading-group-mobile-sheet-field')];
fields.forEach((field, index) => {
const td = cells[index];
if (!td) return;
const label = field.querySelector('.unloading-group-mobile-sheet-label');
if (label) label.remove();
while (field.firstChild) {
td.appendChild(field.firstChild);
}
});
body.innerHTML = '';
}
function closeUnloadingGroupMobileSheet() {
const sheet = document.getElementById('unloadingGroupMobileSheet');
if (!sheet || sheet.hidden) return;
const row = currentUnloadingGroupSheetRow;
if (row) {
restoreUnloadingGroupSheetFieldsToRow(row);
syncUnloadingGroupMobileSummary(row);
}
sheet.hidden = true;
document.body.classList.remove('unloading-group-mobile-sheet-open');
currentUnloadingGroupSheetRow = null;
}
function openUnloadingGroupMobileSheet(row) {
if (!row || !isRecipesMobileEditorUi()) return;
closeUnloadingGroupMobileSheet();
closeIngredientMobileSheet();
closeRecipeHelpModal();
closeRecipePlainHelp();
const sheet = document.getElementById('unloadingGroupMobileSheet');
const body = document.getElementById('unloadingGroupMobileSheetBody');
if (!sheet || !body) return;
resetRecipeMobileSheetPresentation(sheet);
currentUnloadingGroupSheetRow = row;
body.innerHTML = '';
row.querySelectorAll('.unloading-group-detail-cell').forEach((td) => {
const field = document.createElement('div');
field.className = 'unloading-group-mobile-sheet-field';
if (td.classList.contains('recipe-table-actions-cell')) {
field.classList.add('unloading-group-mobile-sheet-actions');
}
if (td.dataset.label) {
const label = document.createElement('div');
label.className = 'unloading-group-mobile-sheet-label';
label.textContent = td.dataset.label;
field.appendChild(label);
}
while (td.firstChild) {
field.appendChild(td.firstChild);
}
body.appendChild(field);
});
const titleEl = document.getElementById('unloadingGroupMobileSheetTitle');
if (titleEl) {
const nameEl = row.querySelector('.unloading-group-mobile-name');
titleEl.textContent = nameEl?.textContent?.trim() || 'Группа выгрузки';
}
document.body.appendChild(sheet);
sheet.hidden = false;
document.body.classList.add('unloading-group-mobile-sheet-open');
}
function addUnloadingGroup() {
const container = document.getElementById('unloadingGroupsTableBody');
const row = document.createElement('div');
row.classList.add('zt-data-grid__row', 'unloading-group-row');
row.innerHTML = buildUnloadingGroupRowHtml();
container.appendChild(row);
handleGroupTypeChange(row.querySelector('.group-type'));
syncUnloadingGroupMobileSummary(row);
updateTripPercentFieldState();
}
function handleGroupTypeChange(select) {
const row = getUnloadingGroupRowFromElement(select);
if (!row) return;
const valueInput = queryUnloadingGroupRow(row, '.group-value');
const type = select.value;
if (type === 'percent') {
// Убираем ограничение max при активной связи - пользователь может вводить любые проценты
valueInput.removeAttribute('max');
valueInput.placeholder = 'Процент';
} else {
const headsCount = parseInt(document.getElementById('headsCount').value) || 0;
valueInput.max = headsCount.toString();
valueInput.placeholder = `Количество голов (0-${headsCount})`;
}
validateGroupValue(valueInput);
syncUnloadingGroupMobileSummary(row);
// updateTripPercentFromGroups() вызывается внутри recalculateWeights(),
// поэтому не нужно вызывать его здесь
updateTripPercentFieldState();
}
// Обновление значений групп выгрузки (через общий пересчет /api/recipes/calculate)
async function updateTotalValues() {
await recalculateWeights();
}
// Пересчет tripPercent на основе итогового процента по группам выгрузки типа 'percent'
function updateTripPercentFromGroups() {
// Если связь разорвана, не пересчитываем автоматически
if (unloadingLinkBroken) {
return;
}
const unloadingGroups = getUnloadingGroupsForCalculation();
const hasPercentGroups = unloadingGroups.some(g => g && g.distributionType === 'percent');
if (hasPercentGroups) {
const totalPercent = unloadingGroups
.filter(g => g && g.distributionType === 'percent')
.reduce((sum, g) => sum + (parseFloat(g.value) || 0), 0);
const tripPercentInput = document.getElementById('tripPercent');
if (tripPercentInput && totalPercent > 0) {
// Обновляем значение без вызова oninput, чтобы избежать рекурсии
tripPercentInput.value = totalPercent.toFixed(1);
// Обновляем отображение процента в списке рейсов
if (selectedRecipe) {
const recipeItem = document.querySelector(`[data-recipe-id="${selectedRecipe}"]`);
if (recipeItem) {
const badge = recipeItem.querySelector('.badge.bg-primary');
if (badge) {
const icon = badge.querySelector('i');
badge.innerHTML = icon ? `<i class="fas fa-percentage me-1"></i>${formatNumber(totalPercent)}%` : `${formatNumber(totalPercent)}%`;
}
}
}
}
}
}
const recipeHelpStore = {};
const RECIPE_PLAIN_HELP_IDS = {
tripPercentHelpIcon: 'tripPercentPlainHelp',
dryMatterModeHelpIcon: 'dryMatterPlainHelp',
unloadingLinkHelpIcon: 'unloadingLinkPlainHelp',
};
function formatRecipeHelpHtml(paragraphsOrHtml) {
if (Array.isArray(paragraphsOrHtml)) {
const body = paragraphsOrHtml
.map((paragraph) => `<p class="zt-notice__p">${paragraph}</p>`)
.join('');
return `<div class="zt-notice zt-notice--static zt-notice--info">${body}</div>`;
}
return paragraphsOrHtml;
}
function formatRecipeHelpPlainHtml(paragraphsOrHtml) {
if (Array.isArray(paragraphsOrHtml)) {
return paragraphsOrHtml
.map((paragraph) => `<p class="zt-notice__p">${paragraph}</p>`)
.join('');
}
return paragraphsOrHtml;
}
function resolveRecipeHelpKey(btnOrId) {
if (typeof btnOrId === 'string') return btnOrId;
return btnOrId?.id || null;
}
function getRecipePlainHelpElement(helpKey) {
const id = RECIPE_PLAIN_HELP_IDS[helpKey];
return id ? document.getElementById(id) : null;
}
function setRecipeHelpContent(btnOrId, title, paragraphsOrHtml) {
const key = resolveRecipeHelpKey(btnOrId);
if (!key) return;
recipeHelpStore[key] = {
title: title || 'Справка',
html: formatRecipeHelpHtml(paragraphsOrHtml),
plainHtml: formatRecipeHelpPlainHtml(paragraphsOrHtml),
};
const plainEl = getRecipePlainHelpElement(key);
if (plainEl) {
plainEl.innerHTML = recipeHelpStore[key].plainHtml;
}
}
function closeRecipePlainHelp() {
document.querySelectorAll('#recipeEdit .recipe-plain-help.is-visible').forEach((el) => {
el.classList.remove('is-visible');
});
document.querySelectorAll('#recipeEdit .recipe-help-icon-btn[aria-expanded="true"]').forEach((btn) => {
btn.setAttribute('aria-expanded', 'false');
});
}
function initRecipePlainHelpOutsideClose() {
if (window._recipePlainHelpOutsideInit) return;
window._recipePlainHelpOutsideInit = true;
document.addEventListener('click', (event) => {
if (isRecipesMobileViewport()) return;
if (event.target.closest?.('.recipe-plain-help, .recipe-help-icon-btn')) return;
closeRecipePlainHelp();
});
}
function toggleRecipePlainHelp(helpKey) {
const plainEl = getRecipePlainHelpElement(helpKey);
const helpBtn = document.getElementById(helpKey);
if (!plainEl) return;
const willShow = !plainEl.classList.contains('is-visible');
closeRecipePlainHelp();
if (willShow) {
plainEl.classList.add('is-visible');
helpBtn?.setAttribute('aria-expanded', 'true');
}
}
function isRecipesMobileHelpUi() {
return isRecipesMobileViewport();
}
function openRecipeHelp(helpKey) {
if (isRecipesMobileHelpUi()) {
openRecipeHelpModal(helpKey);
return;
}
toggleRecipePlainHelp(helpKey);
}
function openRecipeHelpModal(helpKey) {
const data = recipeHelpStore[helpKey];
if (!data) return;
closeIngredientMobileSheet();
closeUnloadingGroupMobileSheet();
const modal = document.getElementById('recipeHelpModal');
const titleEl = document.getElementById('recipeHelpModalTitle');
const bodyEl = document.getElementById('recipeHelpModalBody');
if (!modal || !titleEl || !bodyEl) return;
resetRecipeMobileSheetPresentation(modal);
titleEl.textContent = data.title;
bodyEl.innerHTML = data.html;
document.body.appendChild(modal);
modal.hidden = false;
document.body.classList.add('recipe-help-modal-open');
}
function closeRecipeHelpModal() {
const modal = document.getElementById('recipeHelpModal');
if (!modal || modal.hidden) return;
modal.hidden = true;
document.body.classList.remove('recipe-help-modal-open');
}
// Пояснение у значка «i» у поля «Процент на рейс» (меняется вместе с режимом)
function syncTripPercentPlainHelp() {
const tripPercentInput = document.getElementById('tripPercent');
const helpBtn = document.getElementById('tripPercentHelpIcon');
if (!helpBtn || !tripPercentInput) return;
const card = document.getElementById('unloadingGroupsCard');
const cardVisible = !!(card && card.style.display !== 'none');
const unloadingGroups = getUnloadingGroupsForCalculation();
const hasPercentGroups = unloadingGroups.some(g => g && g.distributionType === 'percent');
const locked = tripPercentInput.disabled;
let paragraphs;
if (!cardVisible) {
if (locked) {
paragraphs = [
'Поле <strong>«Процент на рейс»</strong> сейчас заблокировано: программа ждёт данные из таблицы <strong>«Группы выгрузки»</strong>, но этот раздел не показан.',
'Когда таблица появится, процент подставится автоматически.',
'Либо разорвите <strong>связь</strong> (кнопка с цепочкой у таблицы) и введите значение вручную.'
];
} else {
paragraphs = [
'Раздел <strong>«Группы выгрузки»</strong> сейчас скрыт — введите процент вручную.',
'Когда таблица появится и связь будет активна (цельная синяя цепочка), значение будет подставляться автоматически.'
];
}
} else if (unloadingLinkBroken) {
paragraphs = [
'Связь с таблицей отключена (кнопка с «разорванной» цепью у <strong>«Групп выгрузки»</strong>).',
'Процент на рейс нужно вводить здесь вручную.',
'Чтобы снова считать из таблицы, нажмите кнопку с цепочкой — она станет цельной и синей.'
];
} else if (hasPercentGroups && locked) {
paragraphs = [
'Поле заблокировано: сюда автоматически попадает <strong>сумма процентов</strong> из строк таблицы с типом <strong>«Процент»</strong>.',
'Меняйте проценты в таблице — значение здесь обновится само.',
'Чтобы вводить только здесь, разорвите связь кнопкой с цепочкой.'
];
} else {
paragraphs = [
'В таблице нет строк с типом <strong>«Процент»</strong>, поэтому значение вводится вручную.',
'Добавьте такие строки и оставьте активную связь — поле начнёт заполняться автоматически.'
];
}
setRecipeHelpContent(helpBtn, 'Процент на рейс', paragraphs);
}
// Блокировка/разблокировка поля «Процент на рейс» и обновление подсказки
function updateTripPercentFieldState() {
const tripPercentInput = document.getElementById('tripPercent');
if (!tripPercentInput) return;
const unloadingGroups = getUnloadingGroupsForCalculation();
const hasPercentGroups = unloadingGroups.some(g => g && g.distributionType === 'percent');
if (unloadingLinkBroken) {
tripPercentInput.disabled = false;
tripPercentInput.classList.remove('mode-locked-field');
tripPercentInput.title = '';
} else if (hasPercentGroups) {
tripPercentInput.disabled = true;
tripPercentInput.classList.add('mode-locked-field');
tripPercentInput.title = 'Процент на рейс рассчитывается автоматически на основе итогового процента по группам выгрузки';
} else {
tripPercentInput.disabled = false;
tripPercentInput.classList.remove('mode-locked-field');
tripPercentInput.title = '';
}
syncTripPercentPlainHelp();
}
// Инициализация кнопки переключения связи
function initializeUnloadingLinkButton() {
const linkBtn = document.getElementById('toggleUnloadingLinkBtn');
const linkIcon = document.getElementById('unloadingLinkIcon');
if (linkBtn && linkIcon) {
if (unloadingLinkBroken) {
// Связь разорвана - активное состояние (контурная кнопка)
linkBtn.classList.remove('btn-primary');
linkBtn.classList.add('btn-outline-primary');
linkIcon.className = 'fas fa-unlink';
linkBtn.title = 'Связь разорвана. Восстановить связь с Процент на рейс (%)';
} else {
// Связь не разорвана - неактивное состояние (залитая кнопка)
linkBtn.classList.remove('btn-outline-primary');
linkBtn.classList.add('btn-primary');
linkIcon.className = 'fas fa-link';
linkBtn.title = 'Разорвать связь с Процент на рейс (%)';
}
}
}
// Переключение связи между группами выгрузки и Процент на рейс (%)
function toggleUnloadingLink() {
unloadingLinkBroken = !unloadingLinkBroken;
initializeUnloadingLinkButton();
// Обновляем все поля с типом 'percent' в группах выгрузки
// Убираем ограничение max - пользователь может вводить любые проценты
document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').forEach(row => {
const typeSelect = row.querySelector('.group-type');
const valueInput = row.querySelector('.group-value');
if (typeSelect && valueInput && typeSelect.value === 'percent') {
valueInput.removeAttribute('max');
valueInput.placeholder = 'Процент';
}
});
// Обновляем состояние поля и пересчитываем при восстановлении связи
updateTripPercentFieldState();
if (!unloadingLinkBroken) {
// Вызываем полный пересчет, который обновит tripPercent из групп
updateTotalValues();
}
}
function moveGroupUp(button) {
let row = button?.closest?.('#unloadingGroupsTableBody .unloading-group-row');
if (!row && currentUnloadingGroupSheetRow) {
row = currentUnloadingGroupSheetRow;
closeUnloadingGroupMobileSheet();
}
if (!row) return;
const prevRow = row.previousElementSibling;
if (prevRow) {
row.parentNode.insertBefore(row, prevRow);
updateTotalValues();
}
}
function moveGroupDown(button) {
let row = button?.closest?.('#unloadingGroupsTableBody .unloading-group-row');
if (!row && currentUnloadingGroupSheetRow) {
row = currentUnloadingGroupSheetRow;
closeUnloadingGroupMobileSheet();
}
if (!row) return;
const nextRow = row.nextElementSibling;
if (nextRow) {
row.parentNode.insertBefore(nextRow, row);
updateTotalValues();
}
}
function getUnloadingGroupsData() {
const groups = [];
document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').forEach((row, index) => {
const nameInput = queryUnloadingGroupRow(row, '.group-name');
const typeSelect = queryUnloadingGroupRow(row, '.group-type');
const valueInput = queryUnloadingGroupRow(row, '.group-value');
const weightInput = queryUnloadingGroupRow(row, '.group-weight');
if (nameInput && nameInput.value.trim() &&
typeSelect && valueInput && parseFloat(valueInput.value) > 0) {
const payload = {
name: nameInput.value.trim(),
distribution_type: typeSelect.value,
value: parseFloat(valueInput.value),
weight: weightInput && weightInput.value ? parseFloat(weightInput.value) : null,
order: index + 1
};
if (row.dataset && row.dataset.id) {
payload.id = row.dataset.id;
}
groups.push(payload);
}
});
return groups;
}
async function loadUnloadingGroups(groups) {
const container = document.getElementById('unloadingGroupsTableBody');
container.innerHTML = '';
if (!groups || groups.length === 0) return;
for (const group of groups) {
const row = document.createElement('div');
row.classList.add('zt-data-grid__row', 'unloading-group-row');
if (group.skippedToday) {
row.classList.add('unloading-group-row--skipped');
}
const groupId = group.id || group.group_id || group.row_id;
if (groupId) {
row.dataset.id = String(groupId);
}
row.innerHTML = buildUnloadingGroupRowHtml({
name: group.name,
distributionType: group.distribution_type ?? group.distributionType ?? 'percent',
value: group.value,
weight: group.weight || '',
skippedToday: Boolean(group.skippedToday),
});
container.appendChild(row);
handleGroupTypeChange(row.querySelector('.group-type'));
}
await updateTotalValues();
syncAllUnloadingGroupMobileSummaries();
// updateTripPercentFromGroups() вызывается внутри recalculateWeights(),
// поэтому не нужно вызывать его здесь
updateTripPercentFieldState();
}
function removeUnloadingGroup(button) {
let row = button?.closest?.('#unloadingGroupsTableBody .unloading-group-row');
if (!row && currentUnloadingGroupSheetRow) {
row = currentUnloadingGroupSheetRow;
closeUnloadingGroupMobileSheet();
}
if (!row) return;
if (row.dataset?.id) {
deletedGroupIds.push(row.dataset.id);
}
row.remove();
updateTotalValues();
// updateTripPercentFromGroups() вызывается внутри recalculateWeights(),
// поэтому не нужно вызывать его здесь
updateTripPercentFieldState();
}
// Форматирование веса для печати (без округления вверх: 2.5 остаётся 2.50, не 3.00)
function formatKg(n) {
return (Math.floor((n || 0) * 100) / 100).toFixed(2);
}
// Печать рецепта
function printRecipe() {
const recipeName = document.getElementById('recipeName').value || 'Без названия';
const headsCount = parseInt(document.getElementById('headsCount').value) || 0;
const tripPercent = parseFloat(document.getElementById('tripPercent').value) || 100;
const items = [];
document.querySelectorAll('#ingredientsTableBody .ingredient-row').forEach(tr => {
const select = tr.querySelector('select.ingredient-select');
const tripCell = tr.querySelector('.trip-weight');
if (select && select.selectedIndex > 0 && tripCell) {
const name = select.options[select.selectedIndex].textContent.trim();
const tripKg = parseFloat(tripCell.textContent.replace(',', '.')) || 0;
if (name && tripKg > 0) {
items.push({ name, tripKg });
}
}
});
const totalTrip = items.reduce((sum, item) => sum + item.tripKg, 0);
const printWindow = window.open('', '', 'width=900,height=1000');
if (!printWindow) {
notyf.error('Не удалось открыть окно печати');
return;
}
const styles = `
<style>
* { box-sizing: border-box; }
body { margin: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #111827; }
h1 { font-size: 22px; margin: 0 0 16px; }
.meta { color: #6b7280; margin-bottom: 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #e5e7eb; padding: 8px 10px; text-align: left; }
thead th { background: #f3f4f6; font-weight: 700; }
tfoot td { font-weight: 700; }
.right { text-align: right; }
@page { size: A4 portrait; margin: 12mm; }
@media print { body { margin: 0; } }
</style>
`;
const tableRows = items.map(it => `
<tr>
<td>${it.name}</td>
<td class="right">${formatKg(it.tripKg)} кг</td>
</tr>
`).join('');
printWindow.document.write('<html><head><title>Печать рецепта</title>' + styles + '</head><body>');
printWindow.document.write(`
<h1>Рецепт: ${recipeName}</h1>
<div class="meta">Голов: ${headsCount} · Процент на рейс: ${formatKg(tripPercent)}%</div>
<table>
<thead>
<tr>
<th>Ингредиент</th>
<th class="right">Вес/рейс, кг</th>
</tr>
</thead>
<tbody>
${tableRows || '<tr><td colspan="2">Нет ингредиентов</td></tr>'}
</tbody>
<tfoot>
<tr>
<td>Итого</td>
<td class="right">${formatKg(totalTrip)} кг</td>
</tr>
</tfoot>
</table>
`);
printWindow.document.write('</body></html>');
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 200);
}
// Печать всех рейсов периода
async function printAllRecipesInPeriod() {
if (!selectedPeriod) {
notyf.error('Сначала выберите период');
return;
}
try {
const recipesResp = await fetch(`/api/periods/${selectedPeriod}/recipes`);
if (!recipesResp.ok) throw new Error('Не удалось получить список рейсов');
const recipes = await recipesResp.json();
if (!Array.isArray(recipes) || recipes.length === 0) {
notyf.error('В периоде нет рейсов');
return;
}
const componentsMap = {};
try {
const compResp = await fetch('/api/components');
if (compResp.ok) {
const comps = await compResp.json();
(Array.isArray(comps) ? comps : []).forEach(c => { componentsMap[c.id] = c.name; });
}
} catch (_) {}
const details = [];
for (const r of recipes) {
const id = r.id || r.recipe_id;
if (!id) continue;
const resp = await fetch(`/api/recipes/${id}`);
if (!resp.ok) continue;
const rec = await resp.json();
details.push(rec);
}
if (details.length === 0) {
notyf.error('Не удалось загрузить детали рейсов');
return;
}
const sections = details.map(rec => {
const name = (rec && rec.name) ? rec.name : '';
const heads = rec
? (rec.heads_count ?? rec.headsPerTrip ?? rec.heads_per_trip ?? 0)
: 0;
const percent = rec && (rec.trip_percent != null || rec.tripPercent != null)
? (rec.trip_percent ?? rec.tripPercent)
: 100;
const ingredients = Array.isArray(rec && rec.ingredients) ? rec.ingredients : [];
let totalTrip = 0;
const rows = ingredients.map(ing => {
const compName = (ing.component_name) || (componentsMap[ing.component_id]) || `ID ${ing.component_id || ''}`;
const trip = (ing.amount != null) ? Number(ing.amount) : 0;
totalTrip += (isFinite(trip) ? trip : 0);
return `
<tr>
<td>${compName}</td>
<td class="right">${formatKg(trip)} кг</td>
</tr>`;
}).join('');
return `
<section class="recipe-section">
<h2>Рейс: ${name}</h2>
<div class="meta">Голов: ${heads} · Процент на рейс: ${formatKg(percent)}%</div>
<table>
<thead>
<tr>
<th>Ингредиент</th>
<th class="right">Вес/рейс, кг</th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="2">Нет ингредиентов</td></tr>'}
</tbody>
<tfoot>
<tr>
<td>Итого</td>
<td class="right">${formatKg(totalTrip)} кг</td>
</tr>
</tfoot>
</table>
</section>`;
}).join('<hr class="page-break">');
const printWindow = window.open('', '', 'width=1000,height=1100');
if (!printWindow) {
notyf.error('Не удалось открыть окно печати');
return;
}
const styles = `
<style>
* { box-sizing: border-box; }
body { margin: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #111827; }
h1 { font-size: 24px; margin: 0 0 18px; }
h2 { font-size: 20px; margin: 18px 0 8px; }
.meta { color: #6b7280; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
th, td { border: 1px solid #e5e7eb; padding: 8px 10px; text-align: left; }
thead th { background: #f3f4f6; font-weight: 700; }
tfoot td { font-weight: 700; }
.right { text-align: right; }
.page-break { border: 0; border-top: 1px dashed #e5e7eb; margin: 18px 0; }
@page { size: A4 portrait; margin: 12mm; }
@media print { body { margin: 0; } }
</style>
`;
printWindow.document.write('<html><head><title>Печать всех рейсов</title>' + styles + '</head><body>');
printWindow.document.write('<h1>Все рейсы периода</h1>');
printWindow.document.write(sections);
printWindow.document.write('</body></html>');
printWindow.document.close();
printWindow.focus();
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
} catch (e) {
console.error(e);
notyf.error('Ошибка при печати всех рейсов');
}
}
// Загрузка компонентов
async function loadComponents() {
try {
const response = await fetch('/api/components');
if (!response.ok) {
throw new Error('Ошибка при загрузке компонентов');
}
const components = await response.json();
componentsCache = Array.isArray(components) ? components : [];
// Обновляем все селекты компонентов аккуратно, сохранив выбранные значения
document.querySelectorAll('.ingredient-select').forEach(select => {
const currentValue = select.value;
select.innerHTML = `
<option value="">Выберите ингредиент</option>
${componentsCache.map(comp => `
<option value="${comp.id}"
data-dry-matter="${comp.dry_matter || comp.dryMatter || 0}">
${comp.name}
</option>
`).join('')}
`;
if (currentValue) {
select.value = currentValue;
}
});
return componentsCache;
} catch (error) {
console.error('Ошибка при загрузке компонентов:', error);
notyf.error('Ошибка при загрузке компонентов');
return [];
}
}
// Обработка изменения ингредиента
function handleIngredientChange(select) {
const row = getIngredientRowFromElement(select);
if (!row) return;
const option = select.selectedOptions[0];
if (option && option.value) {
const dryMatter = parseFloat(option.dataset.dryMatter) || 0;
const dryMatterPercentInput = queryIngredientRow(row, '.dry-matter-percent-input');
if (dryMatterPercentInput) {
setInputValueWithoutTrigger(dryMatterPercentInput, dryMatter);
}
// В режиме "замок СВ" при смене компонента НЕ меняем dry_matter_per_head
// (это константа, которая должна сохраняться)
// Просто пересчитываем вес на основе сохраненной константы и нового СВ%
if (calculateFromDryMatter) {
// Пересчитываем веса, но сохранив константу СВ/гол
recalculateWeights();
} else {
recalculateWeights();
}
} else {
const dryMatterPercentInput = queryIngredientRow(row, '.dry-matter-percent-input');
if (dryMatterPercentInput) {
dryMatterPercentInput.value = '0.00';
}
if (!calculateFromDryMatter) {
recalculateWeights();
}
}
syncIngredientMobileSummary(row);
}
// Добавление ингредиента
function addIngredient() {
const tbody = document.getElementById('ingredientsTableBody');
const row = document.createElement('div');
row.classList.add('zt-data-grid__row', 'ingredient-row');
row.innerHTML = buildIngredientRowHtml();
tbody.appendChild(row);
applyDryMatterModeToIngredientRow(row);
syncIngredientMobileSummary(row);
loadIngredientOptions(row.querySelector('.ingredient-select'));
recalculateWeights();
}
// Загрузка опций ингредиентов
async function loadIngredientOptions(select) {
try {
if (!componentsCache) {
await loadComponents();
}
const components = Array.isArray(componentsCache) ? componentsCache : [];
const currentValue = select.value;
select.innerHTML = `
<option value="">Выберите ингредиент</option>
${components.map(component => `
<option value="${component.id}"
data-dry-matter="${component.dry_matter || component.dryMatter || 0}">
${component.name}
</option>
`).join('')}
`;
if (currentValue) {
select.value = currentValue;
}
syncIngredientMobileSummary(select.closest('.ingredient-row'));
} catch (error) {
notyf.error('Ошибка при загрузке списка ингредиентов');
}
}
function getUnloadingGroupsForCalculation() {
const unloadingGroups = [];
document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').forEach(row => {
const typeSelect = queryUnloadingGroupRow(row, '.group-type');
const valueInput = queryUnloadingGroupRow(row, '.group-value');
if (!typeSelect || !valueInput) return;
unloadingGroups.push({
distributionType: typeSelect.value,
value: parseFloat(normalizeNumber(valueInput.value)) || 0
});
});
return unloadingGroups;
}
function applyUnloadingGroupsCalculation(result, headsCount, expectedTripPercent = null, callId = null) {
const rows = document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row');
if (result?.unloadingGroups && result.unloadingGroups.length > 0) {
rows.forEach((row, index) => {
const weightInput = queryUnloadingGroupRow(row, '.group-weight');
if (weightInput && result.unloadingGroups[index]) {
const calculatedWeight = result.unloadingGroups[index].calculatedWeight ?? '';
weightInput.value = calculatedWeight;
}
});
}
// Если связь активна, пересчитываем веса так, чтобы их сумма равнялась totalTripWeight
if (!unloadingLinkBroken && result.totals && result.totals.totalTripWeight !== undefined) {
const totalTripWeight = result.totals.totalTripWeight;
// Собираем данные о группах для пересчета
const groupsData = [];
let totalCalculatedWeight = 0;
rows.forEach((row, index) => {
const typeSelect = queryUnloadingGroupRow(row, '.group-type');
const valueInput = queryUnloadingGroupRow(row, '.group-value');
const weightInput = queryUnloadingGroupRow(row, '.group-weight');
if (typeSelect && valueInput && weightInput) {
const distributionType = typeSelect.value;
const value = parseFloat(normalizeNumber(valueInput.value)) || 0;
const currentWeight = parseFloat(normalizeNumber(weightInput.value)) || 0;
if (value > 0) {
groupsData.push({
row: row,
distributionType: distributionType,
value: value,
currentWeight: currentWeight,
weightInput: weightInput
});
totalCalculatedWeight += currentWeight;
}
}
});
const hasHeadsGroups = groupsData.some((group) => group.distributionType === 'heads');
// Для «Количество голов» веса считаются по формуле API; пропорциональный добив до totalTripWeight ломает расчёт (баготест #2).
if (
groupsData.length > 0
&& !hasHeadsGroups
&& Math.abs(totalCalculatedWeight - totalTripWeight) > 0.01
) {
// Вычисляем коэффициент для пропорционального пересчета
const ratio = totalCalculatedWeight > 0 ? totalTripWeight / totalCalculatedWeight : 1;
// Пересчитываем веса групп пропорционально
let recalculatedTotalWeight = 0;
groupsData.forEach(group => {
const newWeight = group.currentWeight * ratio;
// Округляем до кратного 5
const roundedWeight = Math.round(newWeight / 5) * 5;
group.weightInput.value = roundedWeight;
recalculatedTotalWeight += roundedWeight;
});
// Обновляем totals для корректного отображения итога
if (result.unloadingTotals) {
result.unloadingTotals.totalWeightKg = recalculatedTotalWeight;
}
}
}
const totals = result?.unloadingTotals;
if (!totals) {
updateTripPercentFieldState();
return;
}
const totalPercentEl = document.getElementById('totalPercent');
const totalHeadsEl = document.getElementById('totalHeads');
const totalWeightEl = document.getElementById('totalWeight');
if (totalPercentEl) {
totalPercentEl.textContent = `${(totals.totalPercent ?? 0).toFixed(1)}%`;
if ((totals.totalPercent ?? 0) > 100) totalPercentEl.classList.add('text-danger');
else totalPercentEl.classList.remove('text-danger');
}
if (totalHeadsEl) {
totalHeadsEl.textContent = `${totals.totalHeads ?? 0} / ${headsCount}`;
if ((totals.totalHeads ?? 0) > headsCount) totalHeadsEl.classList.add('text-danger');
else totalHeadsEl.classList.remove('text-danger');
}
if (totalWeightEl) {
const weightToDisplay = totals.totalWeightKg ?? 0;
totalWeightEl.textContent = `${weightToDisplay} кг`;
}
// НЕ обновляем tripPercent здесь, так как он уже был обновлен в recalculateWeights()
// перед отправкой на сервер. Обновляем только отображение в списке рейсов.
// Только если связь не разорвана
if (!unloadingLinkBroken) {
const unloadingGroups = getUnloadingGroupsForCalculation();
const hasPercentGroups = unloadingGroups.some(g => g && g.distributionType === 'percent');
if (hasPercentGroups && totals.totalPercent !== undefined) {
// Обновляем только отображение процента в списке рейсов, но НЕ значение в поле
if (selectedRecipe) {
const recipeItem = document.querySelector(`[data-recipe-id="${selectedRecipe}"]`);
if (recipeItem) {
const badge = recipeItem.querySelector('.badge.bg-primary');
if (badge) {
const icon = badge.querySelector('i');
badge.innerHTML = icon ? `<i class="fas fa-percentage me-1"></i>${formatNumber(totals.totalPercent)}%` : `${formatNumber(totals.totalPercent)}%`;
}
}
}
}
}
updateTripPercentFieldState();
syncAllUnloadingGroupMobileSummaries();
}
// Безопасное обновление значения поля без триггера события change.
// Форматируем до сотых (2 знака). НЕ используем Math.round — он даёт 3 из 2.5 (округление до целых).
function setInputValueWithoutTrigger(input, value) {
if (!input) return;
const num = parseFloat(value);
const formattedValue = Number.isFinite(num) ? Number(num).toFixed(2) : '0.00';
const currentValue = input.value;
// Обновляем только если значение действительно изменилось
if (currentValue !== formattedValue) {
// Временно удаляем обработчик onchange
const originalOnchange = input.getAttribute('onchange');
input.removeAttribute('onchange');
// Обновляем значение
input.value = formattedValue;
// Восстанавливаем обработчик
if (originalOnchange) {
input.setAttribute('onchange', originalOnchange);
}
}
}
// Дебаунс обновления СВ% компонента (чтобы не спамить PUT /api/components/<id>)
const componentDryMatterUpdateTimers = new Map();
// Изменение СВ% (в строке ингредиента) обновляет Component.dry_matter через отдельный endpoint.
// Это запускает каскадный пересчет всех рецептов с этим компонентом на сервере.
async function handleDryMatterPercentChange(input) {
const row = input ? getIngredientRowFromElement(input) : null;
const select = row ? queryIngredientRow(row, '.ingredient-select') : null;
const componentId = select ? select.value : '';
let dm = parseFloat(normalizeNumber(input?.value));
if (!Number.isFinite(dm)) dm = 0;
// Нормализуем диапазон (процент)
dm = Math.max(0, Math.min(100, dm));
setInputValueWithoutTrigger(input, dm);
// Пересчитываем UI в любом случае
// (в т.ч. чтобы пересчитать СВ/гол и веса при "замке СВ")
const doRecalc = async () => {
try { await recalculateWeights(); } catch (e) { /* ignore */ }
};
// Нет выбранного компонента или невалидное значение — компонент не обновляем
if (!componentId || dm <= 0) {
await doRecalc();
return;
}
// Если значение уже совпадает с компонентом в кеше — не дергаем сервер
try {
if (componentsCache) {
const c = componentsCache.find(x => x.id === componentId || String(x.id) === String(componentId));
const current = c ? parseFloat(c.dry_matter || c.dryMatter || 0) : NaN;
if (Number.isFinite(current) && Math.abs(current - dm) < 1e-6) {
await doRecalc();
return;
}
}
} catch (e) {
// ignore
}
const key = String(componentId);
const prevTimer = componentDryMatterUpdateTimers.get(key);
if (prevTimer) clearTimeout(prevTimer);
componentDryMatterUpdateTimers.set(key, setTimeout(async () => {
try {
const resp = await fetch(`/api/components/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dryMatter: dm })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.message || 'Ошибка обновления СВ% компонента');
}
// Обновляем кеш компонентов, чтобы последующие расчеты/отображение брали актуальное значение из БД
await loadComponents();
} catch (error) {
console.error('Ошибка обновления СВ% компонента:', error);
notyf.error(error.message, { fallback: 'Ошибка обновления СВ% компонента' });
} finally {
await doRecalc();
}
}, 350));
}
// Расчеты через API (единая точка)
async function recalculateWeights() {
// Предотвращаем рекурсивные вызовы
if (isRecalculating) {
return;
}
// Отменяем предыдущий запрос, если он еще выполняется
if (calculationAbortController) {
calculationAbortController.abort();
}
// Отменяем предыдущий таймер debouncing
if (calculationTimeout) {
clearTimeout(calculationTimeout);
}
// Создаем новый AbortController для этого запроса
calculationAbortController = new AbortController();
// Debouncing: ждем 300ms перед выполнением расчета
return new Promise((resolve, reject) => {
// Уникальный идентификатор для этого вызова
const callId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
calculationTimeout = setTimeout(async () => {
// Устанавливаем флаг, чтобы предотвратить рекурсивные вызовы
isRecalculating = true;
try {
const headsCount = parseInt(document.getElementById('headsCount')?.value) || 0;
// ВАЖНО: Если связь активна, сначала обновляем tripPercent из групп
// Это нужно сделать ДО отправки на сервер, чтобы избежать рассинхронизации
// Используем переменную напрямую, чтобы гарантировать правильное значение
let tripPercent = 100; // значение по умолчанию
if (!unloadingLinkBroken) {
const unloadingGroups = getUnloadingGroupsForCalculation();
const hasPercentGroups = unloadingGroups.some(g => g && g.distributionType === 'percent');
if (hasPercentGroups) {
const totalPercent = unloadingGroups
.filter(g => g && g.distributionType === 'percent')
.reduce((sum, g) => sum + (parseFloat(g.value) || 0), 0);
tripPercent = totalPercent; // Используем значение напрямую из групп
const tripPercentInput = document.getElementById('tripPercent');
if (tripPercentInput && totalPercent > 0) {
// Обновляем поле для отображения, но используем переменную для расчета
setInputValueWithoutTrigger(tripPercentInput, totalPercent);
}
} else {
// Если нет групп с процентами, берем из поля
tripPercent = parseFloat(normalizeNumber(document.getElementById('tripPercent')?.value)) || 100;
}
} else {
// Если связь разорвана, берем из поля
tripPercent = parseFloat(normalizeNumber(document.getElementById('tripPercent')?.value)) || 100;
}
// Убедимся, что компоненты загружены
if (!componentsCache) {
await loadComponents();
}
const ingredients = [];
document.querySelectorAll('#ingredientsTableBody .ingredient-row').forEach(row => {
const select = queryIngredientRow(row, '.ingredient-select');
if (!select || !select.value) return;
const dmPercentInput = queryIngredientRow(row, '.dry-matter-percent-input');
const dmPerHeadInput = queryIngredientRow(row, '.dry-matter-per-head-input');
const weightPerHeadInput = queryIngredientRow(row, '.weight-per-head-input');
// Берем СВ% из инпута (может быть изменено пользователем)
let dryMatter = dmPercentInput ? (parseFloat(normalizeNumber(dmPercentInput.value)) || 0) : 0;
// Если значение 0 или пустое, берем из компонента
if (!dryMatter && componentsCache) {
const component = componentsCache.find(c => c.id === select.value || String(c.id) === String(select.value));
if (component) {
dryMatter = parseFloat(component.dry_matter || component.dryMatter || 0);
// Обновляем инпут, если он был пустым
if (dmPercentInput) {
setInputValueWithoutTrigger(dmPercentInput, dryMatter);
}
}
}
if (calculateFromDryMatter) {
// Режим "замок СВ": приоритет - сохраненное значение из инпута
let dmPerHead = dmPerHeadInput ? (parseFloat(normalizeNumber(dmPerHeadInput.value)) || 0) : 0;
// В режиме "замок СВ" НИКОГДА не пересчитываем dmPerHead из текущих данных
// Если значение отсутствует, используем 0, а не fallback расчет
// Это гарантирует, что константа сохраняется
ingredients.push({
component_id: select.value,
dryMatterPerHead: dmPerHead,
dryMatter: dryMatter
});
} else {
const wph = weightPerHeadInput ? (parseFloat(normalizeNumber(weightPerHeadInput.value)) || 0) : 0;
ingredients.push({
component_id: select.value,
weightPerHead: wph,
dryMatter: dryMatter
});
}
});
const unloadingGroups = getUnloadingGroupsForCalculation();
if (ingredients.length === 0) {
updateTotalsDisplay({
totalDryMatterPerHead: 0,
totalWeightPerHead: 0,
totalWeight: 0,
totalTripWeight: 0
});
const totalPercent = unloadingGroups
.filter(g => g && g.distributionType === 'percent')
.reduce((sum, g) => sum + (parseFloat(g.value) || 0), 0);
const totalHeads = unloadingGroups
.filter(g => g && g.distributionType === 'heads')
.reduce((sum, g) => sum + (parseFloat(g.value) || 0), 0);
applyUnloadingGroupsCalculation({
unloadingGroups: unloadingGroups.map(() => ({ calculatedWeight: 0 })),
unloadingTotals: { totalPercent: totalPercent, totalHeads: Math.trunc(totalHeads), totalWeightKg: 0 }
}, headsCount, null, callId);
isRecalculating = false;
resolve();
return;
}
// Проверяем, не был ли запрос отменен
if (calculationAbortController.signal.aborted) {
isRecalculating = false;
resolve();
return;
}
const response = await fetch('/api/recipes/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ingredients: ingredients,
headsCount: headsCount,
tripPercent: tripPercent,
unloadingGroups: unloadingGroups,
calculateFromDryMatter: calculateFromDryMatter
}),
signal: calculationAbortController.signal
});
// Проверяем, не был ли запрос отменен
if (calculationAbortController.signal.aborted) {
isRecalculating = false;
resolve();
return;
}
if (!response.ok) throw new Error('Ошибка при расчете');
const result = await response.json();
// Проверяем, не был ли запрос отменен перед обновлением UI
if (calculationAbortController.signal.aborted) {
isRecalculating = false;
resolve();
return;
}
// Сохраняем tripPercent для проверки соответствия результата
const currentTripPercent = tripPercent;
const rows = document.querySelectorAll('#ingredientsTableBody .ingredient-row');
rows.forEach((row, index) => {
if (!result.ingredients || !result.ingredients[index]) return;
const calc = result.ingredients[index];
const totalWeightInput = queryIngredientRow(row, '.total-weight-input');
if (totalWeightInput && calc.totalWeight !== undefined) {
setInputValueWithoutTrigger(totalWeightInput, calc.totalWeight);
}
const tripWeightSpan = queryIngredientRow(row, '.trip-weight-display');
if (tripWeightSpan && calc.tripWeight !== undefined) {
tripWeightSpan.textContent = formatNumber(calc.tripWeight);
}
const dmPerHeadInput = queryIngredientRow(row, '.dry-matter-per-head-input');
// В режиме "замок СВ" НЕ перезаписываем значение, если оно уже введено
// Это позволяет сохранить константу, которую пользователь задал
if (calculateFromDryMatter) {
// В режиме "замок СВ" НИКОГДА не обновляем dmPerHead из расчета
// Константа должна сохраняться независимо от изменений СВ% компонента
// НЕ обновляем поле, даже если оно было пустое - это нарушит константу
// Пересчитываем только вес на основе константы; строго до сотых (2.5 -> "2.50", не 3).
// Берём значение из weightPerHead или weight_per_head; форматируем без округления вверх (truncate).
const weightPerHeadInput = queryIngredientRow(row, '.weight-per-head-input');
const rawWph = calc.weightPerHead !== undefined ? calc.weightPerHead : calc.weight_per_head;
if (weightPerHeadInput && rawWph !== undefined && rawWph !== null) {
const raw = rawWph;
const wph = typeof raw === 'string' ? parseFloat(raw.replace(',', '.')) : parseFloat(raw);
const str = (wph !== null && !Number.isNaN(wph) && Number.isFinite(wph))
? (Math.floor(wph * 100) / 100).toFixed(2)
: '0.00';
if (weightPerHeadInput.value !== str) {
const orig = weightPerHeadInput.getAttribute('onchange');
weightPerHeadInput.removeAttribute('onchange');
weightPerHeadInput.value = str;
if (orig) weightPerHeadInput.setAttribute('onchange', orig);
}
}
} else {
// Обычный режим: обновляем СВ/гол из расчета
if (dmPerHeadInput && calc.dryMatterPerHead !== undefined) {
setInputValueWithoutTrigger(dmPerHeadInput, calc.dryMatterPerHead);
}
}
});
if (result.totals) updateTotalsDisplay(result.totals);
applyUnloadingGroupsCalculation(result, headsCount, currentTripPercent, callId);
syncAllIngredientMobileSummaries();
// Очищаем AbortController после успешного завершения
calculationAbortController = null;
isRecalculating = false;
resolve();
} catch (error) {
// Игнорируем ошибки отмены запроса
if (error.name === 'AbortError') {
isRecalculating = false;
resolve();
return;
}
console.error('Ошибка при расчете:', error);
notyf.error('Ошибка при расчете рецепта');
calculationAbortController = null;
isRecalculating = false;
reject(error);
}
}, 300); // Debounce 300ms
});
}
// Обновление отображения итогов
function updateTotalsDisplay(totals) {
document.getElementById('totalDryMatterPerHead').textContent = totals.totalDryMatterPerHead.toFixed(2);
document.getElementById('totalWeightPerHead').textContent = totals.totalWeightPerHead.toFixed(2);
document.getElementById('totalWeightIngredients').textContent = `${totals.totalWeight.toFixed(2)} кг`;
document.getElementById('totalTripWeight').textContent = `${totals.totalTripWeight.toFixed(2)} кг`;
}
// Пересчет от общего веса
async function recalculateFromTotalWeight(input) {
const row = getIngredientRowFromElement(input);
// В режиме "замка СВ" этот инпут заблокирован, и мы не должны вычислением менять СВ/гол
if (calculateFromDryMatter) return;
const headsCount = parseInt(document.getElementById('headsCount')?.value) || 0;
if (!row || headsCount <= 0) return;
const totalWeight = parseFloat(normalizeNumber(input.value)) || 0;
const weightPerHead = totalWeight / headsCount;
const weightPerHeadInput = queryIngredientRow(row, '.weight-per-head-input');
if (weightPerHeadInput) {
setInputValueWithoutTrigger(weightPerHeadInput, weightPerHead);
}
const dmPercentInput = queryIngredientRow(row, '.dry-matter-percent-input');
const dmPerHeadInput = queryIngredientRow(row, '.dry-matter-per-head-input');
const dmPercent = dmPercentInput ? (parseFloat(normalizeNumber(dmPercentInput.value)) || 0) : 0;
if (dmPerHeadInput) {
setInputValueWithoutTrigger(dmPerHeadInput, weightPerHead * (dmPercent / 100));
}
await recalculateWeights();
}
// Пересчет общих итогов
async function recalculateTotals() {
// Сохранено для совместимости (старые вызовы) — делаем единый пересчет
await recalculateWeights();
}
// Загрузка ингредиентов
async function loadIngredients(ingredients) {
const tbody = document.getElementById('ingredientsTableBody');
tbody.innerHTML = '';
// Убедимся, что компоненты загружены
if (!componentsCache) {
await loadComponents();
}
if (ingredients && ingredients.length > 0) {
const tripPercent = parseFloat(normalizeNumber(document.getElementById('tripPercent')?.value)) || 100;
const headsCount = parseInt(document.getElementById('headsCount')?.value) || 1;
for (const ingredient of ingredients) {
const row = document.createElement('div');
row.classList.add('zt-data-grid__row', 'ingredient-row');
if (ingredient.skippedToday) {
row.classList.add('ingredient-row--skipped');
}
if (ingredient.replacedToday && !ingredient.skippedToday) {
row.classList.add('ingredient-row--replaced');
}
if (ingredient.adjustedToday && !ingredient.skippedToday && !ingredient.replacedToday) {
row.classList.add('ingredient-row--adjusted');
}
const ingId = ingredient.id || ingredient.ingredient_id || ingredient.row_id;
if (ingId) {
row.dataset.id = String(ingId);
}
// amount в БД хранится как вес на рейс (trip_weight)
const tripWeight = parseFloat(ingredient.amount) || 0;
const wphFromDb = parseFloat(ingredient.weight_per_head);
const calculatedWeightPerHead =
Number.isFinite(wphFromDb) && wphFromDb > 0
? wphFromDb
: (headsCount > 0 && tripPercent > 0 ? (tripWeight / (headsCount * (tripPercent / 100))) : 0);
// ВАЖНО: Всегда берем СВ% из компонента, игнорируя ingredient.dry_matter
const componentId = ingredient.component_id;
let dryMatter = 0;
if (componentId && componentsCache) {
const component = componentsCache.find(c => c.id === componentId || String(c.id) === String(componentId));
if (component) {
dryMatter = parseFloat(component.dry_matter || component.dryMatter || 0);
}
}
// В режиме "замок СВ" dryMatterPerHead - это КОНСТАНТА
// Берем её из сохраненных данных ингредиента (если рецепт был в режиме замка)
let calculatedDryMatterPerHead = 0;
const recipeIsLocked = calculateFromDryMatter;
if (recipeIsLocked && ingredient.dry_matter_per_head !== undefined && ingredient.dry_matter_per_head !== null) {
// Берем сохраненное значение из БД
calculatedDryMatterPerHead = parseFloat(ingredient.dry_matter_per_head) || 0;
} else if (!recipeIsLocked) {
// Обычный режим: рассчитываем из текущих данных
calculatedDryMatterPerHead = calculatedWeightPerHead * (dryMatter / 100);
} else {
// Режим "замок СВ", но нет сохраненного значения
// НЕ пересчитываем из calculatedWeightPerHead, так как это может быть неправильно
// Используем 0, чтобы пользователь мог ввести значение вручную
calculatedDryMatterPerHead = 0;
}
row.innerHTML = buildIngredientRowHtml({
dryMatter: formatNumber(dryMatter),
dryMatterPerHead: (parseFloat(calculatedDryMatterPerHead) || 0).toFixed(4),
weightPerHead: formatNumber(calculatedWeightPerHead),
totalWeight: formatNumber(calculatedWeightPerHead * headsCount),
tripWeight: formatNumber(tripWeight),
skippedToday: Boolean(ingredient.skippedToday),
replacedToday: Boolean(ingredient.replacedToday),
adjustedToday: Boolean(ingredient.adjustedToday),
});
tbody.appendChild(row);
applyDryMatterModeToIngredientRow(row);
const select = row.querySelector('.ingredient-select');
await loadIngredientOptions(select);
if (ingredient.component_id) {
select.value = ingredient.component_id;
// Обновляем СВ% из компонента после установки компонента
// НО: в режиме "замок СВ" НЕ вызываем handleIngredientChange,
// чтобы не триггерить расчет, который может перезаписать dryMatterPerHead
if (calculateFromDryMatter) {
// В режиме "замок СВ" просто обновляем поле СВ% без триггера
const dryMatterPercentInput = row.querySelector('.dry-matter-percent-input');
if (dryMatterPercentInput) {
const component = componentsCache.find(c => c.id === ingredient.component_id || String(c.id) === String(ingredient.component_id));
if (component) {
const dryMatter = parseFloat(component.dry_matter || component.dryMatter || 0);
setInputValueWithoutTrigger(dryMatterPercentInput, dryMatter);
}
}
} else {
// В обычном режиме вызываем handleIngredientChange
handleIngredientChange(select);
}
}
}
await recalculateWeights();
syncAllIngredientMobileSummaries();
}
}
// Удаление ингредиента
function removeIngredient(button) {
let row = button?.closest?.('#ingredientsTableBody .ingredient-row');
if (!row && currentIngredientSheetRow) {
row = currentIngredientSheetRow;
closeIngredientMobileSheet();
}
if (!row) return;
if (row.dataset && row.dataset.id) {
deletedIngredientIds.push(row.dataset.id);
}
row.remove();
recalculateWeights();
}
// Перемещение ингредиента
function moveIngredientUp(button) {
let row = button?.closest?.('#ingredientsTableBody .ingredient-row');
if (!row && currentIngredientSheetRow) {
row = currentIngredientSheetRow;
closeIngredientMobileSheet();
}
if (!row) return;
const tbody = row.parentNode;
const prevRow = row.previousElementSibling;
if (prevRow) {
tbody.insertBefore(row, prevRow);
recalculateWeights();
}
}
function moveIngredientDown(button) {
let row = button?.closest?.('#ingredientsTableBody .ingredient-row');
if (!row && currentIngredientSheetRow) {
row = currentIngredientSheetRow;
closeIngredientMobileSheet();
}
if (!row) return;
const tbody = row.parentNode;
const nextRow = row.nextElementSibling;
if (nextRow) {
tbody.insertBefore(nextRow, row);
recalculateWeights();
}
}
// Получение данных ингредиентов
async function getIngredientsData() {
const ingredients = [];
const tripPercent = parseFloat(document.getElementById('tripPercent').value) || 100;
// Убедимся, что компоненты загружены
if (!componentsCache) {
await loadComponents();
}
const rows = document.querySelectorAll('#ingredientsTableBody .ingredient-row');
for (const row of rows) {
const select = queryIngredientRow(row, '.ingredient-select');
if (select?.value) {
const weightPerHead = parseFloat(normalizeNumber(queryIngredientRow(row, '.weight-per-head-input')?.value)) || 0;
const totalWeight = parseFloat(normalizeNumber(queryIngredientRow(row, '.total-weight-input')?.value)) || 0;
const tripWeightText = queryIngredientRow(row, '.trip-weight-display')?.textContent || '';
let tripWeight = parseFloat(normalizeNumber(tripWeightText)) || 0;
if (!tripWeight && totalWeight > 0) {
tripWeight = totalWeight * (tripPercent / 100);
}
// Берем СВ% из инпута (актуальное значение, которое может быть изменено пользователем)
let inputDryMatter = parseFloat(normalizeNumber(queryIngredientRow(row, '.dry-matter-percent-input')?.value)) || 0;
// Если поле пустое или 0, берем из компонента
if (!inputDryMatter && componentsCache) {
const component = componentsCache.find(c => c.id === select.value || String(c.id) === String(select.value));
if (component) {
inputDryMatter = parseFloat(component.dry_matter || component.dryMatter || 0);
}
}
// В режиме "замок СВ" обязательно отправляем dry_matter_per_head
// В обычном режиме тоже отправляем для совместимости
const dmPerHeadInput = queryIngredientRow(row, '.dry-matter-per-head-input');
let dmPerHead = parseFloat(normalizeNumber(dmPerHeadInput?.value)) || 0;
// Если в режиме "замок СВ" и значение не указано, вычисляем из текущих данных
if (calculateFromDryMatter && dmPerHead <= 0 && inputDryMatter > 0 && weightPerHead > 0) {
dmPerHead = weightPerHead * (inputDryMatter / 100);
}
const payload = {
component_id: select.value,
weight_per_head: weightPerHead,
amount: parseFloat(formatNumber(tripWeight)),
// ВАЖНО: СВ% компонента обновляется отдельным запросом (PUT /api/components/<id>),
// поэтому при сохранении рецепта НЕ отправляем dry_matter, чтобы не откатывать компонент случайными значениями.
// При "замке СВ" бэкенд использует это поле как источник истины (константа)
dry_matter_per_head: dmPerHead,
order: ingredients.length + 1
};
if (row.dataset && row.dataset.id) {
payload.id = row.dataset.id;
}
ingredients.push(payload);
}
}
return ingredients;
}
// Сохранение рецепта. closeAfterSave: true — после успеха закрыть форму, false — остаться в форме
async function saveRecipe(event, closeAfterSave) {
if (event) {
event.preventDefault();
}
// Грубая защита от даблкликов/повторов
const now = Date.now();
if (now - lastSaveAt < SAVE_COOLDOWN_MS) {
return;
}
lastSaveAt = now;
// Дебаунс: откладываем реальную отправку
if (saveDebounceTimer) {
clearTimeout(saveDebounceTimer);
}
saveDebounceTimer = setTimeout(() => doSaveRecipe(!!closeAfterSave), SAVE_DEBOUNCE_MS);
}
async function doSaveRecipe(closeAfterSave) {
// Предотвращаем повторные отправки
if (isSubmitting) {
return;
}
closeIngredientMobileSheet();
closeUnloadingGroupMobileSheet();
closeRecipeHelpModal();
closeRecipePlainHelp();
isSubmitting = true;
const submitButtons = Array.from(document.querySelectorAll('#recipeForm button[type="submit"]'));
const saveExitButtons = Array.from(document.querySelectorAll('.recipe-save-exit-btn'));
const submitOriginalHtml = submitButtons.map((btn) => btn.innerHTML);
submitButtons.forEach((btn) => {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
});
saveExitButtons.forEach((btn) => {
btn.disabled = true;
});
try {
const recipeData = {
name: document.getElementById('recipeName')?.value || '',
heads_count: parseInt(document.getElementById('headsCount')?.value) || 0,
mixing_time: parseInt(document.getElementById('mixingTime')?.value) || 0,
trip_percent: parseFloat(document.getElementById('tripPercent')?.value) || 100,
dry_matter_locked: !!calculateFromDryMatter,
unloading_link_broken: unloadingLinkBroken,
ingredients: typeof getIngredientsData === 'function' ? await getIngredientsData() : [],
unloading_groups: typeof getUnloadingGroupsData === 'function' ? getUnloadingGroupsData() : [],
deleted_ingredient_ids: deletedIngredientIds || [],
deleted_unloading_group_ids: deletedGroupIds || []
};
if (
currentDispenserType !== 'mill' &&
(!recipeData.unloading_groups || recipeData.unloading_groups.length === 0)
) {
notyf.error('Добавьте группу выгрузки');
isSubmitting = false;
submitButtons.forEach((btn, i) => {
btn.disabled = false;
if (submitOriginalHtml[i] !== undefined) btn.innerHTML = submitOriginalHtml[i];
});
saveExitButtons.forEach((btn) => {
btn.disabled = false;
});
return;
}
// Для кормоцеха: добавляем target_component_id и привязку к кормоцеху
if (currentDispenserType === 'mill') {
const targetComponentSelect = document.getElementById('targetComponentSelect');
if (targetComponentSelect) {
recipeData.target_component_id = targetComponentSelect.value || null;
}
if (selectedDispenser) {
recipeData.dispenser_id = selectedDispenser;
}
}
let url, method;
const wasCreate = !selectedRecipe;
if (selectedRecipe) {
url = `/api/recipes/${selectedRecipe}`;
method = 'PUT';
} else {
// Для кормоцеха можно создать рецепт без периода
if (currentDispenserType === 'mill') {
url = `/api/recipes`;
method = 'POST';
} else {
if (!selectedPeriod) {
throw new Error('Период не выбран');
}
url = `/api/periods/${selectedPeriod}/recipes`;
method = 'POST';
}
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(recipeData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const detail = errorData.message || errorData.detail;
let message = 'Ошибка при сохранении рейса';
if (typeof detail === 'string') {
if (response.status === 401) {
message = 'Сессия истекла. Войдите снова.';
} else {
message = detail;
}
}
throw new Error(message);
}
const result = await response.json();
if (wasCreate && result && result.id) {
selectedRecipe = result.id;
pageState.set({ selectedRecipe });
}
const savedRecipeName = document.getElementById('recipeName')?.value?.trim();
notyf.success('Сохранено', {
record: savedRecipeName ? { recipeName: savedRecipeName } : undefined,
});
if (closeAfterSave) {
showRecipeEdit(false);
}
// Обновляем список рецептов
if (currentDispenserType === 'mill' && selectedDispenser) {
// Для кормоцеха загружаем рецепты без периодов
await loadMillRecipes(selectedDispenser);
} else if (selectedPeriod) {
// Для кормораздатчика загружаем рецепты периода
await loadRecipes(selectedPeriod);
}
// Обновляем количество рейсов в периодах
if (selectedDispenser && currentDispenserType !== 'mill') {
await loadPeriods(selectedDispenser);
}
// После успешного сохранения очищаем списки удалённых id
deletedIngredientIds = [];
deletedGroupIds = [];
} catch (error) {
console.error('Ошибка при сохранении рейса:', error);
notyf.error(error.message, { fallback: 'Ошибка при сохранении рейса' });
} finally {
isSubmitting = false;
submitButtons.forEach((btn, i) => {
btn.disabled = false;
if (submitOriginalHtml[i] !== undefined) btn.innerHTML = submitOriginalHtml[i];
});
saveExitButtons.forEach((btn) => {
btn.disabled = false;
});
}
}
function setupEventListeners() {
const recipeForm = document.getElementById('recipeForm');
if (recipeForm) {
recipeForm.addEventListener('submit', async (e) => {
e.preventDefault();
await saveRecipe(e, false);
});
// Предотвращаем сохранение по Enter
recipeForm.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
}
});
}
const headsCountInput = document.getElementById('headsCount');
if (headsCountInput) {
headsCountInput.addEventListener('change', () => {
recalculateWeights();
// Обновляем максимальные значения в группах выгрузки
document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').forEach(row => {
const typeSelect = row.querySelector('.group-type');
if (typeSelect && typeSelect.value === 'heads') {
if (typeof handleGroupTypeChange === 'function') {
handleGroupTypeChange(typeSelect);
}
}
});
if (typeof updateTotalValues === 'function') {
updateTotalValues();
}
});
}
const tripPercentInput = document.getElementById('tripPercent');
if (tripPercentInput) {
tripPercentInput.addEventListener('input', (e) => {
updateTripPercent(e.target.value);
});
tripPercentInput.addEventListener('blur', (e) => {
validateTripPercent(e.target);
});
}
initRecipesPage({
addIngredient,
toggleUnloadingLink,
addUnloadingGroup,
showRecipeEdit,
printAllRecipesInPeriod,
printRecipe,
saveRecipe,
moveGroupUp,
moveGroupDown,
removeUnloadingGroup,
moveIngredientUp,
moveIngredientDown,
removeIngredient,
openIngredientMobileSheet,
closeIngredientMobileSheet,
removeIngredientFromSheet,
openUnloadingGroupMobileSheet,
closeUnloadingGroupMobileSheet,
openRecipeHelp,
openRecipeHelpModal,
closeRecipeHelpModal,
closeRecipePlainHelp,
handleGroupTypeChange,
validateGroupValue,
handleIngredientChange,
handleDryMatterPercentChange,
recalculateWeights,
recalculateFromTotalWeight,
openSettings,
closeSettings,
toggleCredentialsForm,
toggleSyncForm,
changeCredentials,
editSyncClientSettings,
deleteSyncClientSettings,
saveSyncClientDisplayName,
loadSyncClientsSettings,
selectDispenser,
selectRecipe,
selectPeriod,
copyRecipe,
deleteRecipe,
unskipRecipeToday,
unskipIngredientPartsToday,
unskipGroupPartsToday,
pasteRecipe,
createNewRecipe,
moveRecipeUp,
moveRecipeDown,
createNewRecipeForMill,
logout,
mobileDashboardBack: () => recipesDataController?.mobileDashboardBack?.(),
getSelectedDispenser: () => selectedDispenser
});
recipesEditorController = createRecipesEditorController({
notyf,
getCurrentDispenserType: () => currentDispenserType,
setCurrentDispenserType: (value) => {
currentDispenserType = value;
},
getComponentsCache: () => componentsCache,
ensureComponentsLoaded: async () => {
if (!componentsCache) {
await loadComponents();
}
},
setSelectedDispenser: (value) => {
selectedDispenser = value;
pageState.set({ selectedDispenser });
},
setSelectedPeriod: (value) => {
selectedPeriod = value;
pageState.set({ selectedPeriod });
},
setSelectedRecipe: (value) => {
selectedRecipe = value;
pageState.set({ selectedRecipe });
},
resetDeletedEntities: () => {
deletedIngredientIds = [];
deletedGroupIds = [];
},
setUnloadingLinkBroken: (value) => {
unloadingLinkBroken = value;
},
initializeUnloadingLinkButton,
updateTripPercentFieldState,
setDryMatterMode,
loadIngredients,
loadUnloadingGroups,
showRecipeEdit
});
recipesDataController = createRecipesDataController({
notyf,
getSelectedDispenser: () => selectedDispenser,
setSelectedDispenser: (value) => {
selectedDispenser = value;
pageState.set({ selectedDispenser });
},
getSelectedPeriod: () => selectedPeriod,
setSelectedPeriod: (value) => {
selectedPeriod = value;
pageState.set({ selectedPeriod });
},
getSelectedRecipe: () => selectedRecipe,
setSelectedRecipe: (value) => {
selectedRecipe = value;
pageState.set({ selectedRecipe });
},
setCurrentDispenserType: (value) => {
currentDispenserType = value;
},
getCurrentDispenserType: () => currentDispenserType,
loadRecipeDetails,
showRecipeEdit,
updatePasteButtons
});
recipesDataController.setMobileDashboardStep("dispensers");
recipesOperationsController = createRecipesOperationsController({
notyf,
getSelectedRecipe: () => selectedRecipe,
setSelectedRecipe: (value) => {
selectedRecipe = value;
pageState.set({ selectedRecipe });
},
getSelectedPeriod: () => selectedPeriod,
setSelectedPeriod: (value) => {
selectedPeriod = value;
pageState.set({ selectedPeriod });
},
getSelectedDispenser: () => selectedDispenser,
getCurrentDispenserType: () => currentDispenserType,
showRecipeEdit,
loadRecipes,
loadPeriods,
selectPeriod,
persistRecipe: (closeAfterSave) => doSaveRecipe(!!closeAfterSave),
toggleUnloadingBlocks: () => recipesEditorController.toggleUnloadingBlocks(),
resetDeletedEntities: () => {
deletedIngredientIds = [];
deletedGroupIds = [];
},
ensureComponentsLoaded: async () => {
if (!componentsCache) {
await loadComponents();
}
},
setUnloadingLinkBroken: (value) => {
unloadingLinkBroken = value;
},
initializeUnloadingLinkButton,
updateTripPercentFieldState,
setDryMatterMode,
loadIngredients,
loadUnloadingGroups,
recalculateWeights,
updateTotalValues
});
attachRecipePeriodTransfer({
getCurrentDispenserType: () => currentDispenserType,
getSelectedDispenser: () => selectedDispenser,
getSelectedPeriod: () => selectedPeriod,
selectDispenser,
selectPeriod,
loadRecipes,
loadPeriods,
notyf
});
attachRecipeListDragDrop(document.getElementById('recipesList'), {
moveRecipe: (recipeId, fromIndex, toIndex) =>
recipesOperationsController.moveRecipe(recipeId, fromIndex, toIndex),
notyf
});
// Загружаем компоненты при старте
loadComponents();
window.getSelectedDispenser = () => selectedDispenser;
window.reloadAfterSkipRestore = reloadAfterSkipRestore;
window.loadRecipeDetailsWithPlanOverlay = loadRecipeDetailsWithPlanOverlay;
}
async function openRecipeFromNotificationLink() {
const params = new URLSearchParams(window.location.search);
const recipeId = params.get('recipe');
if (!recipeId || !recipesDataController) return;
try {
const response = await fetch(`/api/recipes/${encodeURIComponent(recipeId)}/open-context`);
if (!response.ok) return;
const ctx = await response.json();
if (!ctx.dispenserId) return;
await recipesDataController.selectDispenser(ctx.dispenserId);
if (ctx.periodId) {
await recipesDataController.selectPeriod(ctx.periodId);
}
await recipesDataController.selectRecipe(ctx.recipeId || recipeId);
} catch (error) {
console.error('Не удалось открыть рейс из уведомления:', error);
}
}
// Проверяем авторизацию при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
checkAuth();
setupMobileMenu();
setupEventListeners();
loadDispensers().then(() => openRecipeFromNotificationLink());
const toggleButton = document.getElementById('toggleDryMatterMode');
if (toggleButton) {
toggleButton.addEventListener('click', function(e) {
e.preventDefault();
toggleDryMatterMode();
});
}
const dryMatterHelpBtn = document.getElementById('dryMatterModeHelpIcon');
if (dryMatterHelpBtn) {
setRecipeHelpContent(dryMatterHelpBtn, 'Сухое вещество (СВ)', [
'В таблице два столбца: <strong>«СВ, %»</strong> и <strong>«СВ/гол»</strong>. <strong>СВ</strong> — сухое вещество, часть корма без воды.',
'<strong>Замок открыт</strong> (видна «дырка»): программа сама считает <strong>«СВ/гол»</strong> из <strong>«Вес/гол»</strong> и <strong>«СВ, %»</strong>.',
'<strong>Замок закрыт</strong> (кнопка синяя): <strong>«СВ/гол»</strong> задаёте вы сами, программа пересчитывает веса.',
'Столбец <strong>«СВ, %»</strong> можно менять всегда. При сохранении рецепта процент обновится в справочнике ингредиента.'
]);
}
const unloadingLinkHelpBtn = document.getElementById('unloadingLinkHelpIcon');
if (unloadingLinkHelpBtn) {
setRecipeHelpContent(unloadingLinkHelpBtn, 'Связь с процентом на рейс', [
'Кнопка с <strong>цепочкой</strong> управляет связью таблицы и поля <strong>«Процент на рейс»</strong>.',
'<strong>Цельная синяя цепь</strong> — сумма процентов из таблицы переносится в поле автоматически.',
'<strong>Разорванная цепь</strong> — связь выключена, процент вводится вручную в поле выше.',
'Нажмите кнопку с цепочкой ещё раз, чтобы переключить режим.'
]);
}
updateTripPercentFieldState();
initRecipePlainHelpOutsideClose();
});
</script>
<!-- Модалка справки -->
<div id="recipeHelpModal" class="recipe-help-modal" hidden>
<button type="button" class="recipe-mobile-edit-sheet-backdrop" data-action="close-recipe-help" aria-label="Закрыть"></button>
<div class="recipe-help-modal-panel recipe-mobile-edit-sheet-panel" role="dialog" aria-modal="true" aria-labelledby="recipeHelpModalTitle">
<div class="recipe-mobile-edit-sheet-grab" aria-hidden="true"></div>
<div class="recipe-mobile-edit-sheet-header">
<h3 id="recipeHelpModalTitle" class="recipe-mobile-edit-sheet-title">Справка</h3>
<button type="button" class="btn btn-primary btn-sm recipe-mobile-edit-sheet-done" data-action="close-recipe-help">Понятно</button>
</div>
<div id="recipeHelpModalBody" class="recipe-help-modal-body recipe-mobile-edit-sheet-body"></div>
</div>
</div>
<!-- Мобильная модалка редактирования ингредиента -->
<div id="ingredientMobileSheet" class="ingredient-mobile-sheet recipe-mobile-edit-sheet" hidden>
<button type="button" class="ingredient-mobile-sheet-backdrop recipe-mobile-edit-sheet-backdrop" data-action="close-ingredient-sheet" aria-label="Закрыть"></button>
<div class="ingredient-mobile-sheet-panel recipe-mobile-edit-sheet-panel" role="dialog" aria-modal="true" aria-labelledby="ingredientMobileSheetTitle">
<div class="recipe-mobile-edit-sheet-grab" aria-hidden="true"></div>
<div class="ingredient-mobile-sheet-header recipe-mobile-edit-sheet-header">
<h3 id="ingredientMobileSheetTitle" class="ingredient-mobile-sheet-title recipe-mobile-edit-sheet-title">Ингредиент</h3>
<button type="button" class="btn btn-primary btn-sm recipe-mobile-edit-sheet-done" data-action="close-ingredient-sheet">Готово</button>
</div>
<div id="ingredientMobileSheetBody" class="ingredient-mobile-sheet-body recipe-mobile-edit-sheet-body"></div>
</div>
</div>
<div id="unloadingGroupMobileSheet" class="unloading-group-mobile-sheet recipe-mobile-edit-sheet" hidden>
<button type="button" class="recipe-mobile-edit-sheet-backdrop" data-action="close-unloading-group-sheet" aria-label="Закрыть"></button>
<div class="recipe-mobile-edit-sheet-panel" role="dialog" aria-modal="true" aria-labelledby="unloadingGroupMobileSheetTitle">
<div class="recipe-mobile-edit-sheet-grab" aria-hidden="true"></div>
<div class="recipe-mobile-edit-sheet-header">
<h3 id="unloadingGroupMobileSheetTitle" class="recipe-mobile-edit-sheet-title">Группа выгрузки</h3>
<button type="button" class="btn btn-primary btn-sm recipe-mobile-edit-sheet-done" data-action="close-unloading-group-sheet">Готово</button>
</div>
<div id="unloadingGroupMobileSheetBody" class="recipe-mobile-edit-sheet-body"></div>
</div>
</div>
<!-- Настройки: сверху карточка пользователя, снизу выбор темы (общий стиль на всех страницах) -->
<div id="settingsModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content">
<div class="settings-modal-header">
<h3><i class="fas fa-cog"></i> Настройки</h3>
<span class="settings-close" data-action="close-settings">&times;</span>
</div>
<div class="settings-modal-body">
<div class="settings-user-card settings-user-card--clickable" data-action="toggle-credentials-form">
<h4 class="settings-card-title"><i class="fas fa-user"></i> Сменить логин/пароль <i class="fas fa-chevron-down settings-chevron"></i></h4>
</div>
<div class="settings-credentials-form" id="credentialsForm" style="display: none;">
<form id="settingsForm">
<div class="form-group">
<label for="oldLogin">Текущий логин:</label>
<input type="text" id="oldLogin" class="form-control" readonly>
</div>
<div class="form-group">
<label for="oldPassword">Текущий пароль:</label>
<input type="password" id="oldPassword" class="form-control">
</div>
<div class="form-group">
<label for="newLogin">Новый логин:</label>
<input type="text" id="newLogin" class="form-control" required>
</div>
<div class="form-group">
<label for="newPassword">Новый пароль:</label>
<input type="password" id="newPassword" class="form-control" required>
</div>
<div class="form-group">
<label for="confirmPassword">Подтвердите новый пароль:</label>
<input type="password" id="confirmPassword" class="form-control" required>
</div>
</form>
</div>
<div class="settings-sync-card settings-sync-card--clickable" data-action="toggle-sync-form">
<h4 class="settings-card-title"><i class="fas fa-sync-alt"></i> Настройка синхронизации <i class="fas fa-chevron-down settings-chevron"></i></h4>
</div>
<div class="settings-sync-form" id="syncSettingsForm" style="display: none;">
<div id="syncClientsListSettings"></div>
<div id="syncClientsEmptySettings" class="text-muted small" style="display: none;">Нет подключенных клиентов</div>
</div>
<div class="settings-feed-quality-card settings-feed-quality-card--clickable" data-action="open-feed-quality-settings">
<h4 class="settings-card-title"><i class="fas fa-triangle-exclamation"></i> Контроль отклонений <i class="fas fa-chevron-right settings-chevron"></i></h4>
</div>
<div class="settings-theme-card">
<h4 class="settings-card-title"><i class="fas fa-palette"></i> Тема оформления</h4>
<div id="wespThemePicker" class="theme-picker-mount" role="radiogroup" aria-label="Тема оформления"></div>
<div class="settings-contrast-row">
<span class="settings-contrast-row__label">Усиленный контраст</span>
<label class="theme-switch" for="wespHighContrastToggle">
<input type="checkbox" id="wespHighContrastToggle" role="switch" aria-label="Усиленный контраст">
<span class="theme-slider"></span>
</label>
</div>
</div>
<div class="text-muted small mt-2">Версия системы 2.1</div>
</div>
<div class="settings-modal-footer">
<button type="button" class="btn btn-secondary" data-action="close-settings">Отмена</button>
<button type="button" class="btn btn-primary" data-action="change-credentials">
<i class="fas fa-save"></i> Сохранить
</button>
</div>
</div>
</div>
<!-- Правая закладка «Клиенты синхронизации» -->
<button type="button" class="sync-panel-tab" id="syncPanelTab" title="Клиенты синхронизации" aria-label="Открыть панель клиентов">
<span></span><span></span><span></span>
</button>
<div class="sync-panel-overlay" id="syncPanelOverlay"></div>
<div class="sync-panel" id="syncPanel">
<div class="sync-panel-header">
<h3 id="syncPanelTitle">Клиенты синхронизации</h3>
<button type="button" class="sync-panel-close" id="syncPanelClose" aria-label="Закрыть">
<i class="fas fa-times"></i>
</button>
</div>
<div class="sync-panel-body">
<div id="syncClientsView">
<div id="syncClientsList"></div>
<div id="syncClientsEmpty" class="sync-no-data" style="display: none;">Нет подключенных клиентов</div>
<div id="syncClientsError" class="sync-no-data" style="display: none;"></div>
</div>
<div id="syncDeliveriesView" style="display: none;">
<div class="sync-deliveries-toolbar">
<button type="button" class="sync-deliveries-back" id="syncDeliveriesBack">
<i class="fas fa-arrow-left"></i> Назад
</button>
<span id="syncDeliveriesClientName" style="font-weight: 600;"></span>
</div>
<p class="small text-muted mb-2">Данные, отправленные за последние 24 ч</p>
<ul class="sync-deliveries-list" id="syncDeliveriesList"></ul>
<div id="syncDeliveriesEmpty" class="sync-no-data" style="display: none;">Нет отправок за этот период</div>
<div id="syncDeliveriesError" class="sync-no-data" style="display: none;"></div>
</div>
</div>
</div>
<footer class="app-footer" data-wesp-enter>
<span class="text-muted small">Комптон® является зарегистрированным товарным знаком</span>
</footer>
<script>
(function() {
function escapeHtml(s) {
if (s == null) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
const TABLES_RU = {
'component': 'Компонент', 'recipe': 'Рецепт', 'ingredient': 'Ингредиент',
'unloading_group': 'Группа выгрузки', 'loading_report': 'Отчёт загрузки',
'loading_report_component': 'Компонент отчёта', 'component_loading_time': 'Время загрузки',
'unloading_report': 'Отчёт выгрузки', 'unloading_report_group': 'Группа выгрузки',
'feed_dispenser': 'Кормораздатчик', 'feeding_period': 'Период', 'period_recipes': 'Рецепт периода',
'trip': 'Рейс'
};
const ACTIONS_RU = { 'create': 'создание', 'update': 'изменение', 'delete': 'удаление' };
const tab = document.getElementById('syncPanelTab');
const overlay = document.getElementById('syncPanelOverlay');
const panel = document.getElementById('syncPanel');
const closeBtn = document.getElementById('syncPanelClose');
const clientsList = document.getElementById('syncClientsList');
const clientsEmpty = document.getElementById('syncClientsEmpty');
const clientsError = document.getElementById('syncClientsError');
const deliveriesView = document.getElementById('syncDeliveriesView');
const clientsView = document.getElementById('syncClientsView');
const deliveriesBack = document.getElementById('syncDeliveriesBack');
const deliveriesClientName = document.getElementById('syncDeliveriesClientName');
const deliveriesList = document.getElementById('syncDeliveriesList');
const deliveriesEmpty = document.getElementById('syncDeliveriesEmpty');
const deliveriesError = document.getElementById('syncDeliveriesError');
function openSyncPanel() {
overlay.classList.add('open');
panel.classList.add('open');
loadSyncClients();
}
function closeSyncPanel() {
overlay.classList.remove('open');
panel.classList.remove('open');
showClientsView();
}
function showClientsView() {
clientsView.style.display = 'block';
deliveriesView.style.display = 'none';
document.getElementById('syncPanelTitle').textContent = 'Клиенты синхронизации';
}
function showDeliveriesView(name) {
clientsView.style.display = 'none';
deliveriesView.style.display = 'block';
document.getElementById('syncPanelTitle').textContent = 'Данные клиенту';
deliveriesClientName.textContent = name;
}
tab.addEventListener('click', openSyncPanel);
closeBtn.addEventListener('click', closeSyncPanel);
overlay.addEventListener('click', closeSyncPanel);
deliveriesBack.addEventListener('click', function() {
showClientsView();
loadSyncClients();
});
async function loadSyncClients() {
clientsList.innerHTML = '';
clientsEmpty.style.display = 'none';
clientsError.style.display = 'none';
try {
const r = await fetch('/api/sync/clients');
const data = await r.json();
if (!r.ok) {
clientsError.textContent = data.error || 'Ошибка загрузки';
clientsError.style.display = 'block';
return;
}
if (!data || data.length === 0) {
clientsEmpty.style.display = 'block';
return;
}
data.forEach(function(c) {
const displayName = c.display_name || c.client_name || c.node_id;
const lastSeen = c.last_seen ? new Date(c.last_seen).toLocaleString('ru-RU') : 'Никогда';
const card = document.createElement('button');
card.type = 'button';
card.className = 'sync-client-card';
card.innerHTML = '<div class="name">' + escapeHtml(displayName) + '</div><div class="meta">Последнее подключение: ' + escapeHtml(lastSeen) + '</div>';
card.addEventListener('click', function() {
loadSyncDeliveries(c.node_id, displayName);
});
clientsList.appendChild(card);
});
} catch (e) {
clientsError.textContent = 'Не удалось загрузить список клиентов';
clientsError.style.display = 'block';
}
}
async function loadSyncDeliveries(nodeId, clientName) {
showDeliveriesView(clientName);
deliveriesList.innerHTML = '';
deliveriesEmpty.style.display = 'none';
deliveriesError.style.display = 'none';
try {
const r = await fetch('/api/sync/clients/' + encodeURIComponent(nodeId) + '/deliveries?hours=24');
const data = await r.json();
if (!r.ok) {
deliveriesError.textContent = data.error || 'Ошибка загрузки';
deliveriesError.style.display = 'block';
return;
}
const list = data.deliveries || [];
if (list.length === 0) {
deliveriesEmpty.style.display = 'block';
return;
}
list.forEach(function(d) {
const tn = TABLES_RU[d.table_name] || d.table_name;
const act = ACTIONS_RU[d.action] || d.action;
const raw = d.processed_at || d.created_at;
const when = raw ? (function() {
const dt = new Date(raw);
return dt.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
})() : '—';
const name = (d.name && d.name.trim()) ? d.name.trim() : (tn + ' · ' + act);
const ctx = (d.context && d.context.trim()) ? d.context.trim() : '';
const footer = tn + ' ' + act + ' ' + when;
const parts = [];
if (ctx) parts.push('<div class="sync-delivery-context">' + escapeHtml(ctx) + '</div>');
parts.push('<div class="sync-delivery-name">' + escapeHtml(name) + '</div>');
parts.push('<div class="sync-delivery-footer"><span class="action-badge">' + escapeHtml(tn) + ' ' + escapeHtml(act) + '</span><span class="sync-delivery-time">' + escapeHtml(when) + '</span></div>');
const li = document.createElement('li');
li.innerHTML = parts.join('');
deliveriesList.appendChild(li);
});
} catch (e) {
deliveriesError.textContent = 'Не удалось загрузить поставки';
deliveriesError.style.display = 'block';
}
}
})();
</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 && window.WespDialog) {
WespUpdateNotifier.init({ dialog: window.WespDialog });
}
});
</script>
</body>