5474 lines
256 KiB
HTML
5474 lines
256 KiB
HTML
<!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">×</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> |