Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/static/fonts/inter/inter-300.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/fonts/inter/inter-400.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/fonts/inter/inter-500.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/fonts/inter/inter-600.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/fonts/inter/inter-700.ttf') format('truetype');
}
@@ -0,0 +1,93 @@
.kiosk-calibration-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(26, 30, 28, 0.45);
z-index: 5000;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
.kiosk-calibration-backdrop.is-open {
display: flex;
}
.kiosk-calibration-panel {
width: min(920px, 100%);
max-height: min(92vh, 900px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(26, 30, 28, 0.18);
}
.kiosk-calibration-panel--dark {
background: #1f1f1f;
color: #fff;
border: 1px solid #2f2f2f;
}
.kiosk-calibration-panel--light {
background: #fff;
color: #1a1e1c;
border: 1px solid rgba(26, 30, 28, 0.08);
}
.kiosk-calibration-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
flex-shrink: 0;
}
.kiosk-calibration-panel--dark .kiosk-calibration-head {
border-bottom: 1px solid #2f2f2f;
}
.kiosk-calibration-panel--light .kiosk-calibration-head {
border-bottom: 1px solid rgba(26, 30, 28, 0.08);
}
.kiosk-calibration-title {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
}
.kiosk-calibration-close {
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-family: inherit;
flex-shrink: 0;
}
.kiosk-calibration-panel--dark .kiosk-calibration-close {
border: 1px solid #7a7a7a;
background: #3a3a3a;
color: #fff;
}
.kiosk-calibration-panel--light .kiosk-calibration-close {
border: 1px solid rgba(26, 30, 28, 0.14);
background: #fff;
color: #1a1e1c;
}
.kiosk-calibration-frame {
border: 0;
width: 100%;
flex: 1 1 auto;
min-height: 320px;
max-height: calc(92vh - 64px);
background: #fff;
display: block;
}
+6
View File
@@ -0,0 +1,6 @@
/* Киоск: скрыть указатель мыши (Chromium + labwc). */
html.wesp-kiosk-hide-cursor,
html.wesp-kiosk-hide-cursor body,
html.wesp-kiosk-hide-cursor * {
cursor: none !important;
}
+404
View File
@@ -0,0 +1,404 @@
/**
* Тема киоск-модалок: в светлой теме (html[data-theme="light"]) шапки и панели
* совпадают со светлым интерфейсом, без «тёмного» вида заголовков.
*/
/* --- Меню ☰ --- */
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-panel {
background: #ffffff !important;
border-color: #e5e7eb !important;
color: #111827 !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-link {
background: #f3f4f6 !important;
border-color: #e5e7eb !important;
color: #111827 !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-link:hover {
background: #1b66a8 !important;
border-color: #4ea8ff !important;
color: #ffffff !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-close {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Проверка БД / калибровка (iframe) --- */
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel {
background: #ffffff !important;
color: #111827 !important;
border-color: #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel > div:first-child,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel > div:first-child {
border-bottom-color: #e5e7eb !important;
}
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel h2,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel h2 {
color: #111827 !important;
}
html[data-theme="light"] #kioskDbCheckClose,
html[data-theme="light"] #kioskCalibrationClose {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Привязка терминала (QR) --- */
@keyframes kioskPairSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#kioskPairModal {
display: none;
position: fixed;
inset: 0;
z-index: 5000;
box-sizing: border-box;
padding: 8px;
padding:
max(8px, env(safe-area-inset-top, 0px))
max(8px, env(safe-area-inset-right, 0px))
max(8px, env(safe-area-inset-bottom, 0px))
max(8px, env(safe-area-inset-left, 0px));
background: rgba(0, 0, 0, 0.55);
overflow: hidden;
}
#kioskPairModal.kiosk-pair-open {
display: flex;
align-items: stretch;
justify-content: center;
height: 100vh;
height: 100dvh;
}
body.kiosk-pair-modal-open {
overflow: hidden !important;
}
#kioskPairModal .kiosk-pair-panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: min(480px, 100%);
height: 100%;
max-height: 100%;
min-height: 0;
margin: 0 auto;
overflow: hidden;
background: #1f1f1f;
color: #fff;
border-radius: 14px;
padding: 12px;
text-align: center;
border: 1px solid #2f2f2f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
}
#kioskPairModal .kiosk-pair-header {
flex: 0 0 auto;
}
#kioskPairModal .kiosk-pair-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 0 2px;
}
#kioskPairModal h3 {
margin: 0 0 4px 0;
font-size: clamp(0.95rem, 3.6vw, 1.1rem);
}
#kioskPairModal .kiosk-pair-subtitle {
margin: 0 0 6px 0;
font-size: clamp(0.8rem, 3vw, 0.9rem);
}
#kioskPairModal .kiosk-pair-steps {
flex: 0 0 auto;
margin: 0 auto 8px auto;
text-align: left;
max-width: 400px;
font-size: clamp(0.72rem, 2.6vw, 0.8rem);
line-height: 1.35;
color: #d7d7d7;
}
#kioskPairModal .kiosk-pair-loading {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
color: #d7d7d7;
font-size: 14px;
}
#kioskPairModal .kiosk-pair-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #5a5a5a;
border-top-color: #4ea8ff;
border-radius: 50%;
animation: kioskPairSpin 0.8s linear infinite;
flex-shrink: 0;
}
#kioskPairModal .kiosk-pair-qr-wrap {
display: none;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
width: 100%;
margin: 4px auto;
padding: 4px 0;
}
#kioskPairModal .kiosk-pair-qr-wrap.kiosk-pair-qr-visible {
display: flex;
}
#kioskPairModal .kiosk-pair-qr {
display: block;
width: min(92vw, 52vh, 360px);
min-width: min(160px, 78vw);
height: auto;
aspect-ratio: 1 / 1;
object-fit: contain;
border: 1px solid #3b3b3b;
border-radius: 10px;
background: #fff;
}
#kioskPairModal .kiosk-pair-url {
flex: 0 0 auto;
font-size: clamp(0.62rem, 2.2vw, 0.72rem);
word-break: break-all;
margin: 6px 0;
color: #b8d8ff;
text-align: left;
max-height: 3.6em;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#kioskPairModal .kiosk-pair-status {
flex: 0 0 auto;
font-size: clamp(0.72rem, 2.6vw, 0.8rem);
color: #d7d7d7;
margin: 4px 0 6px;
}
#kioskPairModal .kiosk-pair-actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
justify-content: stretch;
align-items: stretch;
flex-wrap: nowrap;
margin: 8px -12px -12px;
padding: 12px;
border-top: 1px solid #333;
border-radius: 0 0 14px 14px;
background: #1f1f1f;
}
#kioskPairModal .kiosk-pair-btn {
box-sizing: border-box;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #7a7a7a;
background: #3a3a3a;
color: #fff;
cursor: pointer;
font: inherit;
font-size: clamp(0.75rem, 2.8vw, 0.875rem);
line-height: 1.25;
min-height: 42px;
flex: 1 1 0;
min-width: 0;
max-width: none;
appearance: none;
-webkit-appearance: none;
}
#kioskPairModal .kiosk-pair-btn--primary {
border-color: #4ea8ff;
background: #1b66a8;
color: #fff;
}
#kioskPairModal .kiosk-pair-btn--secondary {
border-color: #5a5a5a;
background: #2a2a2a;
color: #fff;
}
#kioskPairModal .kiosk-pair-btn--ghost {
border-color: #6a6a6a;
background: #333;
color: #f0f0f0;
}
#kioskPairModal .kiosk-pair-btn:focus-visible {
outline: 2px solid #4ea8ff;
outline-offset: 2px;
}
@media (max-height: 520px) {
#kioskPairModal .kiosk-pair-panel {
padding: 8px;
border-radius: 10px;
}
#kioskPairModal .kiosk-pair-qr {
width: min(88vw, 38vh, 280px);
}
#kioskPairModal .kiosk-pair-steps {
font-size: 0.7rem;
line-height: 1.25;
}
#kioskPairModal .kiosk-pair-btn {
min-height: 38px;
padding: 8px 10px;
}
#kioskPairModal .kiosk-pair-actions {
flex-wrap: wrap;
}
#kioskPairModal .kiosk-pair-btn {
flex: 1 1 calc(50% - 4px);
}
#kioskPairModal .kiosk-pair-btn--ghost {
flex: 1 1 100%;
}
}
#kioskBlockedOverlay {
box-sizing: border-box;
padding: max(16px, env(safe-area-inset-top, 0px)) 16px;
overflow-y: auto;
}
#kioskBlockedOverlay > div {
max-width: min(520px, 100%);
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-panel {
background: #ffffff !important;
color: #111827 !important;
border-color: #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #kioskPairModal h3 {
color: #111827 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-subtitle {
color: #374151 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-steps {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskPairLoading,
html[data-theme="light"] #kioskPairModal .kiosk-pair-loading {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskPairLoading span:first-child,
html[data-theme="light"] #kioskPairModal .kiosk-pair-spinner {
border-color: #d1d5db !important;
border-top-color: #2563eb !important;
}
html[data-theme="light"] #kioskPairUrl {
color: #2563eb !important;
}
html[data-theme="light"] #kioskPairStatus {
color: #6b7280 !important;
}
html[data-theme="dark"] #kioskPairStatus {
color: #d7d7d7 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-actions {
background: #ffffff !important;
border-top-color: #e5e7eb !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--primary,
html[data-theme="light"] #kioskPairCopy {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--secondary,
html[data-theme="light"] #kioskPairRefresh {
border-color: #d1d5db !important;
background: #ffffff !important;
color: #111827 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--ghost,
html[data-theme="light"] #kioskPairClose {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Блокировка до привязки --- */
html[data-theme="light"] #kioskBlockedOverlay {
background: rgba(15, 23, 42, 0.45) !important;
color: #111827 !important;
}
html[data-theme="light"] #kioskBlockedOverlay h2 {
color: #111827 !important;
}
html[data-theme="light"] #kioskBlockedOverlay p {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskBlockedPairBtn {
background: #2563eb !important;
border: 1px solid #2563eb !important;
color: #ffffff !important;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
}
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-400.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-500.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-700.ttf') format('truetype');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
/* Карточка в главных настройках + модалка контроля отклонений */
/* Поверх #settingsModal (z-index: 3000) */
#feedQualitySettingsModal.modal.wesp-shell-modal {
z-index: 3100;
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-fq {
max-width: min(760px, calc(100vw - 1rem));
}
#feedQualitySettingsModal .modal-content.fq-settings-modal__content {
max-height: min(94vh, 900px);
display: flex;
flex-direction: column;
}
#feedQualitySettingsModal .wesp-shell-bsmodal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
max-height: min(calc(94vh - 8rem), 760px);
}
#feedQualitySettingsModal .wesp-shell-bsmodal-actions {
flex-shrink: 0;
margin-top: 0;
padding-top: 12px;
border-top: 1px solid var(--border-light, #e2e8f0);
}
.settings-feed-quality-card {
background: #f8fafc;
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid #e2e8f0;
}
.settings-feed-quality-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-feed-quality-card--clickable:hover {
background: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-feed-quality-card .settings-card-title {
margin: 0;
justify-content: center;
position: relative;
}
.settings-feed-quality-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.settings-feed-quality-card .settings-card-title .settings-chevron {
position: absolute;
right: 0;
}
.fq-settings-modal .fq-settings-section {
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 10px;
padding: 0.85rem 1rem;
margin-bottom: 0.75rem;
background: var(--bg-light, #f8fafc);
}
.fq-settings-modal .fq-settings-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.65rem;
}
.fq-settings-modal .fq-settings-section__title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-dark, #1e293b);
}
.fq-settings-modal .fq-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.65rem 1rem;
}
.fq-settings-modal .fq-settings-field label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #64748b);
margin-bottom: 0.25rem;
}
.fq-settings-modal .fq-settings-checks {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-top: 0.5rem;
}
.fq-settings-modal .fq-settings-check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8125rem;
color: var(--text-dark, #334155);
margin: 0;
cursor: pointer;
}
html[data-theme="dark"] .settings-feed-quality-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .settings-feed-quality-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
html[data-theme="dark"] .fq-settings-modal .fq-settings-section {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] .fq-settings-modal .fq-settings-section__title,
html[data-theme="dark"] .fq-settings-modal .fq-settings-check {
color: #f1f5f9;
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Kiosk/operator skeleton layouts — builds on wesp-recipes-skeleton.css primitives.
*/
/* --- Grid cards (direct children of .dispenser-grid) --- */
.dispenser-card.kiosk-skeleton-card {
pointer-events: none;
cursor: default;
}
.dispenser-card.kiosk-skeleton-card:nth-child(1) { --skel-i: 0; }
.dispenser-card.kiosk-skeleton-card:nth-child(2) { --skel-i: 1; }
.dispenser-card.kiosk-skeleton-card:nth-child(3) { --skel-i: 2; }
.dispenser-card.kiosk-skeleton-card:nth-child(4) { --skel-i: 3; }
.dispenser-grid.kiosk-skeleton-host--exit {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.dashboard-skeleton-bar--kiosk-title {
width: 72%;
height: 1.75rem;
margin-bottom: 12px;
}
.kiosk-skeleton-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.dashboard-skeleton-bar--kiosk-meta {
width: 88%;
height: 0.72rem;
margin-bottom: 0;
}
.dashboard-skeleton-bar--kiosk-meta:last-child {
width: 62%;
}
/* Legacy grid wrapper (unused on recipes_selection; kept for compatibility) */
.kiosk-skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
width: 100%;
min-height: 8rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-grid--exit {
opacity: 0;
pointer-events: none;
}
/* --- Unloading groups list --- */
.kiosk-skeleton-groups {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 6rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-groups--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-group-item {
background: var(--white, #fff);
border: 1px solid var(--gray-200, #e5e7eb);
border-radius: 8px;
padding: 16px 20px;
pointer-events: none;
}
.kiosk-skeleton-group-item:nth-child(1) { --skel-i: 0; }
.kiosk-skeleton-group-item:nth-child(2) { --skel-i: 1; }
.kiosk-skeleton-group-item:nth-child(3) { --skel-i: 2; }
.kiosk-skeleton-group-item .dashboard-skeleton-bar--title {
width: 55%;
height: 1.1rem;
margin-bottom: 10px;
}
.kiosk-skeleton-group-item .dashboard-skeleton-bar--meta {
width: 40%;
height: 0.85rem;
margin-bottom: 0;
}
/* --- Duplicate / weight panel --- */
.kiosk-skeleton-panel {
transition: opacity 0.15s ease;
}
.kiosk-skeleton-panel--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-weight-hero {
width: min(280px, 70%);
height: clamp(2.5rem, 8vmin, 4rem);
margin: 0 auto 16px;
border-radius: 8px;
}
.kiosk-skeleton-recipe-header {
padding: clamp(10px, 2.5vmin, 16px) clamp(12px, 3vw, 20px);
}
.kiosk-skeleton-recipe-header .dashboard-skeleton-bar--title {
width: 65%;
height: 1.25rem;
margin-bottom: 8px;
}
.kiosk-skeleton-recipe-header .dashboard-skeleton-bar--subtitle {
width: 45%;
height: 0.85rem;
margin-bottom: 0;
}
.kiosk-skeleton-component-name {
width: min(320px, 85%);
height: 1.5rem;
margin: 0 auto 20px;
}
/* --- Ingredients table skeleton --- */
.kiosk-skeleton-table {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 12rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-table--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-table-row {
display: flex;
gap: 12px;
align-items: center;
}
.kiosk-skeleton-table-row:nth-child(1) { --skel-i: 0; }
.kiosk-skeleton-table-row:nth-child(2) { --skel-i: 1; }
.kiosk-skeleton-table-row:nth-child(3) { --skel-i: 2; }
.kiosk-skeleton-table-row:nth-child(4) { --skel-i: 3; }
.kiosk-skeleton-table-row:nth-child(5) { --skel-i: 4; }
.kiosk-skeleton-table-row:nth-child(6) { --skel-i: 5; }
.kiosk-skeleton-table-row .dashboard-skeleton-bar {
margin-bottom: 0;
height: 0.85rem;
}
.kiosk-skeleton-table-row .dashboard-skeleton-bar:first-child {
flex: 2 1 0;
min-width: 0;
}
.kiosk-skeleton-table-row .dashboard-skeleton-bar:last-child {
flex: 1 1 0;
min-width: 0;
max-width: 35%;
}
/* --- Dark theme --- */
html[data-theme="dark"] .dispenser-card.kiosk-skeleton-card {
background: #242424;
border-color: #3a3a3a;
}
html[data-theme="dark"] .kiosk-skeleton-group-item {
background: #242424;
border-color: #3a3a3a;
}
@media (prefers-reduced-motion: reduce) {
.dispenser-grid.kiosk-skeleton-host--exit,
.kiosk-skeleton-grid--exit,
.kiosk-skeleton-groups--exit,
.kiosk-skeleton-panel--exit,
.kiosk-skeleton-table--exit {
transition: none;
}
}
+239
View File
@@ -0,0 +1,239 @@
.lab-profiles-page .lab-profiles-header {
margin-bottom: 1rem;
}
.lab-profiles-page .lab-profiles-header h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header p {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header a {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-profiles-page.lab-sandbox-page .lab-profiles-header a:hover {
color: #fde047;
}
.lab-profiles-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
@media (max-width: 992px) {
.lab-profiles-layout {
grid-template-columns: 1fr;
}
}
.lab-profiles-list {
max-height: calc(100vh - 12rem);
overflow: auto;
}
.lab-profiles-list button {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 0.45rem 0.5rem;
border-radius: 8px;
font-size: 0.8125rem;
cursor: pointer;
}
.lab-profiles-list button:hover {
background: rgba(3, 105, 161, 0.08);
}
.lab-profiles-list button.is-active {
background: #e0f2fe;
font-weight: 600;
}
html[data-theme="dark"] .lab-profiles-list button.is-active {
background: rgba(3, 105, 161, 0.25);
}
.lab-profiles-list .profile-key {
display: block;
font-size: 0.7rem;
color: var(--text-muted, #64748b);
}
.lab-profiles-editor table {
width: 100%;
font-size: 0.8125rem;
}
.lab-profiles-editor th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #64748b);
white-space: nowrap;
}
.lab-profiles-editor td {
vertical-align: middle;
}
.lab-profiles-editor input[type="number"] {
min-width: 5.5rem;
max-width: 7rem;
}
.lab-profiles-editor .norm-calc-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: #dbeafe;
color: #1d4ed8;
}
.lab-profiles-editor .norm-source-badge {
display: inline-block;
font-size: 0.62rem;
padding: 0.08rem 0.32rem;
border-radius: 4px;
margin-right: 0.2rem;
white-space: nowrap;
font-weight: 600;
letter-spacing: 0.02em;
}
.lab-profiles-editor .norm-source-badge--db {
background: #f1f5f9;
color: #475569;
}
.lab-profiles-editor .norm-source-badge--racion {
background: #fef3c7;
color: #b45309;
}
.lab-profiles-editor .norm-source-badge--derived {
background: #ede9fe;
color: #6d28d9;
}
.lab-profiles-editor .norm-source-badge--fallback,
.lab-profiles-editor .norm-source-badge--stored {
background: #dcfce7;
color: #15803d;
}
.lab-profiles-editor .norm-source-badge--gfe {
background: #e0f2fe;
color: #0369a1;
}
.lab-profiles-editor .norm-source-badge--empty {
background: transparent;
color: var(--text-muted, #94a3b8);
font-weight: 400;
}
.lab-profiles-source-cell {
white-space: nowrap;
}
.lab-profiles-norms-summary strong {
font-weight: 600;
}
html[data-theme="dark"] .lab-profiles-editor .norm-calc-badge {
background: rgba(29, 78, 216, 0.25);
color: #93c5fd;
}
.lab-profiles-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.lab-profiles-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.lab-profiles-table-wrap {
max-height: calc(100vh - 18rem);
overflow: auto;
}
.lab-profiles-gfe {
font-size: 0.8125rem;
}
.lab-profiles-gfe strong {
font-weight: 600;
}
.lab-profiles-seed {
font-size: 0.8125rem;
}
.lab-profiles-seed select {
max-width: 100%;
}
.lab-heisenberg-prime .lab-profiles-list button:hover {
background: rgba(0, 200, 255, 0.1);
color: #f8fafc;
}
.lab-heisenberg-prime .lab-profiles-list button.is-active {
background: rgba(253, 224, 71, 0.15);
color: var(--lab-prime-yellow, #fde047);
box-shadow: inset 0 0 0 1px rgba(253, 224, 71, 0.3);
}
.lab-heisenberg-prime .lab-profiles-list .profile-key {
color: #94a3b8;
}
.lab-heisenberg-prime .lab-profiles-editor th {
color: var(--lab-prime-crystal, #00c8ff);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-calc-badge {
background: rgba(0, 200, 255, 0.15);
color: var(--lab-prime-crystal, #00c8ff);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--racion {
background: rgba(253, 224, 71, 0.2);
color: var(--lab-prime-yellow, #fde047);
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--derived {
background: rgba(167, 139, 250, 0.2);
color: #c4b5fd;
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--fallback,
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--stored {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--db {
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
+667
View File
@@ -0,0 +1,667 @@
.lab-sandbox-page {
position: relative;
min-height: 100vh;
background-color: transparent;
}
/* Прозрачный body — иначе --bg-page перекрывает фон Heisenberg (::before) */
body.zootech-app-body.lab-sandbox-page {
background-color: transparent !important;
}
html:has(body.lab-sandbox-page) {
background-color: transparent;
}
.lab-sandbox-page::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background: url("/static/img/lab-heisenberg-bg.jpg") center center / cover no-repeat;
background-attachment: fixed;
}
.lab-sandbox-page::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.35) 0%,
rgba(0, 0, 0, 0.08) 40%,
rgba(0, 0, 0, 0.2) 100%
);
}
.lab-sandbox-page .wesp-page-main {
background: transparent;
}
.lab-sandbox-page .lab-sandbox-header {
margin-bottom: 1.25rem;
}
.lab-sandbox-page .lab-sandbox-header h1,
.lab-sandbox-page .lab-sandbox-header h2,
.lab-sandbox-page .lab-sandbox-header p,
.lab-sandbox-page .lab-sandbox-header a {
color: #f8fafc;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}
.lab-sandbox-page .lab-sandbox-header a:hover {
color: #fde047;
}
.lab-sandbox-page .lab-sandbox-header h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
}
.lab-sandbox-page .lab-sandbox-header p {
margin: 0;
color: var(--text-muted, #64748b);
font-size: 0.9rem;
}
.lab-sandbox-flow {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr minmax(280px, 360px);
gap: 1rem;
align-items: start;
}
@media (max-width: 1200px) {
.lab-sandbox-flow {
grid-template-columns: 1fr;
}
}
.lab-sandbox-panel {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 12px;
padding: 1rem;
}
html[data-theme="dark"] .lab-sandbox-panel {
background: var(--bg-white, #1e2428);
border-color: rgba(255, 255, 255, 0.12);
}
.lab-sandbox-panel__title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #64748b);
margin-bottom: 0.75rem;
}
.lab-sandbox-field {
margin-bottom: 0.65rem;
}
.lab-sandbox-field label {
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.lab-sandbox-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.lab-sandbox-table {
width: 100%;
font-size: 0.875rem;
}
.lab-sandbox-table th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #64748b);
white-space: nowrap;
}
.lab-sandbox-table td {
vertical-align: middle;
}
.lab-sandbox-table .form-control,
.lab-sandbox-table .form-select {
min-width: 0;
}
.lab-sandbox-results table {
width: 100%;
font-size: 0.8125rem;
}
.lab-sandbox-results th,
.lab-sandbox-results td {
padding: 0.35rem 0.4rem;
border-bottom: 1px solid var(--border-light, #e2e8f0);
}
.lab-sandbox-diff--ok {
color: #15803d;
font-weight: 600;
}
.lab-sandbox-diff--bad {
color: #b91c1c;
font-weight: 600;
}
.lab-sandbox-diff--na {
color: var(--text-muted, #94a3b8);
}
.lab-sandbox-norm--empty {
color: var(--text-muted, #94a3b8);
font-style: italic;
font-size: 0.78rem;
}
.lab-sandbox-norms {
font-size: 0.78rem;
max-height: 220px;
overflow: auto;
}
.lab-sandbox-norms dt {
font-weight: 600;
margin-top: 0.35rem;
}
.lab-sandbox-norms dd {
margin: 0 0 0.25rem;
color: var(--text-muted, #64748b);
}
.lab-sandbox-empty {
color: var(--text-muted, #94a3b8);
font-size: 0.875rem;
text-align: center;
padding: 1.5rem 0.5rem;
}
.lab-sandbox-step {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
margin-bottom: 0.75rem;
}
html[data-theme="dark"] .lab-sandbox-step {
background: rgba(3, 105, 161, 0.25);
color: #7dd3fc;
}
.lab-formulate-section {
border-top: 1px solid var(--border-light, #e2e8f0);
padding-top: 1.5rem;
}
.lab-agrostar-section {
border-top: 1px solid var(--border-light, #e2e8f0);
padding-top: 1.5rem;
}
.lab-agrostar-drop {
border: 2px dashed var(--border-light, #cbd5e1);
border-radius: 0.75rem;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: rgba(248, 250, 252, 0.6);
}
html[data-theme="dark"] .lab-agrostar-drop {
background: rgba(15, 23, 42, 0.35);
}
.lab-agrostar-drop:hover,
.lab-agrostar-drop:focus-visible {
border-color: #38bdf8;
outline: none;
}
.lab-agrostar-drop--active {
border-color: #0ea5e9;
background: rgba(224, 242, 254, 0.45);
}
html[data-theme="dark"] .lab-agrostar-drop--active {
background: rgba(3, 105, 161, 0.2);
}
.lab-agrostar-drop__icon {
font-size: 2rem;
color: #0ea5e9;
margin-bottom: 0.5rem;
}
.lab-agrostar-drop__title {
font-weight: 600;
margin: 0;
}
.lab-agrostar-drop__file {
color: #0369a1;
font-weight: 500;
}
.lab-agrostar-warn {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
background: #fef3c7;
color: #92400e;
margin: 0.1rem 0.15rem 0.1rem 0;
}
html[data-theme="dark"] .lab-agrostar-warn {
background: rgba(146, 64, 14, 0.25);
color: #fcd34d;
}
.lab-agrostar-result--ok {
color: #15803d;
}
.lab-agrostar-result--err {
color: #b91c1c;
}
.lab-agrostar-sniff {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(148, 163, 184, 0.45);
}
.lab-agrostar-sniff-card {
background: rgba(248, 250, 252, 0.85);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 0.5rem;
padding: 1rem 1.1rem;
margin-bottom: 0.75rem;
}
html[data-theme="dark"] .lab-agrostar-sniff-card {
background: rgba(15, 23, 42, 0.55);
border-color: rgba(71, 85, 105, 0.6);
}
.lab-agrostar-sniff-card__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.lab-agrostar-sniff__target--warn {
color: #92400e;
font-size: 0.875rem;
}
html[data-theme="dark"] .lab-agrostar-sniff__target--warn {
color: #fcd34d;
}
.lab-agrostar-sniff-meta th {
width: 38%;
font-weight: 500;
color: #64748b;
vertical-align: top;
}
.lab-agrostar-sniff-nutrients td:first-child {
max-width: 14rem;
}
.lab-agrostar-sniff__skip {
color: var(--bs-secondary-color);
line-height: 1.45;
}
.lab-agrostar-sniff__notes ul {
padding-left: 1.1rem;
color: #92400e;
}
html[data-theme="dark"] .lab-agrostar-sniff__notes ul {
color: #fcd34d;
}
/* —— Heisenberg prime (ошибка на /lab) —— */
.lab-heisenberg-prime-overlay {
position: fixed;
inset: 0;
z-index: 6500;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
--lab-prime-eye-y: 26.5vh;
--lab-prime-eye-gap: 78px;
}
.lab-heisenberg-prime-active .lab-heisenberg-prime-overlay {
opacity: 1;
}
.lab-heisenberg-prime-active.lab-sandbox-page::before {
filter: saturate(0.45) contrast(1.2) brightness(0.62) sepia(0.15);
transform: scale(1.06);
background-position: center 28%;
transition: filter 0.4s ease, transform 0.6s ease, background-position 0.5s ease;
}
.lab-heisenberg-prime-active.lab-sandbox-page::after {
background: linear-gradient(
180deg,
rgba(40, 0, 0, 0.35) 0%,
rgba(10, 0, 0, 0.08) 38%,
rgba(50, 0, 0, 0.55) 100%
);
}
/* UI убираем — остаётся Walter и баннер prime */
.lab-heisenberg-prime-active #wespZootechNavMount,
.lab-heisenberg-prime-active .lab-sandbox-header,
.lab-heisenberg-prime-active .lab-sandbox-flow,
.lab-heisenberg-prime-active .lab-agrostar-section,
.lab-heisenberg-prime-active .lab-formulate-section {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
transform: translateY(16px) scale(0.97);
transition: opacity 0.35s ease, visibility 0.35s ease, transform 0.35s ease;
}
.lab-heisenberg-prime-active .wesp-page-main {
filter: none;
}
.lab-heisenberg-prime__vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at 50% var(--lab-prime-eye-y),
transparent 0%,
rgba(0, 0, 0, 0.12) 38%,
rgba(80, 0, 0, 0.78) 100%
);
animation: labPrimeVignette 0.9s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__glow {
position: absolute;
left: 50%;
top: var(--lab-prime-eye-y);
width: min(36vw, 280px);
height: min(28vw, 200px);
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgba(220, 38, 38, 0.42) 0%, transparent 68%);
animation: labPrimeGlow 1.1s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__eyes {
position: absolute;
inset: 0;
pointer-events: none;
}
.lab-heisenberg-prime__eye {
position: absolute;
top: var(--lab-prime-eye-y);
width: 102px;
height: 58px;
margin: 0;
border-radius: 50%;
background: #ef4444;
box-shadow:
0 0 10px 3px rgba(239, 98, 98, 0.95),
0 0 22px 8px rgba(185, 28, 28, 0.6),
0 0 40px 16px rgba(127, 29, 29, 0.35);
animation: labPrimeEyePulse 0.55s ease-in-out infinite alternate;
}
.lab-heisenberg-prime__eye--left {
left: calc(50% - var(--lab-prime-eye-gap));
transform: translate(-50%, -50%);
}
.lab-heisenberg-prime__eye--right {
left: calc(50% + var(--lab-prime-eye-gap));
transform: translate(-50%, -50%);
}
.lab-heisenberg-prime__scanlines {
position: absolute;
inset: 0;
opacity: 0.12;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 255, 255, 0.04) 2px,
rgba(255, 255, 255, 0.04) 4px
);
}
.lab-heisenberg-prime__banner {
position: absolute;
left: 50%;
bottom: clamp(1.5rem, 8vh, 4rem);
transform: translateX(-50%);
width: min(92vw, 520px);
padding: 0.85rem 1.1rem;
border-radius: 0.75rem;
text-align: center;
background: rgba(15, 0, 0, 0.82);
border: 1px solid rgba(239, 68, 68, 0.45);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
color: #fecaca;
backdrop-filter: blur(6px);
}
.lab-heisenberg-prime__title {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #fff;
text-shadow: 0 0 18px rgba(239, 68, 68, 0.8);
}
.lab-heisenberg-prime__subtitle {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #f87171;
margin-top: 0.15rem;
}
.lab-heisenberg-prime__reason {
margin-top: 0.55rem;
font-size: 0.92rem;
line-height: 1.35;
color: #fee2e2;
}
.lab-heisenberg-prime__source {
margin-top: 0.35rem;
font-size: 0.72rem;
color: #fca5a5;
opacity: 0.9;
}
@keyframes labPrimeEyePulse {
from {
opacity: 0.75;
filter: brightness(0.92);
}
to {
opacity: 1;
filter: brightness(1.15);
}
}
@keyframes labPrimeVignette {
from {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes labPrimeGlow {
from {
opacity: 0.45;
transform: translate(-50%, -50%) scale(0.96);
}
to {
opacity: 0.85;
transform: translate(-50%, -50%) scale(1.04);
}
}
@media (max-width: 768px) {
.lab-heisenberg-prime-overlay {
--lab-prime-eye-y: 24.5vh;
--lab-prime-eye-gap: 1198px;
}
}
@media (min-width: 1400px) {
.lab-heisenberg-prime-overlay {
--lab-prime-eye-y: 27vh;
--lab-prime-eye-gap: 74px;
}
}
@media (prefers-reduced-motion: reduce) {
.lab-heisenberg-prime__eye,
.lab-heisenberg-prime__vignette,
.lab-heisenberg-prime__glow {
animation: none;
}
.lab-heisenberg-prime-overlay {
transition: none;
}
}
.lab-formulate-steps {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.lab-formulate-step {
border: 1px solid var(--border-light, #e2e8f0);
background: transparent;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.65rem;
cursor: pointer;
}
.lab-formulate-step--active {
background: #e0f2fe;
border-color: #7dd3fc;
color: #0369a1;
}
.lab-formulate-step-panel--calc.d-none {
display: none !important;
}
.lab-formulate-pool-wrap {
max-height: 320px;
overflow: auto;
}
.lab-formulate-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.lab-formulate-badge--ok {
background: #dcfce7;
color: #166534;
}
.lab-formulate-badge--bad {
background: #fee2e2;
color: #991b1b;
}
.lab-formulate-row--ineligible {
opacity: 0.55;
}
.lab-formulate-opt-keys {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 220px;
overflow: auto;
font-size: 0.8rem;
}
.lab-formulate-opt-key {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.5rem;
align-items: start;
cursor: pointer;
}
.lab-formulate-opt-key small {
grid-column: 2;
}
.lab-formulate-result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
margin-bottom: 0.5rem;
}
.lab-formulate-row--target td {
background: rgba(14, 165, 233, 0.08);
font-weight: 600;
}
.lab-formulate-row--target td.lab-sandbox-diff--bad,
.lab-formulate-row--target td.lab-sandbox-diff--ok {
font-size: 0.95rem;
}
+203
View File
@@ -0,0 +1,203 @@
.wesp-logo-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: #f6f6f4;
transition: opacity 0.48s ease, visibility 0.48s;
overflow: hidden;
}
.wesp-logo-loader--exiting {
opacity: 0;
}
.wesp-logo-loader--exiting .wesp-logo-loader__canvas {
transform: scale(1.03);
opacity: 0.92;
}
.wesp-logo-loader--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-logo-loader--embedded {
position: relative;
inset: auto;
width: 100%;
height: 100%;
background: #fff;
overflow: hidden;
}
.wesp-logo-loader--embedded.wesp-logo-loader--transparent,
.wesp-logo-loader--plain.wesp-logo-loader--transparent {
background: transparent !important;
}
.wesp-logo-loader--embedded .wesp-logo-loader__canvas {
position: absolute;
inset: 0;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) {
display: flex;
flex-direction: column;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__canvas {
position: relative;
flex: 1 1 auto;
min-height: 0;
inset: auto;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__footer {
flex: 0 0 auto;
padding: 0 12px 10px;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__footer-row {
margin-bottom: 8px;
}
.wesp-logo-loader--plain {
background: #fff;
}
.wesp-logo-loader--no-chrome .wesp-logo-loader__footer {
display: none;
}
.wesp-logo-loader__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
transition: transform 0.52s ease, opacity 0.52s ease;
transform-origin: 50% 44%;
}
.wesp-logo-loader__footer {
position: relative;
z-index: 2;
padding: 0 12px 12px;
pointer-events: none;
}
.wesp-logo-loader__footer-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.wesp-logo-loader__label,
.wesp-logo-loader__percent {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(26, 30, 28, 0.72);
}
.wesp-logo-loader__percent {
font-variant-numeric: tabular-nums;
}
.wesp-logo-loader__bar {
position: relative;
height: 4px;
width: 100%;
background: rgba(26, 30, 28, 0.08);
}
.wesp-logo-loader__bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: rgb(148, 152, 158);
transition: width 0.08s linear, background 0.18s ease;
}
.wesp-logo-loader__tagline {
margin: 14px 0 0;
padding: 0 8px;
text-align: center;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 0.92rem;
line-height: 1.45;
color: rgba(26, 30, 28, 0.52);
min-height: 1.45em;
transition: opacity 0.35s ease;
}
.wesp-logo-loader__tagline:empty {
display: none;
}
.wesp-logo-loader__tagline--fade {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.wesp-logo-loader__bar-fill {
transition: none;
}
}
/* Demo page helpers */
.wesp-logo-loader-demo {
min-height: 100vh;
background: #f6f6f4;
color: rgba(26, 30, 28, 0.72);
font-family: system-ui, -apple-system, sans-serif;
}
.wesp-logo-loader-demo__panel {
position: fixed;
left: 50%;
bottom: 72px;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(26, 30, 28, 0.08);
box-shadow: 0 8px 24px rgba(26, 30, 28, 0.06);
}
.wesp-logo-loader-demo__btn {
appearance: none;
border: 1px solid rgba(26, 30, 28, 0.12);
background: rgba(72, 129, 109, 0.12);
color: #1a1e1c;
border-radius: 8px;
padding: 8px 14px;
font-size: 14px;
cursor: pointer;
}
.wesp-logo-loader-demo__btn:hover {
background: rgba(72, 129, 109, 0.22);
}
.wesp-logo-loader-demo__hint {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 14px;
text-align: center;
opacity: 0.65;
}
+521
View File
@@ -0,0 +1,521 @@
/**
* Recipe editor — form grid, section cards, CSS Grid data tables (#recipeEdit).
* Loaded after zootech shell/components; replaces Bootstrap .table in editor sections.
*/
/* —— Form 2×2 grid —— */
#recipeEdit .recipe-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem 1.25rem;
margin-bottom: 0;
align-items: start;
}
#recipeEdit .recipe-form-grid .form-group {
margin-bottom: 0;
min-width: 0;
}
#recipeEdit .recipe-form-grid .form-group label,
#recipeEdit .recipe-form-grid .zt-form-label-with-help {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
min-height: 1.375rem;
line-height: 1.375rem;
}
#recipeEdit .zt-form-label-with-help {
position: relative;
padding-right: 2rem;
}
#recipeEdit .zt-form-label-with-help .recipe-help-icon-btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
#recipeEdit .recipe-form-grid .form-control {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
#recipeEdit .recipe-form-grid + .zt-notice {
margin-top: 0.5rem;
}
#recipeEdit .recipe-section-card__help.zt-notice {
margin: 0.75rem 1.5rem 0;
}
#recipeEdit .recipe-section-card__help.zt-notice.is-visible {
margin-bottom: 0.5rem;
}
#recipeEdit .card-body > .zt-notice.is-visible {
margin-bottom: 0.75rem;
}
@media (max-width: 768px) {
#recipeEdit .recipe-plain-help.zt-notice {
display: none !important;
}
}
@media (max-width: 767.98px) {
#recipeEdit .recipe-form-grid {
grid-template-columns: 1fr;
}
}
/* —— Section cards (single border, no nested shell) —— */
#recipeEdit .recipe-section-card {
margin-bottom: 0 !important;
flex-shrink: 0;
border-radius: 12px;
border: 1px solid var(--border-subtle);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
overflow: hidden;
}
#recipeEdit .recipe-section-card > .card-header {
background: var(--surface-muted) !important;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-subtle);
/* Глобальный .card .card-header = 14px; карточка секции = 12px — без сброса виден «ступень» в углу */
border-radius: 0;
}
#recipeEdit .recipe-section-card > .card-body {
padding: 1rem 1.5rem 1.25rem;
}
#recipeEdit .recipe-section-card > .card-body.recipe-section-card__body--grid {
padding: 0;
}
#recipeEdit #recipeForm > .card-body > .recipe-section-card + .recipe-section-card {
margin-top: 0.25rem;
}
#recipeEdit .recipe-section-card__help {
padding: 0.75rem 1.5rem 0;
margin: 0;
}
#recipeEdit .recipe-section-card__body--grid .recipe-section-card__help:last-child {
padding-bottom: 0.75rem;
}
html[data-theme="dark"] #recipeEdit .recipe-section-card > .card-header {
background: var(--dm-field) !important;
border-bottom-color: var(--dm-border);
}
html[data-theme="dark"] #recipeEdit .recipe-section-card {
border-color: var(--dm-border);
box-shadow: none;
}
/* —— Data grid base —— */
#recipeEdit .zt-data-grid__scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
#recipeEdit .zt-data-grid {
display: flex;
flex-direction: column;
gap: 0.625rem;
min-width: 100%;
width: 100%;
}
#recipeEdit .zt-data-grid__body {
display: flex;
flex-direction: column;
}
#recipeEdit .zt-data-grid__head,
#recipeEdit .zt-data-grid__row {
display: grid;
grid-template-columns: var(--zt-data-grid-cols);
min-width: min(100%, 52rem);
}
#recipeEdit .zt-data-grid--ingredients {
--zt-data-grid-actions-col: 10.75rem;
--zt-data-grid-cols: minmax(0, 1.5fr) repeat(5, minmax(0, 0.72fr)) var(--zt-data-grid-actions-col);
}
#recipeEdit .zt-data-grid--unloading-groups {
--zt-data-grid-actions-col: 10.75rem;
--zt-data-grid-cols: minmax(0, 1.35fr) minmax(0, 1.15fr) minmax(0, 0.78fr) minmax(0, 0.78fr) var(--zt-data-grid-actions-col);
}
#recipeEdit .zt-data-grid__cell {
min-width: 0;
padding: 0.5rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--border-subtle);
font-variant-numeric: tabular-nums;
font-family: var(--font-numeric, inherit);
background: var(--surface, #fff);
}
#recipeEdit .zt-data-grid__cell--head {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border-subtle);
font-family: inherit;
font-variant-numeric: normal;
}
#recipeEdit .zt-data-grid__cell--num {
text-align: center;
}
#recipeEdit .zt-data-grid__cell--actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
text-align: right;
}
#recipeEdit .zt-data-grid__cell--head.zt-data-grid__cell--actions {
justify-content: center;
text-align: center;
}
#recipeEdit .zt-data-grid__body .zt-data-grid__row:last-child .zt-data-grid__cell {
border-bottom: none;
}
#recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell {
background: rgba(var(--primary-rgb), 0.05);
}
/* Итого: gap у .zt-data-grid отделяет body от foot; внутри foot — только боковые отступы */
#recipeEdit .zt-data-grid__foot {
padding: 0 0.75rem 0.75rem;
margin-top: 0;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__row--total {
background: var(--surface-muted);
border-radius: 10px;
overflow: hidden;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell {
padding: 0.625rem 0.75rem;
font-weight: 600;
font-size: 0.875rem;
background: transparent;
border: none;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--foot-label,
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--span-2 {
display: flex;
align-items: center;
text-align: left;
justify-content: flex-start;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--num {
display: flex;
align-items: center;
justify-content: center;
}
#recipeEdit .zt-total-line {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
#recipeEdit .zt-total-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
line-height: 1.2;
}
html[data-theme="dark"] #recipeEdit .zt-total-label {
color: var(--dm-muted);
}
#recipeEdit .zt-total-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 0.875rem;
}
#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--actions {
padding: 0.625rem 0.75rem;
}
#recipeEdit .zt-data-grid__cell--span-2 {
grid-column: span 2;
}
#recipeEdit .zt-data-grid .form-control,
#recipeEdit .zt-data-grid .form-select {
width: 100%;
max-width: 100%;
min-height: 2.25rem;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
#recipeEdit .zt-data-grid .text-cell {
display: inline-block;
min-height: 2.25rem;
line-height: 2.25rem;
font-size: 0.875rem;
}
#recipeEdit .zt-data-grid input[type="number"] {
-moz-appearance: textfield;
}
#recipeEdit .zt-data-grid input[type="number"]::-webkit-outer-spin-button,
#recipeEdit .zt-data-grid input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Row actions — кнопки по высоте полей строки, колонка без лишнего воздуха */
#recipeEdit .recipe-table-row-actions {
gap: 0.375rem;
justify-content: flex-end;
width: 100%;
max-width: 100%;
}
/* Список рейсов: не width:100% — иначе прозрачный слой перекрывает .recipe-drag-handle слева */
#recipesList .recipe-table-row-actions {
gap: 0.375rem;
justify-content: flex-end;
flex: 1 1 0;
min-width: 0;
width: auto;
max-width: 100%;
pointer-events: none;
}
#recipesList .recipe-table-row-actions .btn,
#recipesList .recipe-table-row-actions .btn-group {
pointer-events: auto;
}
#recipesList .list-item-leading {
position: relative;
z-index: 2;
flex-shrink: 0;
pointer-events: auto;
}
#recipeEdit .recipe-table-move-group,
#recipesList .recipe-table-move-group,
.feed-dispensers-page .dispenser-card .recipe-table-move-group,
.components-page .component-card .recipe-table-move-group {
display: inline-flex;
flex-shrink: 0;
gap: 0.25rem;
}
#recipeEdit .recipe-table-move-group > .btn + .btn,
#recipesList .recipe-table-move-group > .btn + .btn,
.feed-dispensers-page .dispenser-card .recipe-table-move-group > .btn + .btn,
.components-page .component-card .recipe-table-move-group > .btn + .btn {
margin-left: 0 !important;
}
#recipeEdit .recipe-table-move-group .btn,
#recipeEdit .recipe-table-delete-btn,
#recipesList .recipe-table-move-group .btn,
#recipesList .recipe-table-delete-btn,
.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
.components-page .component-card .recipe-table-move-group .btn,
.components-page .component-card .recipe-table-delete-btn,
#recipesList .recipe-drag-handle {
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0 !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: var(--zt-radius-sm, 8px) !important;
flex-shrink: 0;
}
#recipeEdit .recipe-table-move-group .btn i,
#recipeEdit .recipe-table-delete-btn i,
#recipesList .recipe-table-move-group .btn i,
#recipesList .recipe-table-delete-btn i,
.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn i,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn i,
.components-page .component-card .recipe-table-move-group .btn i,
.components-page .component-card .recipe-table-delete-btn i,
#recipesList .recipe-drag-handle i {
font-size: 0.875rem;
}
#recipesList .recipe-drag-handle {
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
#recipesList .recipe-drag-handle:active {
cursor: grabbing;
}
#recipesList .recipe-drag-handle:focus-visible {
outline: 2px solid var(--primary-color, #0d6efd);
outline-offset: 2px;
z-index: 2;
}
#recipesList .recipe-drag-handle i {
pointer-events: none;
}
#recipeEdit .recipe-table-move-group .btn:focus,
#recipesList .recipe-table-move-group .btn:focus {
z-index: 2;
}
#recipeEdit .recipe-table-delete-btn,
#recipesList .recipe-table-delete-btn,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
.components-page .component-card .recipe-table-delete-btn {
background: transparent !important;
border: 1px solid var(--border-light, rgba(148, 163, 184, 0.35)) !important;
color: var(--text-muted, #94a3b8) !important;
transition: var(--transition);
}
#recipeEdit .recipe-table-delete-btn:hover,
#recipeEdit .recipe-table-delete-btn:focus,
#recipesList .recipe-table-delete-btn:hover,
#recipesList .recipe-table-delete-btn:focus,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover,
.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus,
.components-page .component-card .recipe-table-delete-btn:hover,
.components-page .component-card .recipe-table-delete-btn:focus {
color: var(--danger-color, #ef4444) !important;
background: rgba(239, 68, 68, 0.08) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
}
/* Dark theme */
html[data-theme="dark"] #recipeEdit .zt-data-grid__cell {
background: var(--dm-surface);
border-bottom-color: var(--dm-border);
color: var(--dm-text);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__cell--head {
background: var(--dm-field);
color: var(--dm-muted);
border-bottom-color: var(--dm-border);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell {
background: rgba(var(--primary-rgb), 0.15);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row--total {
background: var(--dm-field);
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__cell {
background: transparent;
color: var(--dm-text);
}
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:hover,
html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:focus,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn:hover,
html[data-theme="dark"] #recipesList .recipe-table-delete-btn:focus,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover,
html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:hover,
html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:focus {
color: #f87171 !important;
background: rgba(239, 68, 68, 0.12) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control {
min-height: 2.5rem;
line-height: 1.5;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
box-shadow: none !important;
opacity: 1 !important;
border-width: 1px;
border-style: solid;
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:focus:not(:disabled):not(.mode-locked-field) {
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25) !important;
}
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control.mode-locked-field,
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:disabled,
html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control[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;
min-height: 2.5rem;
-webkit-text-fill-color: var(--dm-text) !important;
}
+888
View File
@@ -0,0 +1,888 @@
/**
* Mobile layout for /recipes — dashboard steps, card tables, fullscreen editor.
* Breakpoint matches recipes.html mobile-nav (@768px).
*/
.ingredient-mobile-summary,
.ingredient-mobile-sheet,
.unloading-group-mobile-summary,
.unloading-group-mobile-sheet {
display: none;
}
@media (max-width: 768px) {
/* --- Dashboard step navigation --- */
.recipes-mobile-nav {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.65rem 0.85rem;
background: var(--surface-card, #fff);
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 12px;
box-shadow: var(--box-shadow, 0 1px 3px rgba(15, 23, 42, 0.06));
}
.recipes-mobile-nav[hidden] {
display: none !important;
}
.recipes-mobile-nav-back {
flex-shrink: 0;
min-height: 44px;
padding: 0.4rem 0.75rem;
border-radius: 10px;
touch-action: manipulation;
}
.recipes-mobile-nav-title {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dashboard-mobile-step-dispensers #periodsCol,
body.dashboard-mobile-step-dispensers #recipesCol {
display: none !important;
}
body.dashboard-mobile-step-periods #dispensersCol,
body.dashboard-mobile-step-periods #recipesCol {
display: none !important;
}
body.dashboard-mobile-step-recipes #dispensersCol,
body.dashboard-mobile-step-recipes #periodsCol {
display: none !important;
}
body.dashboard-mobile-step-periods #periodsCol,
body.dashboard-mobile-step-recipes #recipesCol {
display: block !important;
}
body.dashboard-mobile-step-dispensers #dispensersCol {
display: block !important;
}
/* --- Recipe list cards --- */
#recipesList .recipe-actions .btn {
min-width: 44px;
min-height: 44px;
padding: 0.45rem 0.65rem;
}
#recipesList .recipe-actions-row {
flex-wrap: wrap;
}
#recipesList .recipe-drag-handle {
touch-action: none;
-webkit-user-drag: element;
}
#recipesList .list-item.recipe-card-dragging {
opacity: 0.72;
}
#recipesList.recipe-list-reorder-active {
touch-action: none;
}
body.recipe-list-reorder-tracking {
touch-action: none;
}
.recipe-reorder-edge-hints {
position: fixed;
inset: 0;
z-index: 1295;
pointer-events: none;
}
.recipe-reorder-edge-hint {
position: absolute;
left: 0;
right: 0;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.18s ease;
}
.recipe-reorder-edge-hint--top {
top: 0;
background: linear-gradient(to bottom, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0));
}
.recipe-reorder-edge-hint--bottom {
bottom: 0;
background: linear-gradient(to top, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0));
}
.recipe-reorder-edge-hint.is-active {
opacity: 1;
}
.recipe-reorder-hint--floating {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px));
z-index: 1296;
margin: 0;
}
.recipe-list-scroll-zone-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--primary-color, #2196f3);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
}
.recipe-reorder-hint,
.recipe-reorder-hint--floating {
padding: 0.55rem 0.75rem;
border-radius: 10px;
font-size: 0.78rem;
line-height: 1.4;
font-weight: 500;
color: var(--primary-color, #2196f3);
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid rgba(var(--primary-rgb), 0.22);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.recipe-reorder-hint.is-visible,
.recipe-reorder-hint--floating.is-visible {
opacity: 1;
transform: translateY(0);
}
#recipesList {
position: relative;
}
#recipesList .recipe-card-insert-indicator {
position: absolute;
left: 0.5rem;
right: 0.5rem;
height: 3px;
border-radius: 999px;
background: var(--primary-color, #2196f3);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.18);
pointer-events: none;
z-index: 8;
}
#recipesList .list-item.recipe-card-dragging {
opacity: 0.35;
border-style: dashed;
}
.recipe-card-drag-ghost--card-clone {
position: fixed;
z-index: 1300;
pointer-events: none;
opacity: 0.96;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
margin: 0 !important;
transition: none !important;
transform: none;
will-change: left, top;
}
body.recipe-mobile-transfer-armed #recipesList .recipe-drag-handle {
color: var(--primary-color, #2196f3);
border-color: rgba(var(--primary-rgb), 0.45);
background: rgba(var(--primary-rgb), 0.12);
}
body.recipe-mobile-transfer-armed #recipesCol .card-header::after {
content: "Режим переноса: выберите период или оборудование";
display: block;
width: 100%;
margin-top: 0.35rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--primary-color, #2196f3);
line-height: 1.35;
}
/* --- Transfer drag: show hidden dashboard columns as overlay --- */
body.recipe-transfer-session #recipesMobileNav {
display: none !important;
}
body.recipe-transfer-session #dispensersCol,
body.recipe-transfer-session #periodsCol {
display: block !important;
position: fixed;
left: 0;
right: 0;
z-index: 1250;
margin: 0 !important;
padding: 0 0.5rem;
max-height: 42vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: var(--surface-page, #f5f7fa);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
}
body.recipe-transfer-session #dispensersCol {
top: 0;
}
body.recipe-transfer-session #periodsCol {
top: 42vh;
max-height: 38vh;
}
body.recipe-transfer-session #recipesCol {
display: block !important;
margin-top: 80vh;
}
body.recipe-transfer-session #recipesCol .card-header {
position: sticky;
top: 0;
z-index: 2;
}
#recipesList .recipe-card-meta {
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
justify-content: flex-start !important;
}
#recipesList .recipe-card-meta > * {
flex: 0 1 auto;
}
/* --- Fullscreen recipe editor --- */
body.recipe-edit-mobile-open {
overflow: hidden;
touch-action: pan-x pan-y;
}
body.recipe-edit-mobile-open .mobile-navbar,
body.recipe-edit-mobile-open .app-footer,
body.recipe-edit-mobile-open .page-content {
display: none !important;
}
body.recipe-edit-mobile-open #recipesDashboard,
body.recipe-edit-mobile-open #recipesMobileNav {
display: none !important;
}
body.recipe-edit-mobile-open #recipeEdit,
#recipeEdit.recipe-edit--mobile-active {
display: flex !important;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: none;
height: 100%;
height: 100dvh;
z-index: 1200;
margin: 0 !important;
overflow: hidden !important;
background: var(--surface-page, #f5f7fa);
padding-bottom: 0;
touch-action: pan-x pan-y;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-panel,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-panel {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 0;
box-shadow: none;
border: none;
overflow: hidden !important;
touch-action: pan-x pan-y;
padding-bottom: 0;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm,
#recipeEdit.recipe-edit--mobile-active #recipeForm {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
touch-action: pan-y;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-header,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-header {
flex-shrink: 0;
}
body.recipe-edit-mobile-open #recipeEdit .card-body,
#recipeEdit.recipe-edit--mobile-active .card-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
/* --- Compact recipe edit header --- */
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header {
padding: 0.4rem 0.85rem;
padding-top: calc(0.4rem + env(safe-area-inset-top, 0px));
background: var(--surface-card, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-text,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-text {
display: none !important;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-inner,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-inner {
justify-content: flex-end;
gap: 0.35rem;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions {
flex: 1 1 auto;
width: 100%;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions .d-flex,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions .d-flex {
justify-content: flex-end;
gap: 0.35rem;
flex-wrap: nowrap;
}
body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-icon-btn,
#recipeEdit.recipe-edit--mobile-active .recipe-edit-header-icon-btn {
min-width: 36px;
min-height: 36px;
padding: 0.28rem 0.38rem;
}
#recipeEdit .recipe-section-toolbar > .btn-primary,
#recipeEdit .recipe-section-toolbar .recipe-section-toggle-btn {
min-height: 36px;
padding: 0.32rem 0.7rem;
font-size: 0.875rem;
touch-action: manipulation;
}
#recipeEdit .recipe-section-card > .card-header {
padding: 0.65rem 0.85rem;
}
#recipeEdit .recipe-section-card > .card-body {
padding: 0.75rem 1rem 1rem;
}
#recipeEdit .recipe-section-toolbar {
margin-bottom: 0.75rem;
gap: 0.5rem;
}
#recipeEdit .recipe-section-toolbar-title {
font-size: 0.95rem;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row {
margin-bottom: 0.65rem !important;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row .form-group,
#recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row .form-group {
margin-bottom: 0.55rem;
}
/* --- Card layout for edit data grids --- */
#recipeEdit .zt-data-grid__scroll {
overflow: visible;
}
#recipeEdit .zt-data-grid__head {
display: none;
}
#recipeEdit #ingredientsTable.ingredients-table--dm-unlocked .ingredient-detail-cell.dry-matter-per-head {
display: none;
}
/* --- Compact ingredient list (mobile) --- */
#recipeEdit #ingredientsTableBody .ingredient-row {
display: block;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
#recipeEdit #ingredientsTableBody .ingredient-row:last-child {
border-bottom: none;
}
#recipeEdit #ingredientsTableBody .ingredient-row .zt-data-grid__cell {
border-bottom: none;
padding: 0;
background: transparent;
}
#recipeEdit #ingredientsTableBody .ingredient-row .ingredient-mobile-summary {
display: block;
padding: 0;
}
#recipeEdit #ingredientsTableBody .ingredient-row .ingredient-detail-cell {
display: none !important;
}
/* --- Compact unloading groups list (mobile) --- */
#recipeEdit #unloadingGroupsTableBody .unloading-group-row {
display: block;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row:last-child {
border-bottom: none;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .zt-data-grid__cell {
border-bottom: none;
padding: 0;
background: transparent;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-mobile-summary {
display: block;
padding: 0;
}
#recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-detail-cell {
display: none !important;
}
#recipeEdit #unloadingGroupsTable .zt-data-grid__foot,
#recipeEdit #ingredientsTable .zt-data-grid__foot {
display: none !important;
}
#recipeEdit .recipe-table-row-actions {
justify-content: flex-start !important;
flex-wrap: wrap;
gap: 0.5rem !important;
}
#recipeEdit .recipe-table-move-group .btn,
#recipeEdit .recipe-table-delete-btn {
min-height: 44px;
min-width: 44px;
touch-action: manipulation;
}
.recipe-mobile-row-summary-btn,
.ingredient-mobile-summary-btn,
.unloading-group-mobile-summary-btn {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
padding: 0.75rem 0.85rem;
border: none;
background: transparent;
text-align: left;
gap: 0.55rem;
touch-action: manipulation;
cursor: pointer;
color: inherit;
}
.ingredient-mobile-name,
.unloading-group-mobile-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ingredient-mobile-trip,
.unloading-group-mobile-meta {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #64748b);
white-space: nowrap;
}
.recipe-mobile-row-summary-chevron,
.ingredient-mobile-summary-chevron {
flex-shrink: 0;
color: var(--text-secondary, #64748b);
font-size: 0.85rem;
}
/* Hide desktop totals block — values are in edit sheet or recalculated */
/* --- Mobile edit bottom sheets --- */
.recipe-mobile-edit-sheet[hidden],
.ingredient-mobile-sheet[hidden],
.unloading-group-mobile-sheet[hidden] {
display: none !important;
}
.recipe-mobile-edit-sheet:not([hidden]),
.ingredient-mobile-sheet:not([hidden]),
.unloading-group-mobile-sheet:not([hidden]) {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
body.ingredient-mobile-sheet-open,
body.unloading-group-mobile-sheet-open,
body.recipe-help-modal-open {
overflow: hidden;
}
.recipe-mobile-edit-sheet-backdrop,
.ingredient-mobile-sheet-backdrop {
position: absolute;
inset: 0;
border: none;
padding: 0;
background: rgba(15, 23, 42, 0.45);
cursor: pointer;
}
.recipe-mobile-edit-sheet-panel,
.ingredient-mobile-sheet-panel {
position: relative;
display: flex;
flex-direction: column;
max-height: min(82dvh, 620px);
min-height: 0;
background: var(--surface-card, #fff);
border-radius: 16px 16px 0 0;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(15, 23, 42, 0.15);
touch-action: pan-y;
will-change: transform;
}
.recipe-mobile-edit-sheet-grab {
flex-shrink: 0;
width: 2.75rem;
height: 0.28rem;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: var(--border-subtle, rgba(148, 163, 184, 0.55));
touch-action: none;
cursor: grab;
}
.recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-grab {
cursor: grabbing;
}
.recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-body,
.recipe-mobile-edit-sheet--dragging .ingredient-mobile-sheet-body {
overflow: hidden;
}
.recipe-mobile-edit-sheet-header,
.ingredient-mobile-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
padding: 0.85rem 1.1rem 0.75rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
flex-shrink: 0;
touch-action: none;
cursor: grab;
}
.recipe-mobile-edit-sheet-done,
.ingredient-mobile-sheet-done {
min-height: 36px;
min-width: 5.5rem;
padding-left: 0.85rem;
padding-right: 0.85rem;
font-size: 0.875rem;
flex-shrink: 0;
touch-action: manipulation;
cursor: pointer;
}
.recipe-mobile-edit-sheet-title,
.ingredient-mobile-sheet-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
padding-right: 0.35rem;
}
.recipe-mobile-edit-sheet-body,
.ingredient-mobile-sheet-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 1rem 1.1rem calc(1.1rem + env(safe-area-inset-bottom, 0px));
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.ingredient-mobile-sheet-field,
.unloading-group-mobile-sheet-field {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.ingredient-mobile-sheet-field:last-child,
.unloading-group-mobile-sheet-field:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.ingredient-mobile-sheet-label,
.unloading-group-mobile-sheet-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary, #64748b);
margin-bottom: 0.5rem;
}
.ingredient-mobile-sheet-field .form-control,
.ingredient-mobile-sheet-field .form-select,
.unloading-group-mobile-sheet-field .form-control,
.unloading-group-mobile-sheet-field .form-select {
width: 100%;
font-size: 16px;
min-height: 44px;
}
.ingredient-mobile-sheet-field.ingredient-mobile-sheet-actions,
.unloading-group-mobile-sheet-field.unloading-group-mobile-sheet-actions {
padding-top: 0.75rem;
margin-top: 0.25rem;
margin-bottom: 0;
border-top: 1px solid var(--border-color, rgba(148, 163, 184, 0.2));
border-bottom: none;
padding-bottom: 0;
}
.ingredient-mobile-sheet-actions .recipe-table-row-actions,
.unloading-group-mobile-sheet-actions .recipe-table-row-actions {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
justify-content: stretch !important;
flex-wrap: wrap;
}
.ingredient-mobile-sheet-actions .recipe-table-move-group,
.unloading-group-mobile-sheet-actions .recipe-table-move-group {
display: contents;
}
.ingredient-mobile-sheet-actions .recipe-table-move-group .btn,
.unloading-group-mobile-sheet-actions .recipe-table-move-group .btn,
.ingredient-mobile-sheet-actions .recipe-table-delete-btn,
.unloading-group-mobile-sheet-actions .recipe-table-delete-btn {
width: 100%;
min-height: 48px;
min-width: 0;
padding: 0.55rem 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.05rem;
border-radius: 0.5rem !important;
touch-action: manipulation;
}
.ingredient-mobile-sheet-actions .recipe-table-delete-btn,
.unloading-group-mobile-sheet-actions .recipe-table-delete-btn {
grid-column: 1 / -1;
}
.ingredient-mobile-sheet.ingredients-table--dm-unlocked .ingredient-mobile-sheet-field.dry-matter-per-head {
display: none;
}
#recipeEdit .recipe-help-icon-btn {
width: 2rem;
height: 2rem;
touch-action: manipulation;
}
.recipe-help-modal-body {
padding: 0 1rem 1.25rem;
}
.recipe-help-modal-body .zt-notice--static {
margin: 0;
box-shadow: none;
}
/* Footer actions: hidden on mobile — use header icons (save / print / close) */
body.recipe-edit-mobile-open #recipeEdit .recipe-form-footer-actions,
#recipeEdit.recipe-edit--mobile-active .recipe-form-footer-actions {
display: none !important;
}
/* iOS zoom prevention for top-level form fields */
#recipeEdit .form-control,
#recipeEdit .form-select {
font-size: 16px;
}
}
@media (max-width: 400px) {
#recipesList .recipe-card-meta {
flex-direction: column;
align-items: flex-start !important;
}
}
@media (max-width: 768px) {
html[data-theme="dark"] .recipes-mobile-nav {
background: var(--dm-surface, #1a2228);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipes-mobile-nav-title {
color: var(--dm-text, #e9ecef);
}
html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row,
html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit,
html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active {
background: var(--dm-bg, #12181b);
}
html[data-theme="dark"] body.recipe-transfer-session #dispensersCol,
html[data-theme="dark"] body.recipe-transfer-session #periodsCol {
background: var(--dm-bg, #12181b);
}
html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header,
html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active .recipe-edit-header {
background: var(--dm-surface, #1a2228);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipe-help-modal-panel,
html[data-theme="dark"] .recipe-mobile-edit-sheet-panel,
html[data-theme="dark"] .ingredient-mobile-sheet-panel {
background: var(--dm-surface, #1a2228);
}
html[data-theme="dark"] .recipe-mobile-edit-sheet-header,
html[data-theme="dark"] .ingredient-mobile-sheet-header {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .recipe-mobile-edit-sheet-grab {
background: rgba(255, 255, 255, 0.28);
}
html[data-theme="dark"] .ingredient-mobile-sheet-field,
html[data-theme="dark"] .unloading-group-mobile-sheet-field {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .ingredient-mobile-name,
html[data-theme="dark"] .unloading-group-mobile-name {
color: var(--dm-text, #e9ecef);
}
html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row,
html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row {
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] #recipesList .recipe-card-insert-indicator {
box-shadow: 0 0 0 3px rgba(100, 181, 246, 0.22);
}
html[data-theme="dark"] .recipe-card-drag-ghost--card-clone {
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.45);
}
html[data-theme="dark"] .recipe-list-scroll-zone-label {
background: rgba(26, 34, 40, 0.94);
color: #64b5f6;
}
html[data-theme="dark"] .recipe-reorder-hint {
background: rgba(100, 181, 246, 0.1);
border-color: rgba(100, 181, 246, 0.24);
color: #64b5f6;
}
}
@@ -0,0 +1,370 @@
/**
* Skeleton loaders for /recipes — list cards, edit panel, drag ghost.
* Uses page tokens from recipes.html :root and dark --dm-* from wesp-zootech-shell.css.
*/
@keyframes dashboard-skeleton-shimmer {
0% {
background-position: 240% 0;
}
100% {
background-position: -240% 0;
}
}
@keyframes dashboard-stat-card-unfold {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* --- List skeleton container --- */
.dashboard-list-skeleton {
display: flex;
flex-direction: column;
gap: 0;
padding: 2px 0;
min-height: 11.5rem;
transition: opacity 150ms ease;
}
.dashboard-list-skeleton--exit {
opacity: 0;
pointer-events: none;
}
/* Card row — matches .list-item */
.dashboard-skeleton-row {
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1.25rem;
background: var(--surface-card, #fff);
border: 1px solid var(--border-strong, #cbd5e1);
pointer-events: none;
}
.dashboard-skeleton-row:last-child {
margin-bottom: 0;
}
.dashboard-skeleton-row:nth-child(1) { --skel-i: 0; }
.dashboard-skeleton-row:nth-child(2) { --skel-i: 1; }
.dashboard-skeleton-row:nth-child(3) { --skel-i: 2; }
.dashboard-skeleton-row:nth-child(4) { --skel-i: 3; }
.dashboard-skeleton-row--recipe {
padding: 1.25rem;
}
.dashboard-skeleton-row--field {
background: transparent;
border: none;
padding: 0;
margin-bottom: 0;
}
/* --- Bars & pills --- */
.dashboard-skeleton-bar,
.dashboard-skeleton-pill {
border-radius: 5px;
background: linear-gradient(
105deg,
var(--border-subtle, #e2e8f0) 0%,
var(--border-subtle, #e2e8f0) 34%,
rgba(255, 255, 255, 0.92) 50%,
var(--border-subtle, #e2e8f0) 66%,
var(--border-subtle, #e2e8f0) 100%
);
background-size: 240% 100%;
animation: dashboard-skeleton-shimmer 1.2s ease-in-out infinite;
animation-delay: calc(var(--skel-i, 0) * 0.08s);
}
.dashboard-skeleton-bar {
height: 0.72rem;
margin-bottom: 0.65rem;
width: 100%;
}
.dashboard-skeleton-bar:last-child {
margin-bottom: 0;
}
.dashboard-skeleton-bar--narrow {
width: 58%;
height: 0.62rem;
}
.dashboard-skeleton-bar--head-title {
flex: 1 1 auto;
min-width: 0;
max-width: 72%;
margin-bottom: 0;
}
.dashboard-skeleton-bar--meta-half {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
}
.dashboard-skeleton-bar--actions-left {
width: 32%;
height: 0.62rem;
margin-bottom: 0;
}
.dashboard-skeleton-bar--actions-right {
width: 38%;
margin-bottom: 0;
margin-left: auto;
max-width: 40%;
}
.dashboard-skeleton-bar--title {
width: 92%;
margin-bottom: 0;
}
.dashboard-skeleton-bar--subtitle {
width: 70%;
margin-top: 0.65rem;
}
.dashboard-skeleton-bar--meta {
height: 0.58rem;
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
}
.dashboard-skeleton-bar--meta.dashboard-skeleton-bar--narrow {
flex: 0 0 30%;
width: auto;
}
.dashboard-skeleton-bar--label {
width: 40%;
height: 0.62rem;
margin-bottom: 0.5rem;
}
.dashboard-skeleton-bar--input {
height: 2.5rem;
margin-bottom: 0;
border-radius: 6px;
}
.dashboard-skeleton-bar--hero-title {
width: min(420px, 72%);
height: 1rem;
}
.dashboard-skeleton-bar--hero-sub {
width: 36%;
margin-top: 0.65rem;
}
.dashboard-skeleton-pill {
flex: 0 0 auto;
width: 4.75rem;
height: 1.25rem;
border-radius: 999px;
margin-bottom: 0;
}
.dashboard-skeleton-pill--wide {
width: 5.5rem;
}
/* --- Layout rows inside skeleton cards --- */
.dashboard-skeleton-row-head,
.dashboard-skeleton-row-foot {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
justify-content: space-between;
}
.dashboard-skeleton-row-head {
margin-bottom: 0.75rem;
}
.dashboard-skeleton-row-foot--end {
justify-content: flex-end;
}
.dashboard-skeleton-flex-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.dashboard-skeleton-flex-row--actions {
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--actions-end {
justify-content: flex-end;
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--actions-end .dashboard-skeleton-pill {
width: 2.25rem;
height: 2.25rem;
}
.dashboard-skeleton-card-inner--mill > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
.dashboard-skeleton-flex-row--meta .dashboard-skeleton-bar {
flex: 1 1 0;
min-width: 0;
}
.dashboard-skeleton-card-inner {
display: flex;
flex-direction: column;
gap: 0;
}
.dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
/* --- Edit panel skeleton --- */
.recipe-edit-skeleton-panel {
display: none;
}
.recipe-edit-panel--loading .recipe-edit-skeleton-panel {
display: flex;
align-items: center;
justify-content: center;
}
.recipe-edit-panel--loading #recipeForm {
display: none !important;
}
.recipe-edit-skeleton-hero {
margin-bottom: 1.35rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.recipe-edit-skeleton-field {
margin-bottom: 0;
}
.recipe-edit-skeleton-table {
display: flex;
flex-direction: column;
gap: 0.65rem;
min-height: 10rem;
padding: 0.25rem 0;
}
.recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter {
animation: dashboard-stat-card-unfold 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* --- Drag ghost --- */
.recipe-card-drag-ghost {
box-sizing: border-box;
padding: 1.25rem;
margin: 0;
border-radius: 8px;
border: 1px solid var(--border-strong, #cbd5e1);
background: var(--surface-card, #fff);
box-shadow: var(--box-shadow-hover, 0 10px 28px rgba(15, 23, 42, 0.14));
}
.recipe-card-drag-ghost--follow {
position: fixed;
z-index: 1300;
pointer-events: none;
transform: translate(-50%, -50%);
opacity: 0.96;
will-change: transform, left, top;
}
.recipe-card-drag-ghost .dashboard-skeleton-bar {
margin-bottom: 0;
}
.recipe-card-drag-ghost .dashboard-skeleton-flex-row--actions {
margin-bottom: 0.65rem;
}
.recipe-card-drag-ghost .dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle {
margin-bottom: 0.65rem;
}
/* --- Dark theme --- */
html[data-theme="dark"] .dashboard-skeleton-row {
background: var(--dm-surface, #2c3338);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .dashboard-skeleton-bar,
html[data-theme="dark"] .dashboard-skeleton-pill {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0.06) 0%,
rgba(255, 255, 255, 0.06) 34%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.06) 66%,
rgba(255, 255, 255, 0.06) 100%
);
background-size: 240% 100%;
}
html[data-theme="dark"] .recipe-card-drag-ghost {
background: var(--dm-surface) !important;
border-color: var(--dm-border) !important;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-panel {
background: var(--dm-surface) !important;
}
html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-hero {
border-bottom-color: var(--dm-border) !important;
}
/* --- Reduced motion --- */
@media (prefers-reduced-motion: reduce) {
.dashboard-skeleton-bar,
.dashboard-skeleton-pill {
animation: none !important;
background: var(--border-subtle, #e2e8f0);
}
html[data-theme="dark"] .dashboard-skeleton-bar,
html[data-theme="dark"] .dashboard-skeleton-pill {
background: rgba(255, 255, 255, 0.12);
}
.dashboard-list-skeleton--exit {
transition: none;
}
.recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter {
animation: none !important;
}
}
+986
View File
@@ -0,0 +1,986 @@
/**
* /reports — таблицы, фильтры, отступы (карточки — inline в reports.html как на /components).
*/
/* Карточки отчётов не интерактивные — без hover-подсветки (кроме контроля отклонений) */
.zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover {
background: var(--bg-white, #fff) !important;
border-color: var(--border-light) !important;
box-shadow: none !important;
transform: none !important;
}
html[data-theme="dark"] .zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover {
background: var(--bg-white, #2c3338) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.reports-page .reports-container {
max-width: none;
margin: 0;
padding: 0;
}
.reports-page .period-selectors {
margin-bottom: 0.75rem;
}
/* Шапка и вкладки */
.reports-page .reports-controls__head {
margin-bottom: var(--zt-space-3, 1rem);
}
.reports-page .reports-controls__head .form-title {
margin-bottom: 0;
}
.reports-page .reports-controls__block + .reports-controls__block {
margin-top: var(--zt-space-3, 1rem);
padding-top: var(--zt-space-3, 1rem);
border-top: 1px solid var(--border-light);
}
.reports-page .reports-controls__block--tabs {
padding-top: 0;
border-top: none;
margin-top: 0;
}
.reports-page .reports-controls__block--filters {
margin-bottom: 0;
overflow: visible;
}
.reports-page .reports-controls .zt-tabs {
margin-bottom: 0;
}
.reports-page .reports-view-tabs-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--zt-space-2, 0.5rem) var(--zt-space-3, 0.75rem);
}
.reports-page .reports-view-tabs-row__sep {
width: 1px;
height: 1.25rem;
background: var(--border-light);
flex-shrink: 0;
}
.reports-page .reports-journal-tabs-wrap {
display: flex;
align-items: center;
gap: var(--zt-space-2, 0.5rem);
}
/* Компактная строка фильтров — единая сетка */
.reports-page .reports-filter-bar {
display: grid;
grid-template-columns: minmax(11rem, 1.15fr) minmax(8.5rem, 0.95fr) minmax(7.5rem, 0.85fr) auto;
gap: var(--zt-space-2, 0.5rem);
align-items: center;
}
.reports-page .reports-filter-bar--with-farm {
grid-template-columns:
minmax(10rem, 1fr)
minmax(7.5rem, 0.85fr)
minmax(7rem, 0.8fr)
minmax(6.5rem, 0.75fr)
auto;
}
.reports-page .reports-filter-bar__control,
.reports-page .reports-filter-bar__select,
.reports-page .reports-drp__trigger,
.reports-page .reports-filter-bar__apply {
width: 100%;
min-height: var(--zt-btn-height, 40px);
height: var(--zt-btn-height, 40px);
font-size: var(--zt-font-body, 0.875rem);
}
.reports-page .reports-filter-bar__apply {
width: auto;
min-width: 7.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
white-space: nowrap;
padding-inline: 1rem;
font-weight: 600;
}
.reports-page .reports-filter-select {
position: relative;
min-width: 0;
}
.reports-page .reports-filter-select__native {
position: absolute !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
padding: 0;
margin: -1px;
}
.reports-page .reports-filter-dropdown__trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
width: 100%;
padding: 0 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
cursor: pointer;
text-align: left;
box-shadow: none;
appearance: none;
font-size: var(--zt-font-body, 0.875rem);
}
.reports-page .reports-filter-dropdown__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-filter-dropdown__chev {
flex-shrink: 0;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
}
.reports-page .reports-filter-dropdown__trigger:hover {
border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35);
}
.reports-page .reports-filter-dropdown__trigger:focus,
.reports-page .reports-filter-dropdown__trigger[aria-expanded="true"] {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
outline: none;
}
.reports-page .reports-filter-dropdown__menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 0.35rem);
z-index: 1200;
max-height: 16rem;
overflow-y: auto;
padding: 0.35rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-filter-dropdown__item {
display: block;
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 0.45rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
font-size: var(--zt-font-body, 0.875rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-filter-dropdown__item:hover,
.reports-page .reports-filter-dropdown__item--active {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
color: var(--primary-color);
}
.reports-page .reports-filter-dropdown__check {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.45rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
font-size: var(--zt-font-body, 0.875rem);
user-select: none;
}
.reports-page .reports-filter-dropdown__check:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.06);
}
.reports-page .reports-filter-dropdown__check-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__trigger,
html[data-theme="dark"] .reports-page .reports-filter-dropdown__menu {
background: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__item {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-filter-dropdown__check {
color: #f5f5f5;
}
.reports-page .reports-filter-select .form-select {
appearance: none;
-webkit-appearance: none;
padding-right: 2rem;
background-color: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
box-shadow: none;
}
.reports-page .reports-filter-select .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
}
.reports-page .reports-filter-select__chev {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
}
html[data-theme="dark"] .reports-page .reports-filter-select .form-select {
background-color: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
.reports-page .reports-export-dropdown {
position: relative;
}
.reports-page .reports-export-dropdown__btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.reports-page .reports-export-dropdown__chev {
font-size: 0.6rem;
opacity: 0.7;
}
.reports-page .reports-export-dropdown__menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
z-index: 1200;
min-width: 16.5rem;
max-width: 19rem;
padding: 0.35rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-export-dropdown__group + .reports-export-dropdown__group {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px solid var(--border-light);
}
.reports-page .reports-export-dropdown__group-label {
padding: 0.2rem 0.65rem 0.35rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted, var(--text-muted));
}
.reports-page .reports-export-dropdown__item {
display: flex;
align-items: flex-start;
gap: 0.55rem;
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 0.55rem 0.65rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
color: var(--text, var(--text-dark));
}
.reports-page .reports-export-dropdown__icon {
flex-shrink: 0;
width: 1.125rem;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.05rem;
font-size: 1rem;
line-height: 1;
}
.reports-page .reports-export-dropdown__text {
flex: 1;
min-width: 0;
}
.reports-page .reports-export-dropdown__item:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
}
.reports-page .reports-export-dropdown__item strong {
display: block;
font-size: 0.8125rem;
}
.reports-page .reports-export-dropdown__item small {
display: block;
color: var(--muted, var(--text-muted));
font-size: 0.6875rem;
margin-top: 0.1rem;
}
html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
background: var(--dm-field, #2c3338);
border-color: rgba(255, 255, 255, 0.12);
}
.reports-page .reports-drp {
position: relative;
min-width: 0;
}
.reports-page .reports-drp__trigger {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
background: var(--surface, var(--bg-white, #fff));
color: var(--text, var(--text-dark));
cursor: pointer;
text-align: left;
box-shadow: none;
appearance: none;
}
.reports-page .reports-drp__trigger:hover {
border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35);
}
.reports-page .reports-drp__trigger:focus,
.reports-page .reports-drp__trigger[aria-expanded="true"] {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15);
outline: none;
}
.reports-page .reports-drp__trigger .fa-calendar-alt {
color: var(--primary-color);
flex-shrink: 0;
font-size: 0.875rem;
}
.reports-page .reports-drp__trigger #reportsDateRangeLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-page .reports-drp__chev {
margin-left: auto;
font-size: 0.65rem;
color: var(--muted, var(--text-muted));
flex-shrink: 0;
}
.reports-page .reports-drp__panel {
position: fixed;
z-index: 2500;
width: min(19rem, calc(100vw - 1.5rem));
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg, 12px);
background: var(--surface, var(--bg-white, #fff));
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
.reports-page .reports-drp__presets {
display: flex;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.reports-page .reports-drp__preset {
flex: 1;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-sm, 8px);
background: transparent;
color: var(--text, var(--text-dark));
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.35rem;
min-height: 2rem;
cursor: pointer;
transition: background var(--transition, 0.15s), border-color var(--transition, 0.15s);
}
.reports-page .reports-drp__preset.active,
.reports-page .reports-drp__preset:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
}
.reports-page .reports-drp__cal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.35rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text, var(--text-dark));
}
.reports-page .reports-drp__nav {
border: none;
background: transparent;
color: var(--muted, var(--text-muted));
width: 1.75rem;
height: 1.75rem;
border-radius: var(--zt-radius-sm, 8px);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.reports-page .reports-drp__nav:hover {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.08);
color: var(--primary-color);
}
.reports-page .reports-drp__weekdays,
.reports-page .reports-drp__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.15rem;
}
.reports-page .reports-drp__weekdays {
margin-bottom: 0.25rem;
font-size: 0.625rem;
font-weight: 600;
color: var(--muted, var(--text-muted));
text-align: center;
}
.reports-page .reports-drp__day {
border: none;
background: transparent;
border-radius: var(--zt-radius-sm, 8px);
font-size: 0.75rem;
line-height: 1.75rem;
height: 1.75rem;
padding: 0;
cursor: pointer;
color: var(--text, var(--text-dark));
}
.reports-page .reports-drp__day--empty {
pointer-events: none;
}
.reports-page .reports-drp__day--today {
font-weight: 700;
color: var(--primary-color);
}
.reports-page .reports-drp__day--in-range {
background: rgba(var(--primary-rgb, 13, 110, 253), 0.12);
}
.reports-page .reports-drp__day--edge {
background: var(--primary-color);
color: #fff;
}
.reports-page .reports-drp__day--edge.reports-drp__day--today {
color: #fff;
}
html[data-theme="dark"] .reports-page .reports-drp__trigger,
html[data-theme="dark"] .reports-page .reports-drp__panel {
background: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__preset {
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="dark"] .reports-page .reports-drp__day {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__cal-head {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .reports-drp__weekdays {
color: #a8b0b8;
}
@media (max-width: 768px) {
.reports-page .reports-filter-bar,
.reports-page .reports-filter-bar--with-farm {
grid-template-columns: 1fr 1fr;
}
.reports-page .reports-drp {
grid-column: 1 / -1;
}
.reports-page .reports-filter-bar__apply {
grid-column: 1 / -1;
width: 100%;
}
}
@media (max-width: 640px) {
.reports-page .reports-filter-bar__apply-text {
display: none;
}
}
.reports-page .calendar-inputs {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.reports-page .calendar-group {
flex: 1;
min-width: min(100%, 200px);
}
.reports-page .filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
align-items: end;
}
.reports-page .filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.reports-page .report-section {
margin-top: 0.75rem;
}
.reports-page .report-section__title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
gap: 0.5rem;
}
.reports-page .report-section__title i {
color: var(--primary-color);
}
.reports-page .report-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-sm, 8px);
}
.reports-page .components-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
background: transparent;
margin: 0;
}
.reports-page .components-table th,
.reports-page .components-table td {
padding: 0.5rem 0.65rem;
text-align: right;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
}
.reports-page .components-table th:first-child,
.reports-page .components-table td:first-child {
text-align: left;
}
.reports-page .components-table th {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.06);
font-weight: 600;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.reports-page .components-table th:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.1);
}
.reports-page .components-table tbody tr:last-child td {
border-bottom: none;
}
.reports-page .components-table tbody tr:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.04);
}
.reports-page .deviation-negative {
color: #ef4444;
font-weight: 500;
}
.reports-page .deviation-positive {
color: #10b981;
font-weight: 500;
}
.reports-page .deviation-significant {
color: #ef4444;
font-weight: 600;
}
.reports-page .loading-duration {
font-variant-numeric: tabular-nums;
color: var(--primary-color);
}
.reports-page .empty-state {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
}
.reports-page .empty-state .empty-icon {
font-size: 3rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.reports-page .reports-controls .btn-outline-success {
--bs-btn-color: #059669;
--bs-btn-border-color: rgba(5, 150, 105, 0.45);
--bs-btn-hover-bg: rgba(5, 150, 105, 0.08);
--bs-btn-hover-border-color: #059669;
}
html[data-theme="dark"] .reports-page .report-section__title {
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .components-table th {
background: rgba(255, 255, 255, 0.06);
}
html[data-theme="dark"] .reports-page .components-table th,
html[data-theme="dark"] .reports-page .components-table td {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .reports-page .report-table-wrap {
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="dark"] .reports-page .reports-controls .btn-outline-success {
--bs-btn-color: #6ee7b7;
--bs-btn-border-color: rgba(110, 231, 183, 0.35);
}
@media (max-width: 768px) {
.reports-page .report-card__top {
flex-direction: column;
align-items: flex-start;
}
.reports-page .component-info {
grid-template-columns: 1fr;
}
.reports-page .calendar-inputs {
flex-direction: column;
align-items: stretch;
}
.reports-page .filter-buttons {
width: 100%;
}
.reports-page .filter-buttons .btn {
flex: 1;
}
}
.reports-page .reports-controls__head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.reports-page .feed-alerts-summary-chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--warning-color, #d97706);
background: rgba(217, 119, 6, 0.12);
border-radius: 999px;
padding: 0.25rem 0.65rem;
}
.reports-page .reports-view-tabs,
.reports-page .alerts-severity-tabs {
margin-bottom: 0;
}
/* Лента отклонений — карточки как на /components */
.reports-page .deviation-feed {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reports-page .deviation-alert-card.list-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
width: 100%;
text-align: left;
appearance: none;
-webkit-appearance: none;
font: inherit;
color: inherit;
margin: 0;
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.25rem;
cursor: pointer;
box-shadow: var(--shadow-soft);
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.reports-page .deviation-alert-card.list-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-soft), 0 0 0 1px rgba(var(--primary-rgb, 33, 150, 243), 0.2);
background-color: rgba(var(--primary-rgb), 0.04);
}
.reports-page .deviation-alert-card__top {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.reports-page .deviation-alert-card__head {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.reports-page .deviation-alert-card__title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--text-dark);
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.reports-page .deviation-alert-card__title i {
color: var(--primary-color);
font-size: 0.8rem;
flex-shrink: 0;
}
.reports-page .deviation-type-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.reports-page .deviation-type-badge--warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.reports-page .deviation-type-badge--error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.reports-page .deviation-alert-card__info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.35rem 1rem;
}
.reports-page .deviation-alert-card__context {
font-size: 0.875rem;
color: var(--text-dark);
font-weight: 600;
line-height: 1.35;
}
.reports-page .deviation-alert-card__meta {
font-size: 0.8125rem;
color: var(--text-muted);
}
.reports-page .deviation-feed-item__dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-top: 0.45rem;
}
.reports-page .deviation-feed-item__dot--warning {
background: var(--warning-color, #f59e0b);
}
.reports-page .deviation-feed-item__dot--error {
background: var(--danger-color, #ef4444);
}
.reports-page .deviation-feed-item__dot--info {
background: var(--info-color, #0ea5e9);
}
.reports-page .deviation-feed-item__delta {
flex-shrink: 0;
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.15rem;
}
.reports-page .deviation-feed-item__chevron {
align-self: flex-end;
color: var(--text-muted);
opacity: 0.45;
font-size: 0.7rem;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.reports-page .deviation-alert-card:hover .deviation-feed-item__chevron {
opacity: 0.85;
transform: translateX(2px);
}
html[data-theme="dark"] .reports-page .deviation-alert-card.list-item {
background: var(--bg-white) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
box-shadow: none !important;
color: #f5f5f5;
}
html[data-theme="dark"] .reports-page .deviation-alert-card.list-item:hover {
background-color: rgba(var(--primary-rgb), 0.1) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="dark"] .reports-page .deviation-alert-card__title,
html[data-theme="dark"] .reports-page .deviation-alert-card__context {
color: #f5f5f5 !important;
}
html[data-theme="dark"] .reports-page .deviation-alert-card__meta {
color: #94a3b8;
}
html[data-theme="dark"] .reports-page .deviation-type-badge--warning {
background: rgba(251, 191, 36, 0.22);
color: #fcd34d;
}
html[data-theme="dark"] .reports-page .deviation-type-badge--error {
background: rgba(248, 113, 113, 0.22);
color: #fca5a5;
}
html[data-theme="dark"] .reports-page .deviation-feed-item__delta {
color: #f1f5f9;
}
html[data-theme="dark"] .reports-page .deviation-negative,
html[data-theme="dark"] .reports-page .deviation-significant {
color: #f87171;
}
html[data-theme="dark"] .reports-page .deviation-positive {
color: #34d399;
}
html[data-theme="dark"] .reports-page .feed-alerts-summary-chip {
color: #fbbf24;
background: rgba(251, 191, 36, 0.16);
}
+737
View File
@@ -0,0 +1,737 @@
:root {
--setup-page-bg: #fff;
--setup-overlay-bg: #fff;
--setup-surface: #fff;
--setup-surface-muted: rgba(26, 30, 28, 0.04);
--setup-border: rgba(26, 30, 28, 0.08);
--setup-border-strong: rgba(26, 30, 28, 0.14);
--setup-text: #1a1e1c;
--setup-text-muted: rgba(26, 30, 28, 0.72);
--setup-text-soft: rgba(26, 30, 28, 0.55);
--setup-text-faint: rgba(26, 30, 28, 0.5);
--setup-accent: #2c7be5;
--setup-accent-soft: rgba(44, 123, 229, 0.08);
--setup-accent-text: #1a5bb8;
--setup-shadow: 0 16px 48px rgba(26, 30, 28, 0.08);
--setup-busy-bg: rgba(255, 255, 255, 0.82);
--setup-info-bg: rgba(44, 123, 229, 0.08);
--setup-info-text: rgba(26, 30, 28, 0.78);
--setup-warn-bg: #fff7e6;
--setup-warn-border: #ffd591;
--setup-warn-text: #874d00;
--setup-error: #b42318;
--setup-ok: #067647;
--setup-loader-bg: #fff;
}
html[data-theme="dark"] {
--setup-page-bg: #0f1114;
--setup-overlay-bg: #0f1114;
--setup-surface: #181b20;
--setup-surface-muted: rgba(255, 255, 255, 0.05);
--setup-border: rgba(255, 255, 255, 0.1);
--setup-border-strong: rgba(255, 255, 255, 0.16);
--setup-text: #eef0f3;
--setup-text-muted: rgba(238, 240, 243, 0.78);
--setup-text-soft: rgba(238, 240, 243, 0.58);
--setup-text-faint: rgba(238, 240, 243, 0.45);
--setup-accent: #4d93ef;
--setup-accent-soft: rgba(77, 147, 239, 0.14);
--setup-accent-text: #8eb8ff;
--setup-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
--setup-busy-bg: rgba(24, 27, 32, 0.88);
--setup-info-bg: rgba(77, 147, 239, 0.12);
--setup-info-text: rgba(238, 240, 243, 0.86);
--setup-warn-bg: rgba(255, 169, 64, 0.12);
--setup-warn-border: rgba(255, 169, 64, 0.35);
--setup-warn-text: #ffd591;
--setup-error: #ff8a80;
--setup-ok: #6ee7a8;
--setup-loader-bg: #181b20;
}
html,
body {
margin: 0;
background: var(--setup-page-bg);
color: var(--setup-text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wesp-setup {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--setup-overlay-bg);
}
.wesp-setup__intro {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
height: min(240px, 36vh);
background: transparent;
transition: opacity 0.45s ease, visibility 0.45s;
}
.wesp-setup__intro--done {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-setup__intro .wesp-logo-loader,
.wesp-setup__intro .wesp-logo-loader--embedded,
.wesp-setup__intro .wesp-logo-loader--plain {
background: transparent !important;
}
.wesp-setup__panel {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10002;
display: flex;
flex-direction: column;
width: min(520px, calc(100vw - 32px));
max-height: min(85vh, 620px);
overflow: hidden;
padding: 0;
border-radius: 16px;
background: var(--setup-surface);
border: 1px solid var(--setup-border);
box-shadow: var(--setup-shadow);
color: var(--setup-text);
opacity: 1;
visibility: visible;
transition: opacity 0.35s ease 0.08s, visibility 0.35s ease 0.08s, background 0.2s ease, color 0.2s ease;
isolation: isolate;
}
.wesp-setup__header {
flex: 0 0 auto;
padding: 22px 24px 0;
}
.wesp-setup__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 24px 8px;
overscroll-behavior: contain;
}
.wesp-setup__footer {
flex: 0 0 auto;
padding: 12px 24px calc(16px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--setup-border);
background: var(--setup-surface);
}
.wesp-setup__panel--busy {
pointer-events: none;
}
.wesp-setup__busy {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 16px;
background: var(--setup-busy-bg);
backdrop-filter: blur(2px);
}
.wesp-setup__spinner {
width: 28px;
height: 28px;
border: 2px solid var(--setup-border-strong);
border-top-color: var(--setup-accent);
border-radius: 50%;
animation: wesp-setup-spin 0.75s linear infinite;
}
.wesp-setup__busy-label {
font-size: 13px;
color: var(--setup-text-muted);
}
@keyframes wesp-setup-spin {
to {
transform: rotate(360deg);
}
}
.wesp-setup__panel--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-setup__title {
margin: 0 0 6px;
font-size: 21px;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.25;
color: var(--setup-text);
}
.wesp-setup__lead {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--setup-text-muted);
}
.wesp-setup__step-indicator {
margin-bottom: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--setup-text-faint);
}
.wesp-setup__field {
margin-bottom: 12px;
}
.wesp-setup__label {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: 500;
color: var(--setup-text);
}
.wesp-setup__input,
.wesp-setup__select {
width: 100%;
box-sizing: border-box;
padding: 11px 12px;
border: 1px solid var(--setup-border-strong);
border-radius: 10px;
font-size: 16px;
background: var(--setup-surface);
color: var(--setup-text);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.wesp-setup__input:focus,
.wesp-setup__select:focus {
outline: none;
border-color: var(--setup-accent);
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__input:-webkit-autofill,
.wesp-setup__input:-webkit-autofill:hover,
.wesp-setup__input:-webkit-autofill:focus,
.wesp-setup__input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
-webkit-text-fill-color: var(--setup-text) !important;
caret-color: var(--setup-text);
transition: background-color 600000s ease-in-out 0s;
}
.wesp-setup__input:autofill {
box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
-webkit-text-fill-color: var(--setup-text) !important;
caret-color: var(--setup-text);
}
.wesp-setup__input--readonly {
background: var(--setup-surface-muted);
color: var(--setup-text-muted);
cursor: default;
}
.wesp-setup__input-group {
display: flex;
align-items: stretch;
width: 100%;
}
.wesp-setup__input-group .wesp-setup__input {
flex: 1 1 auto;
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
.wesp-setup__input-suffix {
display: flex;
align-items: center;
padding: 0 12px;
border: 1px solid var(--setup-border-strong);
border-left: none;
border-radius: 0 8px 8px 0;
background: var(--setup-surface-muted);
color: var(--setup-text-soft);
font-size: 14px;
white-space: nowrap;
user-select: none;
}
.wesp-setup__checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.wesp-setup__role-grid {
display: grid;
gap: 10px;
margin-bottom: 4px;
}
.wesp-setup__role-grid--cards {
gap: 12px;
}
.wesp-setup__role-btn {
text-align: left;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--setup-border-strong);
background: var(--setup-surface);
color: var(--setup-text);
cursor: pointer;
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.wesp-setup__role-btn:hover {
border-color: var(--setup-accent);
}
.wesp-setup__role-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__role-card {
display: flex;
align-items: center;
justify-content: center;
min-height: 84px;
padding: 20px 18px;
border-radius: 12px;
text-align: center;
}
.wesp-setup__theme-grid .wesp-setup__theme-card {
position: relative;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0;
min-height: 0;
padding: 12px 14px 12px 62px;
text-align: left;
background: var(--setup-surface-muted);
border-color: var(--setup-border);
color: var(--setup-text);
transition: border-color 0.15s ease, background 0.15s ease;
}
.wesp-setup__theme-grid .wesp-setup__theme-card::before {
content: "";
position: absolute;
left: 14px;
top: 50%;
width: 32px;
height: 32px;
margin-top: -16px;
border-radius: 8px;
border: 1px solid var(--setup-border-strong);
box-sizing: border-box;
}
.wesp-setup__theme-card--light::before {
background: linear-gradient(145deg, #ffffff 0%, #d8dee8 100%);
}
.wesp-setup__theme-card--dark::before {
background: linear-gradient(145deg, #505662 0%, #171b21 100%);
}
.wesp-setup__theme-card span {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.45;
font-weight: 400;
color: var(--setup-text-soft);
}
.wesp-setup__role-btn strong {
display: block;
margin-bottom: 4px;
}
.wesp-setup__role-card strong {
margin: 0;
font-size: 17px;
font-weight: 600;
line-height: 1.25;
color: var(--setup-text);
}
.wesp-setup__theme-card strong {
margin-bottom: 0;
font-size: 15px;
}
.wesp-setup__role-btn.is-selected:not(.wesp-setup__theme-card) {
border-color: var(--setup-accent);
background: var(--setup-accent-soft);
box-shadow: 0 0 0 1px rgba(44, 123, 229, 0.18);
}
.wesp-setup__theme-card.is-selected {
border-color: var(--setup-accent);
background: var(--setup-accent-soft);
box-shadow: none;
}
.wesp-setup__role-card.is-selected:not(.wesp-setup__theme-card) strong {
color: var(--setup-accent-text);
}
.wesp-setup__theme-grid .wesp-setup__theme-card strong {
color: var(--setup-text);
}
.wesp-setup__theme-grid .wesp-setup__theme-card.is-selected strong {
color: var(--setup-accent-text);
}
.wesp-setup__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wesp-setup__actions-fill {
flex: 1 1 12px;
min-width: 8px;
}
.wesp-setup__btn {
appearance: none;
border: 1px solid var(--setup-border-strong);
border-radius: 10px;
padding: 10px 16px;
min-height: 42px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
background: var(--setup-surface);
color: var(--setup-text);
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.wesp-setup__btn:hover:not(:disabled) {
border-color: var(--setup-accent);
}
.wesp-setup__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__btn--ghost {
background: transparent;
border-color: transparent;
color: var(--setup-text-muted);
}
.wesp-setup__btn--ghost:hover:not(:disabled) {
background: var(--setup-surface-muted);
border-color: var(--setup-border);
color: var(--setup-text);
}
.wesp-setup__btn--primary {
background: var(--setup-accent);
border-color: var(--setup-accent);
color: #fff;
padding-inline: 20px;
}
.wesp-setup__btn--primary:hover:not(:disabled) {
filter: brightness(1.06);
border-color: var(--setup-accent);
}
.wesp-setup__btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.wesp-setup__msg {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
min-height: 1.4em;
}
.wesp-setup__msg--error {
color: var(--setup-error);
}
.wesp-setup__msg--ok {
color: var(--setup-ok);
}
.wesp-setup__hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--setup-text-soft);
}
.wesp-setup__info {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--setup-info-bg);
font-size: 13px;
line-height: 1.5;
color: var(--setup-info-text);
}
.wesp-setup__info--warn {
background: var(--setup-warn-bg);
border: 1px solid var(--setup-warn-border);
color: var(--setup-warn-text);
}
.wesp-setup__section-title {
margin: 18px 0 10px;
font-size: 15px;
font-weight: 600;
}
.wesp-setup__qr-wrap {
display: flex;
justify-content: center;
margin: 8px 0 10px;
padding: 14px;
border-radius: 12px;
background: var(--setup-surface-muted);
border: 1px solid var(--setup-border);
}
.wesp-setup__qr {
display: block;
width: min(200px, 100%);
height: auto;
margin: 0;
}
.wesp-setup__url {
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
text-align: center;
color: var(--setup-text-muted);
}
.wesp-setup__progress-bar {
height: 6px;
background: var(--setup-surface-muted);
border-radius: 999px;
overflow: hidden;
margin-top: 4px;
}
.wesp-setup__progress-fill {
height: 100%;
width: 0;
background: var(--setup-accent);
transition: width 0.2s linear;
}
.wesp-setup__summary-list {
margin: 0;
padding-left: 18px;
font-size: 14px;
line-height: 1.6;
}
.wesp-setup__input--touch {
cursor: text;
caret-color: var(--setup-accent);
}
.wesp-setup__keyboard {
flex: 0 0 auto;
display: none;
padding: 10px 16px 4px;
border-top: 1px solid var(--setup-border);
background: var(--setup-surface);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.wesp-setup__keyboard.is-visible {
display: block;
opacity: 1;
transform: translateY(0);
}
.wesp-setup__keyboard-grid {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 6px;
max-width: 100%;
margin: 0 auto;
}
.wesp-setup__keyboard-key {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 8px 4px;
border: 1px solid var(--setup-border-strong);
border-radius: 8px;
background: var(--setup-surface-muted);
color: var(--setup-text);
font-size: 15px;
font-weight: 500;
font-family: inherit;
line-height: 1;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
}
.wesp-setup__keyboard-key:active {
transform: scale(0.96);
background: var(--setup-accent-soft);
border-color: var(--setup-accent);
}
.wesp-setup__keyboard-key--wide {
font-size: 18px;
}
.wesp-setup__keyboard-key--action {
font-size: 12px;
font-weight: 600;
letter-spacing: -0.02em;
}
@media (max-width: 520px) {
.wesp-setup__intro {
height: min(200px, 32vh);
}
.wesp-setup__panel {
left: 0;
right: 0;
top: auto;
bottom: 0;
transform: none;
width: 100%;
max-width: 100%;
max-height: min(92dvh, 720px);
border-radius: 18px 18px 0 0;
border-bottom: none;
}
.wesp-setup__header {
padding: 18px 18px 0;
}
.wesp-setup__content {
padding: 14px 18px 6px;
}
.wesp-setup__footer {
padding: 10px 18px calc(14px + env(safe-area-inset-bottom, 0px));
}
.wesp-setup__title {
font-size: 20px;
}
.wesp-setup__role-card {
min-height: 72px;
padding: 16px 14px;
}
.wesp-setup__theme-grid .wesp-setup__theme-card {
padding: 11px 12px 11px 56px;
}
.wesp-setup__theme-grid .wesp-setup__theme-card::before {
left: 12px;
width: 28px;
height: 28px;
margin-top: -14px;
}
.wesp-setup__btn {
min-height: 44px;
}
.wesp-setup__actions {
gap: 6px;
}
.wesp-setup__actions-fill {
flex-basis: 100%;
height: 0;
min-width: 0;
}
.wesp-setup__btn--primary {
flex: 1 1 auto;
min-width: calc(50% - 4px);
}
.wesp-setup__keyboard {
padding: 8px 14px 2px;
}
.wesp-setup__keyboard-grid {
gap: 5px;
}
.wesp-setup__keyboard-key {
min-height: 44px;
font-size: 14px;
}
.wesp-setup__keyboard-key--action {
font-size: 11px;
}
}
+75
View File
@@ -0,0 +1,75 @@
/* Экран /starting — тот же масштаб логотипа, что intro в setup wizard */
.wesp-startup-screen {
min-height: 100vh;
margin: 0;
background: #f6f6f4;
color: rgba(26, 30, 28, 0.72);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wesp-startup__stage {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
display: flex;
flex-direction: column;
align-items: stretch;
}
.wesp-startup__stage .wesp-setup__intro {
position: relative;
left: auto;
top: auto;
transform: none;
width: 100%;
height: min(240px, 36vh);
background: transparent;
}
.wesp-startup__stage .wesp-logo-loader,
.wesp-startup__stage .wesp-logo-loader--embedded,
.wesp-startup__stage .wesp-logo-loader--plain {
background: transparent !important;
}
/* Подпись только снаружи (#startupQuip), не пустой tagline внутри лоадера */
.wesp-startup__stage .wesp-logo-loader__tagline {
display: none !important;
}
.wesp-startup__quip {
flex: 0 0 auto;
margin: 10px 0 0;
padding: 0 12px;
text-align: center;
font-size: 0.92rem;
line-height: 1.5;
color: rgba(26, 30, 28, 0.58);
min-height: 3em;
max-width: 100%;
}
.wesp-startup__quip--fade {
opacity: 0;
}
.wesp-startup__quip--error {
color: #cf1322;
}
/* Киоск / логин зоотехника: --z-login-bg из zootechnician-login.css */
html[data-theme="dark"] .wesp-startup-screen {
background: #12181b;
color: #ffffff;
}
html[data-theme="dark"] .wesp-startup__quip {
color: #a8b0b8;
}
html[data-theme="dark"] .wesp-startup__quip--error {
color: #ff7875;
}
+227
View File
@@ -0,0 +1,227 @@
/* Ненавязчивый баннер «доступно обновление» — киоск и операторские страницы */
#wespUpdateBanner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4500;
display: none;
box-sizing: border-box;
padding: 10px 14px;
padding-bottom: max(10px, env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(30, 58, 138, 0.97), rgba(29, 78, 216, 0.98));
color: #f8fafc;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.25);
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
#wespUpdateBanner.wesp-update-banner--visible {
display: block;
}
#wespUpdateBanner .wesp-update-banner-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
#wespUpdateBanner .wesp-update-banner-text {
flex: 1 1 200px;
min-width: 0;
}
#wespUpdateBanner .wesp-update-banner-text strong {
font-weight: 600;
}
#wespUpdateBanner .wesp-update-banner-changelog {
display: none;
width: 100%;
margin: 4px 0 0;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.18);
border-radius: 6px;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow: auto;
}
#wespUpdateBanner.wesp-update-banner--expanded .wesp-update-banner-changelog {
display: block;
}
#wespUpdateBanner .wesp-update-banner-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
#wespUpdateBanner .wesp-update-banner-btn {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.12);
color: #fff;
border-radius: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
}
#wespUpdateBanner .wesp-update-banner-btn:hover {
background: rgba(255, 255, 255, 0.22);
}
#wespUpdateBanner .wesp-update-banner-btn--primary {
background: #fff;
color: #1d4ed8;
border-color: #fff;
}
#wespUpdateBanner .wesp-update-banner-btn--primary:hover {
background: #eff6ff;
}
#wespUpdateOverlay {
position: fixed;
inset: 0;
z-index: 6000;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.72);
box-sizing: border-box;
}
#wespUpdateOverlay.wesp-update-overlay--visible {
display: flex;
}
#wespUpdateOverlay .wesp-update-overlay-panel {
background: #fff;
color: #111827;
border-radius: 10px;
padding: 24px 28px;
max-width: 420px;
width: 100%;
text-align: center;
font-family: Inter, system-ui, sans-serif;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
#wespUpdateOverlay .wesp-update-overlay-title {
margin: 0 0 10px;
font-size: 1.125rem;
font-weight: 600;
}
#wespUpdateOverlay .wesp-update-overlay-body {
margin: 0 0 16px;
font-size: 0.95rem;
color: #4b5563;
line-height: 1.5;
}
#wespUpdateOverlay .wesp-update-progress-wrap {
text-align: left;
}
#wespUpdateOverlay .wesp-update-progress-track {
height: 8px;
border-radius: 999px;
background: #e5e7eb;
overflow: hidden;
}
#wespUpdateOverlay .wesp-update-progress-bar {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
);
transition: width 0.25s ease;
}
#wespUpdateOverlay .wesp-update-progress-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 8px;
font-size: 0.8125rem;
color: #6b7280;
}
#wespUpdateOverlay .wesp-update-progress-percent {
font-weight: 600;
color: #1d4ed8;
flex-shrink: 0;
}
#wespUpdateOverlay .wesp-update-progress-detail {
text-align: right;
min-width: 0;
word-break: break-word;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-panel {
background: #242424;
color: #f3f4f6;
}
#wespUpdateOverlay .wesp-update-progress-bar.wesp-update-progress-bar--error {
background: linear-gradient(90deg, #dc2626, #b91c1c);
}
#wespUpdateOverlay .wesp-update-overlay-panel--rollback {
border: 1px solid #fecaca;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-panel--rollback {
border-color: #7f1d1d;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-body {
color: #d1d5db;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-track {
background: #374151;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-meta {
color: #9ca3af;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-percent {
color: #93c5fd;
}
@media (max-width: 480px) {
#wespUpdateBanner .wesp-update-banner-inner {
flex-direction: column;
align-items: stretch;
}
#wespUpdateBanner .wesp-update-banner-actions {
justify-content: stretch;
}
#wespUpdateBanner .wesp-update-banner-btn {
flex: 1;
text-align: center;
}
}
@@ -0,0 +1,807 @@
/**
* Компоненты операторского UI — карточки, кнопки, формы, табы, loading, empty.
* После wesp-zootech-layout.css
*/
/* —— Section —— */
.zt-section {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg);
box-shadow: var(--shadow-soft);
padding: var(--zt-space-4);
margin-bottom: var(--zt-space-4);
}
.zt-section__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--zt-space-3);
margin-bottom: var(--zt-space-3);
flex-wrap: wrap;
}
.zt-section__title {
display: flex;
align-items: center;
gap: var(--zt-space-2);
font-size: var(--zt-font-section);
font-weight: 600;
color: var(--text);
margin: 0;
}
.zt-section__title i {
color: var(--primary-color);
font-size: 1.1rem;
}
.zt-section__subtitle {
font-size: var(--zt-font-body);
color: var(--muted);
margin: 0.25rem 0 0;
}
.zt-section__actions {
display: flex;
align-items: center;
gap: var(--zt-space-2);
flex-shrink: 0;
}
/* —— Card —— */
.zt-card {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg);
padding: var(--zt-space-4);
box-shadow: var(--shadow-soft);
transition: border-color var(--transition), box-shadow var(--transition);
}
.zt-card--interactive {
cursor: pointer;
}
.zt-card--interactive:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-soft), 0 0 0 2px rgba(var(--primary-rgb), 0.18);
}
.zt-card--dashed {
border-style: dashed;
border-color: var(--border-light);
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--zt-space-3);
}
.zt-card--stat {
border-left: 3px solid var(--primary-color);
padding: var(--zt-space-3) var(--zt-space-4);
}
.zt-card-grid {
display: grid;
gap: var(--zt-space-3);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* Legacy aliases */
.component-card,
.dispenser-card,
.consumption-block .zt-card {
/* migrated pages use zt-card; keep hooks for gradual migration */
}
/* —— Loading placeholder —— */
.zt-loading-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
min-height: 8rem;
padding: var(--zt-space-3) 0;
}
.zt-loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
}
.zt-loading-list.dashboard-list-skeleton--exit,
.dashboard-list-skeleton--exit {
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.zt-card--loading {
pointer-events: none;
min-height: 5.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2);
animation: zt-card-pulse 1.6s ease-in-out infinite;
}
@keyframes zt-card-pulse {
0%,
100% {
border-color: var(--border-light);
opacity: 1;
}
50% {
border-color: rgba(var(--primary-rgb), 0.35);
opacity: 0.92;
}
}
.zt-spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: zt-spin 0.7s linear infinite;
}
.zt-spinner--lg {
width: 32px;
height: 32px;
border-width: 3px;
}
@keyframes zt-spin {
to {
transform: rotate(360deg);
}
}
.zt-loading-caption {
font-size: var(--zt-font-caption);
color: var(--muted);
margin: 0;
}
.zt-loading-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-3);
padding: var(--zt-space-5);
min-height: 12rem;
}
/* —— Tabs —— */
.zt-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--zt-space-1);
margin-bottom: var(--zt-space-3);
}
.zt-tab,
.fa-tab-btn.period-btn {
border: 1px solid var(--border-light);
background: transparent;
border-radius: var(--zt-radius-sm);
padding: 0.45rem 0.85rem;
font-size: var(--zt-font-body);
font-weight: 500;
color: var(--text);
cursor: pointer;
transition: background var(--transition), border-color var(--transition),
color var(--transition);
min-height: var(--zt-btn-height);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.zt-tab:hover,
.fa-tab-btn:not(.active):hover {
background: rgba(var(--primary-rgb), 0.06);
border-color: rgba(var(--primary-rgb), 0.25);
}
.zt-tab.active,
.zt-tab--active,
.fa-tab-btn.active,
.period-btn.active {
background: var(--gradient-blue) !important;
border-color: transparent !important;
color: #fff !important;
}
/* —— Empty state —— */
.zt-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--zt-space-5) var(--zt-space-4);
color: var(--muted);
}
.zt-empty__icon {
font-size: 2.5rem;
margin-bottom: var(--zt-space-3);
opacity: 0.45;
}
.zt-empty__title {
font-size: var(--zt-font-section);
font-weight: 600;
color: var(--text);
margin: 0 0 var(--zt-space-1);
}
.zt-empty__hint {
font-size: var(--zt-font-body);
margin: 0;
max-width: 28rem;
}
/* —— Inline notices & toasts (единый вид подсказок и уведомлений) —— */
.zt-notice {
display: none;
margin: 0;
padding: 0.75rem 1rem;
font-size: var(--zt-font-body);
line-height: 1.55;
color: var(--text);
background: var(--bg-light);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md);
border-left-width: 3px;
border-left-style: solid;
box-shadow: var(--box-shadow);
}
.zt-notice.is-visible,
.zt-notice--static {
display: block;
}
.zt-notice--info {
border-left-color: var(--primary-color);
}
.zt-notice--success {
border-left-color: var(--success-color);
background: rgba(16, 185, 129, 0.08);
}
.zt-notice--warning {
border-left-color: var(--warning-color);
background: rgba(245, 158, 11, 0.1);
}
.zt-notice--error {
border-left-color: var(--danger-color);
background: rgba(239, 68, 68, 0.08);
}
.zt-notice__p {
margin: 0 0 0.65rem;
}
.zt-notice__p:last-child {
margin-bottom: 0;
}
.zt-notice__p strong {
font-weight: 600;
color: var(--text);
}
html[data-theme="dark"] .zt-notice {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: var(--text);
box-shadow: none;
}
html[data-theme="dark"] .zt-notice--success {
background: rgba(16, 185, 129, 0.12);
}
html[data-theme="dark"] .zt-notice--warning {
background: rgba(245, 158, 11, 0.12);
}
html[data-theme="dark"] .zt-notice--error {
background: rgba(239, 68, 68, 0.12);
}
/* —— Bottom-center toast (Yandex Music style) —— */
@keyframes wesp-zt-toast-enter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wesp-zt-toast-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(12px);
}
}
@keyframes wesp-zt-toast-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes wesp-zt-toast-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.wesp-zt-toast-host {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 6000;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
}
.wesp-zt-toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 0.6rem;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
min-height: 40px;
padding: 0.45rem 0.55rem 0.45rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 14, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
animation: wesp-zt-toast-enter 280ms ease-out both;
}
.wesp-zt-toast.is-exiting {
animation: wesp-zt-toast-exit 220ms ease-in both;
}
@media (prefers-reduced-motion: reduce) {
.wesp-zt-toast {
animation: wesp-zt-toast-fade-in 200ms ease-out both;
}
.wesp-zt-toast.is-exiting {
animation: wesp-zt-toast-fade-out 180ms ease-in both;
}
}
.wesp-zt-toast__accent {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
align-self: center;
border-radius: 50%;
background: var(--success-color);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.45);
}
.wesp-zt-toast--error .wesp-zt-toast__accent {
background: var(--danger-color);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.45);
}
.wesp-zt-toast--warning .wesp-zt-toast__accent {
background: var(--warning-color);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.45);
}
.wesp-zt-toast__message {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding-right: 0.15rem;
font-size: 0.9375rem;
line-height: 1.35;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wesp-zt-toast--multiline .wesp-zt-toast__message {
white-space: normal;
}
.wesp-zt-toast__message strong {
font-weight: 600;
color: #fff;
}
.wesp-zt-toast__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
margin: 0;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.55);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.wesp-zt-toast__close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="light"] .wesp-zt-toast {
background: rgba(30, 30, 34, 0.9);
border-color: rgba(255, 255, 255, 0.12);
}
html[data-theme="light"] .wesp-zt-toast__message {
color: rgba(255, 255, 255, 0.92);
}
/* —— Notification center FAB (bottom-left) —— */
.wesp-zt-notify-fab {
position: fixed;
left: max(20px, calc(env(safe-area-inset-left, 0px) + 16px));
bottom: max(24px, calc(env(safe-area-inset-bottom, 0px) + 20px));
z-index: 5500;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
background: rgba(12, 12, 14, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.wesp-zt-notify-fab:hover {
transform: scale(1.04);
}
.wesp-zt-notify-fab:focus-visible {
outline: 2px solid rgba(var(--primary-rgb), 0.85);
outline-offset: 2px;
}
.wesp-zt-notify-fab__glyph {
font-family: Inter, Arial, Helvetica, "DejaVu Sans", sans-serif;
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
color: #fff;
transform: translateY(0.5px);
}
.wesp-zt-notify-fab__badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(12, 12, 14, 0.95);
}
.wesp-zt-notify-fab__badge[hidden] {
display: none !important;
}
/* —— Button grid (modals) —— */
.zt-btn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--zt-space-2);
}
@media (max-width: 480px) {
.zt-btn-grid {
grid-template-columns: 1fr;
}
}
/* —— Form grid —— */
.zt-form-grid {
display: grid;
gap: var(--zt-space-3);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.zootech-app-body .form-label,
.zt-section .form-label {
font-size: var(--zt-font-caption);
font-weight: 500;
color: var(--muted);
margin-bottom: 0.35rem;
}
/* —— Bootstrap overrides (operator pages) —— */
.zootech-app-body .btn {
min-height: var(--zt-btn-height);
padding: 0.45rem 1rem;
border-radius: var(--zt-radius-md);
font-weight: 500;
font-size: var(--zt-font-body);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--zt-space-1);
transition: transform var(--transition), filter var(--transition),
background var(--transition), border-color var(--transition);
}
.zootech-app-body .btn:active:not(:disabled) {
transform: scale(0.98);
}
.zootech-app-body .btn:focus-visible {
outline: none;
box-shadow: var(--zt-focus-ring);
}
.zootech-app-body .btn-primary {
background: var(--gradient-blue) !important;
border: none !important;
color: #fff !important;
}
.zootech-app-body .btn-primary:hover:not(:disabled) {
filter: brightness(1.06);
}
.zootech-app-body .btn-success {
background: var(--success-color) !important;
border-color: var(--success-color) !important;
color: #fff !important;
}
.zootech-app-body .btn-success:hover:not(:disabled) {
filter: brightness(1.05);
}
.zootech-app-body .btn-secondary {
background: var(--field) !important;
border-color: var(--border-light) !important;
color: var(--text) !important;
}
.zootech-app-body .btn-outline-primary {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.zootech-app-body .btn-outline-primary:hover:not(:disabled) {
background: rgba(var(--primary-rgb), 0.08) !important;
}
.zootech-app-body .btn-outline-secondary {
color: var(--text) !important;
border-color: var(--border-light) !important;
}
.zootech-app-body .btn-outline-danger {
color: var(--danger-color) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
}
.zootech-app-body .btn-outline-success {
color: var(--success-color) !important;
border-color: rgba(16, 185, 129, 0.5) !important;
}
.zootech-app-body .btn-outline-success:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.1) !important;
}
.zootech-app-body .form-control,
.zootech-app-body .form-select {
min-height: var(--zt-btn-height);
border-radius: var(--zt-radius-md);
border-color: var(--border-light);
font-size: var(--zt-font-body);
}
.zootech-app-body .form-control:focus,
.zootech-app-body .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
}
/* Magic / modern btn aliases */
.zootech-app-body .magic-button,
.zootech-app-body .modern-btn.btn-primary-modern,
.zootech-app-body .btn-primary-modern {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--zt-space-1);
padding: 0.55rem 1.25rem;
font-weight: 600;
color: #fff;
background: var(--gradient-blue);
border: none;
border-radius: var(--zt-radius-md);
cursor: pointer;
min-height: var(--zt-btn-height);
box-shadow: var(--shadow-soft);
}
.zootech-app-body .magic-button:hover {
filter: brightness(1.06);
}
/* Recipe editor — квадратные icon-кнопки в шапке */
#recipeEdit .recipe-edit-header-icon-btn {
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--zt-radius-md);
}
#recipeEdit .recipe-section-toolbar > .btn-primary,
#recipeEdit .recipe-form-footer-actions .btn-primary {
min-height: var(--zt-btn-height);
padding: 0.45rem 1rem;
font-weight: 600;
}
#recipeEdit .recipe-save-exit-btn {
min-width: 11rem;
}
@keyframes zt-reveal {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Recipes boot overlay — только зона контента, navbar остаётся виден */
#wesp-recipes-boot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 900;
background: var(--bg-page);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.35s ease;
pointer-events: none;
}
#wesp-recipes-boot.wesp-recipes-boot--hide {
opacity: 0;
}
html[data-theme="dark"] #wesp-recipes-boot {
background: var(--bg-page);
}
#wesp-recipes-boot .wesp-logo-loader--embedded {
width: min(480px, 90vw);
height: min(222px, 36vh);
}
/* Gray scale legacy (reports) */
:root {
--gray-50: var(--bg-light);
--gray-100: #f3f4f6;
--gray-200: var(--border-light);
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: var(--text-muted);
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: var(--text-dark);
--white: var(--surface);
}
html[data-theme="dark"] {
--gray-50: var(--bg-page);
--gray-100: var(--surface);
--gray-200: #2a3036;
--gray-300: var(--field);
--gray-400: var(--muted);
--gray-500: #b8c0c8;
--gray-600: #d0d4d8;
--gray-700: #e8eaec;
--gray-800: #f0f4f8;
--gray-900: var(--text);
--white: var(--surface);
}
@media (max-width: 768px) {
.zootech-app-body .btn {
min-height: var(--zt-btn-height-touch);
}
}
@media (prefers-reduced-motion: reduce) {
.zt-card--loading {
animation: none;
}
.zt-spinner {
animation: none;
border-top-color: var(--primary-color);
}
}
File diff suppressed because it is too large Load Diff
+946
View File
@@ -0,0 +1,946 @@
/**
* Общая вёрстка операторских (зоотех) страниц: токены, шрифт, навбар, футер.
* Подключать после wesp-zootech-shell.css и bootstrap.min.css.
*/
:root {
--primary-color: #2196f3;
--primary-rgb: 33, 150, 243;
--primary: var(--primary-color);
--gradient-border: linear-gradient(
135deg,
var(--primary-color),
var(--primary-light),
var(--primary-dark)
);
--primary-light: #42a5f5;
--primary-dark: #1976d2;
--primary-darker: #1565c0;
--primary-blue: var(--primary-color);
--light-blue: #42a5f5;
--accent-blue: #1565c0;
--secondary-color: #6c757d;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #0ea5e9;
--bg-page: #f5f7fa;
--bg-light: #f8fafc;
--bg-white: #ffffff;
--surface-muted: #f2f5f9;
/* Слот логотипа: logo2.png 2222×1024; contain + left (обе темы — один файл) */
--nav-logo-slot-h: 38px;
--nav-logo-slot-w: 115px;
--mobile-menu-logo-slot-h: 32px;
--mobile-menu-logo-slot-w: 96px;
--text-dark: #1e293b;
--text-muted: #64748b;
--border-light: #e2e8f0;
--border-radius: 10px;
--box-shadow: 0 2px 4px rgba(15, 23, 42, 0.06);
--box-shadow-hover: 0 8px 22px rgba(15, 23, 42, 0.1),
0 0 0 1px rgba(15, 23, 42, 0.04);
--shadow-soft: 0 4px 6px -1px rgb(15 23 42 / 0.08),
0 2px 4px -2px rgb(15 23 42 / 0.06);
--shadow-medium: 0 10px 20px -4px rgb(15 23 42 / 0.12);
--gradient-blue: linear-gradient(
135deg,
var(--primary-color) 0%,
var(--primary-light) 100%
);
--transition: 0.22s ease;
--zootech-font: "Inter", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
--zootech-font-mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace;
--zootech-content-max: 1400px;
--zootech-content-pad-x: clamp(0.75rem, 2.5vw, 1.25rem);
--zootech-nav-pad-y: 0.875rem;
/* Semantic aliases */
--surface: var(--bg-white);
--field: #ffffff;
--text: var(--text-dark);
--muted: var(--text-muted);
/* Spacing (8px grid) */
--zt-space-1: 8px;
--zt-space-2: 12px;
--zt-space-3: 16px;
--zt-space-4: 24px;
--zt-space-5: 32px;
--zt-radius-sm: 8px;
--zt-radius-md: 10px;
--zt-radius-lg: 12px;
--zt-font-caption: 0.8125rem;
--zt-font-body: 0.9375rem;
--zt-font-section: 1.125rem;
--zt-font-page: 1.5rem;
--zt-btn-height: 40px;
--zt-btn-height-touch: 44px;
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px rgba(var(--primary-rgb), 0.45);
}
html[data-theme="organic"] {
--primary-color: #48816d;
--primary-rgb: 72, 129, 109;
--primary-light: #5a9a82;
--primary-dark: #3a6b58;
--primary-darker: #2f5748;
--bg-page: #f6f6f4;
--bg-light: #f6f6f4;
--bg-white: #ffffff;
--surface-muted: #eff4f2;
--text-dark: #1a1e1c;
--text-muted: #4a5a52;
--border-light: #e8e8e2;
--box-shadow: 0 2px 4px rgba(26, 30, 28, 0.04);
--box-shadow-hover: 0 8px 22px rgba(26, 30, 28, 0.08),
0 0 0 1px rgba(26, 30, 28, 0.03);
--shadow-soft: 0 5px 20px rgba(0, 0, 0, 0.02);
--shadow-medium: 0 10px 20px -4px rgba(26, 30, 28, 0.1);
--surface: var(--bg-white);
--field: #ffffff;
--text: var(--text-dark);
--muted: var(--text-muted);
--zt-radius-lg: 24px;
--zt-radius-md: 16px;
--organic-logo-filter: brightness(0) saturate(100%) invert(43%) sepia(19%) saturate(747%)
hue-rotate(118deg) brightness(92%) contrast(87%);
--bs-primary: #48816d;
--bs-primary-rgb: 72, 129, 109;
--bs-link-color: #48816d;
--bs-link-hover-color: #3a6b58;
}
html[data-theme="organic"] .nav-brand .logo.logo--light-theme,
html[data-theme="organic"] .mobile-brand .logo.logo--light-theme {
filter: var(--organic-logo-filter) !important;
}
/* Усиленный контраст — все темы (ручной data-high-contrast или системный prefers-contrast) */
html[data-high-contrast="true"][data-theme="light"],
html[data-high-contrast="true"]:not([data-theme]) {
--text-dark: #0f172a;
--text-muted: #334155;
--border-light: #94a3b8;
--primary-color: #1565c0;
--primary-rgb: 21, 101, 192;
--primary-dark: #0d47a1;
--primary-darker: #0a3d91;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
}
html[data-high-contrast="true"][data-theme="organic"] {
--primary-color: #2f6b57;
--primary-rgb: 47, 107, 87;
--primary-light: #3d8570;
--primary-dark: #234a3d;
--primary-darker: #1a3830;
--text-dark: #0f1211;
--text-muted: #2e3834;
--border-light: #b0b8b2;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
--organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%)
hue-rotate(118deg) brightness(88%) contrast(96%);
--bs-primary: #2f6b57;
--bs-primary-rgb: 47, 107, 87;
--bs-link-color: #2f6b57;
--bs-link-hover-color: #234a3d;
--bs-success: #2f6b57;
--bs-success-rgb: 47, 107, 87;
}
html[data-high-contrast="true"][data-theme="dark"] {
--text-dark: #ffffff;
--text-muted: #d1d9e0;
--border-light: rgba(255, 255, 255, 0.28);
--bg-white: #353d44;
--field: #4a5259;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light);
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff;
}
@media (prefers-contrast: more) {
html:not([data-high-contrast="false"])[data-theme="light"],
html:not([data-high-contrast="false"]):not([data-theme]) {
--text-dark: #0f172a;
--text-muted: #334155;
--border-light: #94a3b8;
--primary-color: #1565c0;
--primary-rgb: 21, 101, 192;
--primary-dark: #0d47a1;
--primary-darker: #0a3d91;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
}
html:not([data-high-contrast="false"])[data-theme="organic"] {
--primary-color: #2f6b57;
--primary-rgb: 47, 107, 87;
--primary-light: #3d8570;
--primary-dark: #234a3d;
--primary-darker: #1a3830;
--text-dark: #0f1211;
--text-muted: #2e3834;
--border-light: #b0b8b2;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08);
--zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark);
--organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%)
hue-rotate(118deg) brightness(88%) contrast(96%);
--bs-primary: #2f6b57;
--bs-primary-rgb: 47, 107, 87;
--bs-link-color: #2f6b57;
--bs-link-hover-color: #234a3d;
--bs-success: #2f6b57;
--bs-success-rgb: 47, 107, 87;
}
html:not([data-high-contrast="false"])[data-theme="dark"] {
--text-dark: #ffffff;
--text-muted: #d1d9e0;
--border-light: rgba(255, 255, 255, 0.28);
--bg-white: #353d44;
--field: #4a525a;
--box-shadow: none;
--box-shadow-hover: 0 0 0 1px var(--border-light);
--zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff;
}
}
html[data-theme="dark"] {
--bg-page: #12181b;
--bg-light: #12181b;
--bg-white: #2c3338;
--text-dark: #f5f5f5;
--text-muted: #a8b0b8;
--border-light: rgba(255, 255, 255, 0.12);
--shadow-soft: 0 4px 12px rgb(0 0 0 / 0.35);
--shadow-medium: 0 12px 24px rgb(0 0 0 / 0.45);
--box-shadow-hover: 0 8px 22px rgb(0 0 0 / 0.35);
--surface: var(--bg-white);
--field: #3e444a;
--text: var(--text-dark);
--muted: var(--text-muted);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body.zootech-app-body,
body:has(.mobile-navbar) {
margin: 0;
font-family: var(--zootech-font);
font-size: 0.9375rem;
color: var(--text-dark);
line-height: 1.55;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--bg-page);
-webkit-font-smoothing: antialiased;
}
.zootech-app-body .container,
body:has(.mobile-navbar) .container {
max-width: var(--zootech-content-max);
margin-left: auto;
margin-right: auto;
padding-left: var(--zootech-content-pad-x);
padding-right: var(--zootech-content-pad-x);
}
.app-footer {
margin-top: auto;
padding: 14px var(--zootech-content-pad-x) 20px;
text-align: center;
font-size: 0.8125rem;
color: var(--text-muted);
}
html[data-theme="dark"] .app-footer,
html[data-theme="dark"] .app-footer .text-muted {
color: var(--text-muted) !important;
}
.page-content {
flex: 1 0 auto;
width: 100%;
}
.main-content {
flex: 1 1 auto;
padding-bottom: 1.5rem;
}
/* —— Верхняя панель —— */
.mobile-navbar {
background: var(--gradient-blue);
padding: var(--zootech-nav-pad-y) 0;
margin-bottom: 1.75rem;
box-shadow: var(--box-shadow-hover);
position: sticky;
top: 0;
z-index: 1000;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.01em;
flex-shrink: 0;
}
.nav-brand i {
font-size: 1.35rem;
color: #ffd54f;
}
.nav-brand .nav-logo-link {
position: relative;
width: var(--nav-logo-slot-w);
height: var(--nav-logo-slot-h);
flex-shrink: 0;
isolation: isolate;
}
/* Блик только по контуру букв (mask = тот же PNG, что и логотип) */
.nav-logo-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.72) 50%,
rgba(255, 255, 255, 0) 70%,
rgba(255, 255, 255, 0) 100%
);
background-size: 280% 100%;
background-position: 100% 0;
background-repeat: no-repeat;
-webkit-mask-image: url("/static/logo2.png");
mask-image: url("/static/logo2.png");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: left center;
mask-position: left center;
mix-blend-mode: soft-light;
will-change: background-position, opacity;
}
/* Блик после появления navbar (ready → done), иначе анимация обрывается */
html.wesp-page-enter-ready .nav-logo-link::after,
html.wesp-page-enter-done .nav-logo-link::after {
animation: wesp-nav-logo-shimmer 3s cubic-bezier(0.42, 0, 0.18, 1) infinite;
animation-delay: 0.35s;
}
@keyframes wesp-nav-logo-shimmer {
0%,
100% {
background-position: 100% 0;
opacity: 0;
}
6% {
opacity: 1;
}
24% {
background-position: 0% 0;
opacity: 1;
}
32%,
100% {
background-position: 0% 0;
opacity: 0;
}
}
html[data-theme="dark"] .nav-logo-link::after {
display: none;
}
html[data-theme="dark"] .nav-logo-link--canvas-shimmer .nav-logo__shimmer-canvas {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.mobile-brand .nav-logo-link {
position: relative;
width: var(--mobile-menu-logo-slot-w);
height: var(--mobile-menu-logo-slot-h);
flex-shrink: 0;
isolation: isolate;
}
.nav-brand .logo,
.mobile-brand .logo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: left center;
filter: brightness(0) invert(1);
}
.nav-brand .logo--dark-theme,
.mobile-brand .logo--dark-theme {
display: none;
}
html[data-theme="dark"] .nav-brand .logo--light-theme,
html[data-theme="dark"] .mobile-brand .logo--light-theme {
display: none;
}
html[data-theme="dark"] .nav-brand .logo--dark-theme,
html[data-theme="dark"] .mobile-brand .logo--dark-theme {
display: block;
}
.hamburger-menu {
display: none;
flex-direction: column;
gap: 5px;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: background var(--transition), transform var(--transition);
}
.hamburger-menu:hover {
background: rgba(255, 255, 255, 0.12);
}
.hamburger-line {
width: 24px;
height: 2.5px;
background: #fff;
border-radius: 2px;
transition: transform var(--transition), opacity var(--transition);
}
.hamburger-menu.active .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-menu.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-menu.active .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.desktop-menu {
display: flex;
gap: 0.45rem;
align-items: center;
flex-wrap: nowrap;
}
.menu-btn {
position: relative;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.9rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
transition: transform var(--transition), background var(--transition),
box-shadow var(--transition);
cursor: pointer;
text-decoration: none;
color: #fff;
font-weight: 500;
font-size: 0.875rem;
overflow: hidden;
}
.menu-btn:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.28);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
color: #fff;
}
.menu-btn.active {
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.35);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.menu-btn i {
font-size: 0.95rem;
color: #fff;
}
.user-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-info {
color: #fff;
font-weight: 500;
font-size: 0.875rem;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout-btn {
display: flex;
align-items: center;
gap: 0.4rem;
color: #ffc9c9;
text-decoration: none;
padding: 0.45rem 0.85rem;
border-radius: 8px;
transition: background var(--transition), color var(--transition);
font-weight: 500;
font-size: 0.875rem;
}
.logout-btn:hover {
background: rgba(255, 107, 107, 0.2);
color: #fff;
}
/* Мобильное меню */
.mobile-menu-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
z-index: 2000;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition), visibility var(--transition);
}
.mobile-menu-overlay.active {
opacity: 1;
visibility: visible;
}
.mobile-menu {
position: fixed;
top: 0;
right: -100%;
width: min(88vw, 340px);
height: 100%;
background: var(--gradient-blue);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25);
transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
flex-direction: column;
}
.mobile-menu-overlay.active .mobile-menu {
right: 0;
}
.mobile-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mobile-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
font-weight: 600;
font-size: 1.05rem;
}
.mobile-brand i {
font-size: 1.35rem;
color: #ffd54f;
}
.close-menu {
color: #fff;
font-size: 1.35rem;
cursor: pointer;
padding: 8px;
border-radius: 50%;
line-height: 1;
transition: background var(--transition);
}
.close-menu:hover {
background: rgba(255, 255, 255, 0.12);
}
.mobile-menu-items {
flex: 1;
padding: 0.5rem 0;
overflow-y: auto;
}
.mobile-menu-item {
display: flex;
align-items: center;
gap: 0.85rem;
color: #fff;
text-decoration: none;
padding: 0.9rem 1.25rem;
font-weight: 500;
font-size: 0.9375rem;
border-left: 3px solid transparent;
transition: background var(--transition), border-color var(--transition);
}
.mobile-menu-item:hover,
.mobile-menu-item.active {
background: rgba(255, 255, 255, 0.1);
border-left-color: #ffd54f;
color: #fff;
}
.mobile-menu-item i {
font-size: 1.1rem;
width: 22px;
text-align: center;
}
.mobile-menu-footer {
padding: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.mobile-user-info {
color: #fff;
font-weight: 500;
font-size: 0.875rem;
margin-bottom: 0.85rem;
padding: 0.6rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.mobile-logout-btn {
display: flex;
align-items: center;
gap: 0.5rem;
color: #ffc9c9;
text-decoration: none;
padding: 0.7rem 0.85rem;
border-radius: 8px;
font-weight: 500;
background: rgba(255, 107, 107, 0.12);
transition: background var(--transition);
}
.mobile-logout-btn:hover {
background: rgba(255, 107, 107, 0.22);
color: #fff;
}
/* Светлая тема: навбар как заголовки таблиц (surface-muted), без синего градиента */
html:not([data-theme="dark"]) .mobile-navbar {
background: var(--surface-muted);
box-shadow: var(--box-shadow);
border-bottom: 1px solid var(--border-light);
}
html:not([data-theme="dark"]) .nav-brand {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .nav-brand i {
color: var(--primary-color);
}
html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo,
html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo {
filter: none;
}
html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo--dark-theme,
html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo--dark-theme {
display: none;
}
html:not([data-theme="dark"]) .hamburger-line {
background: var(--text-dark);
}
html:not([data-theme="dark"]) .hamburger-menu:hover {
background: rgba(15, 23, 42, 0.06);
}
html:not([data-theme="dark"]) .menu-btn {
background: var(--bg-white);
border: 1px solid var(--border-light);
color: var(--text-dark);
box-shadow: none;
}
html:not([data-theme="dark"]) .menu-btn:hover {
background: var(--bg-white);
border-color: var(--primary-color);
color: var(--primary-darker);
box-shadow: var(--box-shadow);
}
html:not([data-theme="dark"]) .menu-btn.active {
background: rgba(var(--primary-rgb), 0.1);
border-color: var(--primary-color);
color: var(--primary-darker);
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.12);
}
html:not([data-theme="dark"]) .menu-btn i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .user-info {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .logout-btn {
color: var(--danger-color);
}
html:not([data-theme="dark"]) .logout-btn:hover {
background: rgba(239, 68, 68, 0.08);
color: var(--danger-color);
}
html:not([data-theme="dark"]) .mobile-menu {
background: var(--bg-white);
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
}
html:not([data-theme="dark"]) .mobile-menu-header {
background: var(--surface-muted);
border-bottom-color: var(--border-light);
}
html:not([data-theme="dark"]) .mobile-brand {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .mobile-brand i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .close-menu {
color: var(--text-muted);
}
html:not([data-theme="dark"]) .close-menu:hover {
background: rgba(15, 23, 42, 0.06);
}
html:not([data-theme="dark"]) .mobile-menu-item {
color: var(--text-dark);
}
html:not([data-theme="dark"]) .mobile-menu-item:hover,
html:not([data-theme="dark"]) .mobile-menu-item.active {
background: rgba(var(--primary-rgb), 0.08);
border-left-color: var(--primary-color);
color: var(--primary-darker);
}
html:not([data-theme="dark"]) .mobile-menu-item i {
color: var(--primary-color);
}
html:not([data-theme="dark"]) .mobile-menu-footer {
border-top-color: var(--border-light);
}
html:not([data-theme="dark"]) .mobile-user-info {
color: var(--text-dark);
background: var(--surface-muted);
}
html:not([data-theme="dark"]) .mobile-logout-btn {
color: var(--danger-color);
background: rgba(239, 68, 68, 0.08);
}
html:not([data-theme="dark"]) .mobile-logout-btn:hover {
background: rgba(239, 68, 68, 0.12);
color: var(--danger-color);
}
/* Заголовок раздела (общий паттерн) */
.page-header {
background: var(--bg-white);
border-radius: var(--border-radius);
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft);
border: 1px solid var(--border-light);
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
margin: 0 0 0.35rem;
letter-spacing: -0.02em;
}
.page-subtitle {
font-size: 0.9375rem;
color: var(--text-muted);
margin: 0;
}
@media (max-width: 1200px) {
.desktop-menu {
gap: 0.35rem;
}
.menu-btn {
padding: 0.5rem 0.7rem;
font-size: 0.8125rem;
}
}
@media (max-width: 992px) {
.desktop-menu {
display: none;
}
}
@media (max-width: 768px) {
.hamburger-menu {
display: flex;
}
.user-section {
display: none;
}
:root {
--nav-logo-slot-h: 32px;
--nav-logo-slot-w: 96px;
--mobile-menu-logo-slot-h: 28px;
--mobile-menu-logo-slot-w: 84px;
}
.page-header {
padding: 1.15rem 1.25rem;
}
.page-title {
font-size: 1.3rem;
}
}
/* —— Page enter (navbar → main → blocks), только opacity — без translate (иначе «дёргается») —— */
:root {
--wesp-enter-duration: 350ms;
--wesp-enter-duration-fast: 350ms;
--wesp-enter-ease: cubic-bezier(0.22, 1, 0.36, 1);
--wesp-enter-stagger: 55ms;
--wesp-enter-nav-delay: 0ms;
--wesp-enter-items-base-delay: 80ms;
}
@keyframes wesp-enter-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
html.wesp-page-enter-pending .mobile-navbar {
opacity: 0;
}
html.wesp-page-enter-ready .mobile-navbar {
animation: wesp-enter-fade var(--wesp-enter-duration-fast) var(--wesp-enter-ease) both;
animation-delay: var(--wesp-enter-nav-delay);
}
html.wesp-page-enter-pending .wesp-page-main {
opacity: 0;
}
html.wesp-page-enter-grid-page.wesp-page-enter-pending .wesp-page-main,
html.wesp-page-enter-grid-page.wesp-page-enter-ready .wesp-page-main {
opacity: 1;
}
html.wesp-page-enter-ready .wesp-page-main {
opacity: 1;
}
html.wesp-page-enter-pending .wesp-page-enter-item {
opacity: 0;
}
html.wesp-page-enter-ready .wesp-page-enter-item {
animation: wesp-enter-fade var(--wesp-enter-duration) var(--wesp-enter-ease) both;
animation-delay: calc(
var(--wesp-enter-items-base-delay) + var(--wesp-enter-i, 0) * var(--wesp-enter-stagger)
);
}
html.wesp-page-enter-done .mobile-navbar,
html.wesp-page-enter-done .wesp-page-main,
html.wesp-page-enter-done .wesp-page-enter-item {
animation: none !important;
opacity: 1;
transform: none;
will-change: auto;
}
@media (prefers-reduced-motion: reduce) {
html.wesp-page-enter-pending .mobile-navbar,
html.wesp-page-enter-pending .wesp-page-main,
html.wesp-page-enter-pending .wesp-page-enter-item {
opacity: 1 !important;
}
html.wesp-page-enter-ready .mobile-navbar,
html.wesp-page-enter-ready .wesp-page-main,
html.wesp-page-enter-ready .wesp-page-enter-item {
animation: none !important;
}
.nav-logo-link::after {
animation: none !important;
display: none;
}
}
@@ -0,0 +1,629 @@
/**
* Общая доводка UI зоотех-страниц (consumption, components, feed_dispensers, reports).
* Подключать после page-inline <style>, до </head>.
*/
.page-content,
.zootech-app-body .wesp-page-main {
flex: 1 0 auto;
width: 100%;
}
/* Панели (отчёты): компактная шапка, скругление на контейнере */
.zootech-panel {
background: var(--surface, var(--bg-white));
border: 1px solid var(--border-light);
border-radius: 16px;
box-shadow: var(--shadow-soft);
margin-bottom: var(--zt-space-4, 1.5rem);
overflow: hidden;
padding: 0;
}
.zootech-panel > .card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0.875rem 0.375rem;
margin: 0;
min-height: 0;
line-height: 1.25;
font-size: 0.875rem;
font-weight: 600;
color: var(--text, var(--text-dark));
background: var(--surface, var(--bg-white)) !important;
border-bottom: 1px solid var(--border-light);
border-radius: 0;
}
.zootech-panel > .card-header i {
font-size: 0.8125rem;
color: var(--primary-color);
}
.zootech-panel > .card-header .btn-sm,
.zootech-panel > .card-header .btn {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
line-height: 1.2;
min-height: auto;
}
.zootech-panel > .card-body {
padding: 0.875rem 1rem 1rem;
}
/* Секции components / consumption / reports */
.zootech-app-body .form-section.zt-section,
.zootech-app-body .consumption-block.zt-section {
border-radius: 16px;
overflow: hidden;
padding: var(--zt-space-4, 1.5rem);
margin-bottom: var(--zt-space-4, 1.5rem);
}
/* /reports: выпадающий календарь не должен обрезаться карточкой */
.zootech-app-body.reports-page .form-section.zt-section.reports-controls {
overflow: visible;
}
.zootech-app-body .form-section .form-title,
.zootech-app-body .consumption-block .section-title {
font-size: var(--zt-font-section, 1.125rem);
font-weight: 600;
margin: 0 0 var(--zt-space-3, 1rem);
padding: 0;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.25;
}
.zootech-app-body .consumption-section {
margin: 1.25rem 0 0;
}
/* Карточки: без поднятия, подсветка как на dashboard рецептов */
.zootech-app-body .report-card,
.zootech-app-body .component-card,
.zootech-app-body .dispenser-card.list-item,
.zootech-app-body .zt-card,
.zootech-app-body .zt-section,
.zootech-app-body .list-item,
.zootech-app-body .role-card {
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.zootech-app-body .report-card:hover,
.zootech-app-body .component-card.zt-card--interactive:hover,
.zootech-app-body .component-card.component-card--clickable:hover,
.zootech-app-body .dispenser-card.list-item:hover,
.zootech-app-body .zt-card--interactive:hover,
.zootech-app-body .zt-card:hover,
.zootech-app-body .zt-section:hover,
.zootech-app-body .list-item:hover,
.zootech-app-body .role-card:hover {
transform: none !important;
}
.zootech-app-body .report-card:hover {
box-shadow: var(--shadow-soft);
background-color: rgba(var(--primary-rgb), 0.04);
border-color: rgba(var(--primary-rgb), 0.35);
}
html[data-theme="dark"] .zootech-app-body .report-card:hover {
background-color: rgba(var(--primary-rgb), 0.08);
border-color: rgba(var(--primary-rgb), 0.4);
box-shadow: none;
}
.zootech-app-body .component-card.zt-card--interactive:hover,
.zootech-app-body .component-card.component-card--clickable:hover {
box-shadow: var(--shadow-soft), 0 0 0 1px rgba(var(--primary-rgb, 33, 150, 243), 0.2);
border-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.04);
}
.zootech-app-body .dispenser-card.list-item:hover {
box-shadow: none !important;
border-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.05);
}
/* Кнопки: без translateY на hover */
.zootech-app-body .btn-secondary:hover,
.zootech-app-body .btn-danger:hover,
.zootech-app-body .btn-success:hover,
.zootech-app-body .magic-button:hover,
.zootech-app-body .modern-btn:hover,
.zootech-app-body .nav-link:hover {
transform: none !important;
}
.zootech-app-body .magic-button:hover,
.zootech-app-body .modern-btn.btn-primary-modern:hover {
filter: brightness(1.06);
}
/* Не дублировать page-enter и fade-in / content-reveal */
html.wesp-page-enter-pending .fade-in,
html.wesp-page-enter-ready .fade-in,
html.wesp-page-enter-pending .wesp-content-reveal,
html.wesp-page-enter-ready .wesp-content-reveal {
animation: none !important;
opacity: inherit;
transform: none !important;
}
html.wesp-page-enter-done .fade-in {
animation: zt-reveal 0.45s var(--wesp-enter-ease, ease) both;
}
@media (prefers-reduced-motion: reduce) {
html.wesp-page-enter-done .fade-in {
animation: none !important;
}
}
/* Loading на странице оборудования */
.zootech-app-body .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--zt-space-2, 12px);
min-height: 8rem;
padding: var(--zt-space-4, 24px);
color: var(--text-muted);
font-size: var(--zt-font-body, 0.9375rem);
}
.zootech-app-body .loading-state i {
color: var(--primary-color);
}
/* Dashed add-cards — единая min-height */
.zootech-app-body .add-devices-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--zt-space-3, 16px);
margin-bottom: 1rem;
}
@media (max-width: 992px) {
.zootech-app-body .add-devices-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 576px) {
.zootech-app-body .add-devices-container {
grid-template-columns: 1fr;
}
}
/* Analytics: итоги и сравнение на /reports */
.analytics-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.analytics-kpi-grid {
grid-template-columns: 1fr;
}
}
.analytics-kpi-card {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.125rem;
}
.analytics-kpi-card__title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.analytics-kpi-card__value {
font-size: 1.375rem;
font-weight: 700;
color: var(--text-dark);
}
.analytics-kpi-card__value--warn {
color: #dc3545;
}
.analytics-kpi-card__value--loss {
color: #dc3545;
font-weight: 700;
}
.analytics-kpi-card__value--risk {
color: #d97706;
font-weight: 700;
}
html[data-theme="dark"] .analytics-kpi-card__value--risk {
color: #fbbf24;
}
.analytics-kpi-card__hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.35rem;
}
.analytics-section-title {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.analytics-top-list {
display: grid;
grid-template-columns: minmax(8.5rem, 12rem) minmax(5rem, 1fr) minmax(7.5rem, max-content);
column-gap: 1rem;
row-gap: 0.55rem;
align-items: center;
}
.analytics-top-row {
display: contents;
}
.analytics-top-row__name {
font-size: 0.875rem;
line-height: 1.35;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analytics-top-row__bar {
height: 8px;
min-width: 0;
width: 100%;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.analytics-top-row__bar span {
display: block;
height: 100%;
background: var(--primary-color);
border-radius: 4px;
}
.analytics-pf-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.analytics-pf-card {
background: var(--bg-white, #fff);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1rem 1.125rem;
cursor: pointer;
}
.analytics-pf-card__head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.analytics-pf-section + .analytics-pf-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
}
.analytics-pf-section__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.analytics-pf-mixer-line {
margin: 0.75rem 0 0;
padding-top: 0.75rem;
border-top: 1px dashed var(--border-light);
font-size: 0.875rem;
color: var(--text-muted);
}
.analytics-pf-mixer-line strong {
color: var(--text-primary, inherit);
font-weight: 600;
}
.analytics-pf-mixer-line--negative strong {
color: #dc3545;
}
.analytics-pf-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.analytics-pf-legend__dot {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 2px;
margin-right: 0.25rem;
vertical-align: middle;
}
.analytics-pf-legend__dot--base { background: #94a3b8; }
.analytics-pf-legend__dot--plan { background: #3b82f6; }
.analytics-pf-legend__dot--actual { background: #10b981; }
.analytics-pf-bars {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.35rem;
}
.analytics-pf-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.analytics-pf-bar span {
display: block;
height: 8px;
border-radius: 4px;
min-width: 2px;
}
.analytics-pf-bar--base span { background: #94a3b8; }
.analytics-pf-bar--plan span { background: #3b82f6; }
.analytics-pf-bar--actual span { background: #10b981; }
.analytics-pf-bar em {
font-style: normal;
min-width: 3rem;
text-align: right;
color: var(--text-muted);
}
.analytics-top-row__value {
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
text-align: right;
justify-self: end;
}
.analytics-top-row__value--loss {
color: #dc3545;
}
.analytics-top-row__value--risk {
color: #d97706;
}
.analytics-top-row__bar--loss span {
background: #dc3545;
}
.analytics-top-row__bar--risk span {
background: #d97706;
}
.analytics-pf-comp--excluded {
opacity: 0.82;
}
.analytics-pf-comp--excluded .analytics-pf-comp__name {
color: #6b7280;
}
html[data-theme="dark"] .analytics-pf-comp--excluded .analytics-pf-comp__name {
color: #9ca3af;
}
.analytics-pf-comp__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.analytics-pf-fault {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 999px;
white-space: nowrap;
}
.analytics-pf-fault--execution {
color: #dc3545;
background: rgba(220, 53, 69, 0.1);
}
.analytics-pf-fault--zootech,
.analytics-pf-fault--excluded {
color: #4b5563;
background: rgba(107, 114, 128, 0.12);
}
.analytics-pf-fault--adjusted {
color: #2563eb;
background: rgba(37, 99, 235, 0.1);
}
html[data-theme="dark"] .analytics-pf-fault--zootech,
html[data-theme="dark"] .analytics-pf-fault--excluded {
color: #d1d5db;
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .analytics-pf-fault--adjusted {
color: #93c5fd;
background: rgba(147, 197, 253, 0.12);
}
.analytics-pf-note--execution {
color: #dc3545;
}
.analytics-pf-note--zootech {
color: #6b7280;
}
html[data-theme="dark"] .analytics-pf-note--zootech {
color: #d1d5db;
}
html[data-theme="dark"] .analytics-pf-note--execution {
color: #f87171;
}
.analytics-pf-notes {
margin: 0.25rem 0 0;
padding-left: 1.1rem;
font-size: 0.8125rem;
list-style: disc;
}
.stock-forecast-banner {
margin: 0 0 1rem;
padding: 0.65rem 0.875rem;
border-radius: 0.75rem;
border: 1px solid rgba(220, 53, 69, 0.35);
background: rgba(220, 53, 69, 0.08);
font-size: 0.875rem;
}
.info-item--warn .info-value {
color: #dc3545;
font-weight: 700;
}
.stock-card-explanation {
grid-column: 1 / -1;
margin-top: 0.25rem;
}
/* Оборудование: отступ от navbar как на /recipes (только margin-bottom у navbar) */
.zootech-app-body.feed-dispensers-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.components-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.reports-page .page-content > .container {
padding-top: 0;
}
.zootech-app-body.feed-dispensers-page .add-devices-container {
margin-bottom: 1rem;
}
/* Унифицированный чекбокс (фильтры /reports, настройки и др.) */
.zt-checkbox {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.zt-checkbox__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.zt-checkbox__box {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1.5px solid var(--border-light, #cbd5e1);
border-radius: 4px;
background: var(--surface, var(--bg-white, #fff));
color: #fff;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
pointer-events: none;
}
.zt-checkbox__box::after {
content: "";
display: block;
width: 0.28rem;
height: 0.5rem;
border: solid currentColor;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
margin-top: -0.12rem;
transition: transform 0.12s ease;
}
.zt-checkbox__input:checked + .zt-checkbox__box {
background: var(--primary-color, #2196f3);
border-color: var(--primary-color, #2196f3);
}
.zt-checkbox__input:checked + .zt-checkbox__box::after {
transform: rotate(45deg) scale(1);
}
.zt-checkbox__input:focus-visible + .zt-checkbox__box {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 33, 150, 243), 0.22);
}
.zt-checkbox__input:disabled + .zt-checkbox__box {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="dark"] .zt-checkbox__box {
background: var(--dm-field, #2c3338);
border-color: rgba(255, 255, 255, 0.22);
}
html[data-theme="dark"] .zt-checkbox__input:checked + .zt-checkbox__box {
background: var(--primary-color);
border-color: var(--primary-color);
}
@@ -0,0 +1,658 @@
/**
* Bootstrap shell-модалки + настройки (стиль WespDialog).
* Без Bootstrap .fade — анимация через .wesp-shell-visible / .wesp-shell-closing (JS).
*/
:root {
--wesp-modal-duration: 0.22s;
--wesp-modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
/* Backdrop (Bootstrap создаёт .modal-backdrop без fade, если у modal нет .fade) */
body.modal-open .modal-backdrop.show {
opacity: 0.55;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal.wesp-shell-modal {
--bs-modal-bg: transparent;
}
.modal.wesp-shell-modal.show {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--bs-modal-margin, 0.5rem);
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell {
max-width: 420px;
width: 100%;
margin: 0 auto;
min-height: 0 !important;
height: auto;
flex: 0 0 auto;
display: block;
pointer-events: none;
opacity: 0;
transform: scale(0.97) translateY(8px);
transition:
transform var(--wesp-modal-duration) var(--wesp-modal-ease),
opacity var(--wesp-modal-duration) var(--wesp-modal-ease);
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-wide {
max-width: min(520px, calc(100vw - 1rem));
}
.modal.wesp-shell-modal.wesp-shell-visible .modal-dialog.modal-dialog--shell {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.modal.wesp-shell-modal.wesp-shell-closing .modal-dialog.modal-dialog--shell {
opacity: 0;
transform: scale(0.97) translateY(8px);
pointer-events: none;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
pointer-events: auto;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
width: 100%;
max-width: 100%;
height: auto;
max-height: min(90vh, 560px);
overflow-x: hidden;
overflow-y: auto;
padding: 20px 22px;
font-family: var(--zootech-font, Inter, system-ui, sans-serif);
font-size: var(--zt-font-body, 0.9375rem);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-align: left;
background: #fff;
color: #111827;
border-radius: 8px;
border: 1px solid #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18) !important;
}
.modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal--settings {
max-height: min(92vh, 720px);
}
.wesp-shell-bsmodal-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 12px;
}
.wesp-shell-bsmodal-title-row .wesp-shell-bsmodal-title {
margin: 0;
flex: 1;
min-width: 0;
}
.wesp-shell-bsmodal-close {
flex-shrink: 0;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: transparent;
color: #64748b;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease, color 0.15s ease;
}
.wesp-shell-bsmodal-close:hover {
background: rgba(15, 23, 42, 0.06);
color: #111827;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
line-height: 1.3;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-body {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: #374151;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-label {
color: #374151;
font-weight: 500;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn {
min-width: 120px;
padding: 10px 18px;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
border: 1px solid #e5e7eb;
background: #fff;
color: #374151;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, filter 0.2s;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn:hover {
filter: brightness(0.97);
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary {
background: var(--primary-color, #2196f3);
border-color: var(--primary-color, #2196f3);
color: #fff;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary:hover {
filter: none;
box-shadow: 0 4px 12px rgba(var(--primary-rgb, 33, 150, 243), 0.25);
}
/* Внутренние блоки модалки настроек */
.wesp-shell-bsmodal-body--settings .settings-user-card,
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-card,
.wesp-shell-bsmodal-body--settings .settings-sync-form,
.wesp-shell-bsmodal-body--settings .settings-theme-card {
background: var(--surface-muted, #f8fafc);
border-radius: 8px;
padding: 0.875rem 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e5e7eb);
}
.wesp-shell-bsmodal-body--settings .settings-theme-card {
min-width: 0;
}
.wesp-shell-bsmodal-body--settings .settings-card-title {
font-size: 0.9rem;
font-weight: 600;
margin: 0;
color: var(--text, #1e293b);
display: flex;
align-items: center;
gap: 0.5rem;
}
.wesp-shell-bsmodal-body--settings .settings-user-card--clickable,
.wesp-shell-bsmodal-body--settings .settings-sync-card--clickable {
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.wesp-shell-bsmodal-body--settings .settings-user-card--clickable:hover,
.wesp-shell-bsmodal-body--settings .settings-sync-card--clickable:hover {
border-color: var(--primary-color, #2196f3);
background: rgba(var(--primary-rgb, 33, 150, 243), 0.04);
}
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-form {
animation: wesp-settings-panel-in 0.22s var(--wesp-modal-ease) both;
}
@keyframes wesp-settings-panel-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 576px) {
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions {
flex-direction: column;
}
.modal.wesp-shell-modal .wesp-shell-bsmodal-actions .wesp-shell-bsmodal-btn {
width: 100%;
}
}
html[data-theme="dark"] .modal.wesp-shell-modal .modal-content.wesp-shell-bsmodal {
background: var(--dm-surface, #2c3338) !important;
color: var(--dm-text, #f5f5f5) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-title,
html[data-theme="dark"] .wesp-shell-bsmodal-close:hover {
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .wesp-shell-bsmodal-close {
color: var(--dm-muted, #a8b0b8);
}
html[data-theme="dark"] .wesp-shell-bsmodal-close:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body,
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body p,
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body strong {
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-label {
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .text-muted {
color: var(--dm-muted, #a8b0b8) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-body .form-control {
background-color: var(--dm-field, #3e444a) !important;
color: var(--dm-text, #f5f5f5) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
}
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-user-card,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-credentials-form,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-sync-card,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-sync-form,
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-theme-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .wesp-shell-bsmodal-body--settings .settings-card-title {
color: var(--dm-text, #f5f5f5);
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-btn {
background: var(--dm-field, #3e444a) !important;
border-color: var(--dm-border, rgba(255, 255, 255, 0.12)) !important;
color: var(--dm-text, #f5f5f5) !important;
}
html[data-theme="dark"] .modal.wesp-shell-modal .wesp-shell-bsmodal-btn-primary {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #fff !important;
}
/* Появление карточек сетки */
@keyframes wesp-grid-item-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
.components-page .component-card.wesp-grid-enter-item,
.reports-page .report-card.wesp-grid-enter-item {
animation: wesp-grid-item-enter var(--wesp-enter-duration, 350ms) var(--wesp-enter-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
animation-delay: calc(var(--wesp-grid-i, 0) * 45ms);
}
html.wesp-page-enter-pending .feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
html.wesp-page-enter-pending .components-page .component-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .components-page .component-card.wesp-grid-enter-item,
html.wesp-page-enter-pending .reports-page .report-card.wesp-grid-enter-item,
html.wesp-page-enter-ready .reports-page .report-card.wesp-grid-enter-item {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.feed-dispensers-page .empty-state.wesp-content-reveal,
.feed-dispensers-page .loading-state.wesp-content-reveal,
.components-page .empty-state.wesp-content-reveal,
.components-page .loading-state.wesp-content-reveal,
.reports-page .empty-state.wesp-content-reveal,
.reports-page .loading-state.wesp-content-reveal {
animation: wesp-enter-fade var(--wesp-enter-duration-fast, 350ms) var(--wesp-enter-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
}
@media (prefers-reduced-motion: reduce) {
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell {
transition: opacity 0.12s ease !important;
transform: none !important;
}
body.modal-open .modal-backdrop.show {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.feed-dispensers-page .dispenser-card.wesp-grid-enter-item,
.components-page .component-card.wesp-grid-enter-item,
.feed-dispensers-page .empty-state.wesp-content-reveal,
.feed-dispensers-page .loading-state.wesp-content-reveal,
.components-page .empty-state.wesp-content-reveal,
.components-page .loading-state.wesp-content-reveal,
.reports-page .report-card.wesp-grid-enter-item,
.reports-page .empty-state.wesp-content-reveal,
.reports-page .loading-state.wesp-content-reveal,
.wesp-shell-bsmodal-body--settings .settings-credentials-form,
.wesp-shell-bsmodal-body--settings .settings-sync-form {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/* —— Notification center modal —— */
.zt-notify-center__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.zt-notify-center__title {
margin: 0;
}
.zt-notify-center__read-all {
border: none;
background: transparent;
color: var(--primary-color, #2196f3);
font-size: 0.8125rem;
font-weight: 600;
padding: 0.25rem 0.35rem;
cursor: pointer;
}
.zt-notify-center__body {
min-height: 200px;
max-height: min(52vh, 380px);
overflow: auto;
}
.zt-notify-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.zt-notify-toolbar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.zt-notify-toolbar__filters {
margin-bottom: 0;
flex: 1;
min-width: 0;
}
.zt-notify-toolbar__filters .zt-tab {
padding: 0.35rem 0.55rem;
font-size: 0.75rem;
min-height: auto;
}
.zt-notify-toolbar__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
flex-wrap: wrap;
}
.zt-notify-toolbar__unread {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
margin: 0;
cursor: pointer;
user-select: none;
}
.zt-notify-toolbar__unread input {
margin: 0;
}
.zt-notify-load-more {
padding: 0.5rem 0 0.25rem;
text-align: center;
}
.zt-notify-load-more__hint {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted, #64748b);
}
.zt-notify-load-more__spinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted, #64748b);
}
.zt-notify-empty--loading .zt-spinner {
margin-bottom: 0.5rem;
}
.zt-notify-toolbar__sort {
flex-shrink: 0;
margin: 0;
}
.zt-notify-toolbar__sort select {
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 8px;
background: var(--bg-light, #f8fafc);
color: var(--text-dark, #1e293b);
font-size: 0.75rem;
font-weight: 500;
padding: 0.35rem 1.75rem 0.35rem 0.55rem;
cursor: pointer;
max-width: 11rem;
}
.zt-notify-group {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #64748b);
padding: 0.35rem 0.15rem 0.15rem;
}
.zt-notify-group:not(:first-child) {
margin-top: 0.35rem;
}
.zt-notify-item__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.zt-notify-item__category {
font-size: 0.6875rem;
color: var(--text-muted, #64748b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zt-notify-empty {
text-align: center;
color: var(--text-muted, #64748b);
padding: 2.5rem 1rem;
}
.zt-notify-empty i {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.45;
}
.zt-notify-item {
display: flex;
align-items: flex-start;
gap: 0.65rem;
width: 100%;
text-align: left;
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 10px;
background: var(--bg-light, #f8fafc);
padding: 0.65rem 0.75rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.zt-notify-item:hover {
background: #fff;
border-color: rgba(var(--primary-rgb), 0.25);
}
.zt-notify-item--unread {
background: #fff;
border-color: rgba(var(--primary-rgb), 0.35);
}
.zt-notify-item__dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-top: 0.45rem;
background: var(--info-color, #0ea5e9);
}
.zt-notify-item__dot--success {
background: var(--success-color, #10b981);
}
.zt-notify-item__dot--error {
background: var(--danger-color, #ef4444);
}
.zt-notify-item__dot--warning {
background: var(--warning-color, #f59e0b);
}
.zt-notify-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.zt-notify-item__title {
font-size: 0.9375rem;
color: var(--text-dark, #1e293b);
line-height: 1.35;
}
.zt-notify-item--unread .zt-notify-item__title {
font-weight: 600;
}
.zt-notify-item__preview {
font-size: 0.8125rem;
line-height: 1.45;
color: var(--text-muted, #64748b);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.zt-notify-item__time {
font-size: 0.75rem;
color: var(--text-muted, #64748b);
flex-shrink: 0;
}
.zt-notify-detail__back {
border: none;
background: transparent;
color: var(--primary-color, #2196f3);
font-size: 0.875rem;
padding: 0;
margin-bottom: 0.75rem;
cursor: pointer;
}
.zt-notify-detail__kind {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.zt-notify-detail__text {
margin: 0 0 0.75rem;
line-height: 1.55;
color: var(--text-dark, #1e293b);
}
.zt-notify-detail__when {
margin: 0;
font-size: 0.8125rem;
color: var(--text-muted, #64748b);
}
html[data-theme="dark"] .zt-notify-item {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] .zt-notify-item--unread,
html[data-theme="dark"] .zt-notify-item:hover {
background: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .zt-notify-toolbar__sort select {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
+644
View File
@@ -0,0 +1,644 @@
/**
* Общая оболочка зоотех-страниц: тёмная палитра как у страницы входа,
* футер, модалка настроек, базовые тёмные стили Bootstrap.
* Подключать после bootstrap.min.css.
* Токены и навбар — wesp-zootech-layout.css (подключать после этого файла).
*/
/* Светлая тема: единый фон страницы */
body {
background-color: var(--bg-page, #f5f7fa);
}
/* Клик по логотипу в шапке → страница рецептов */
.nav-brand .nav-logo-link,
.mobile-brand .nav-logo-link {
display: flex;
align-items: center;
line-height: 0;
text-decoration: none;
color: inherit;
}
html[data-theme="dark"] {
--wesp-accent: var(--primary-color, #2196f3);
--dm-bg: var(--bg-page, #12181b);
--dm-surface: var(--surface, #2c3338);
--dm-field: var(--field, #3e444a);
--dm-field-disabled: #353a40;
--dm-field-hover: #4a525a;
--dm-text: var(--text, #f5f5f5);
--dm-muted: var(--muted, #a8b0b8);
--dm-border: var(--border-light, rgba(255, 255, 255, 0.12));
--dm-border-hover: rgba(255, 255, 255, 0.25);
--locked-field-bg: var(--dm-field-disabled);
--locked-field-border: rgba(255, 255, 255, 0.22);
--locked-field-text: var(--dm-text);
--locked-field-stripe: rgba(255, 255, 255, 0.07);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
html[data-theme="dark"] body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .app-footer {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .app-footer .text-muted {
color: var(--dm-muted) !important;
}
/* Bootstrap-модалки и формы в тёмной теме (не shell/settings — у них свои поверхности) */
html[data-theme="dark"] body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-content,
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-body {
background-color: var(--dm-bg) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal:not(.wesp-shell-modal) .modal-content {
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .modal-header,
html[data-theme="dark"] .modal-footer {
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .modal-footer {
background-color: var(--dm-surface) !important;
}
html[data-theme="dark"] .form-control,
html[data-theme="dark"] input:not([readonly]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea {
background-color: var(--dm-field) !important;
color: var(--dm-text) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .form-control[readonly] {
background-color: var(--dm-field-disabled) !important;
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .text-muted,
html[data-theme="dark"] .modal-body label {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] ::placeholder {
color: rgba(245, 245, 245, 0.65) !important;
}
html[data-theme="dark"] .btn {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .btn-secondary:hover {
background-color: var(--dm-field-hover) !important;
border-color: var(--dm-border-hover) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .modal-footer .btn-secondary:hover {
background-color: var(--dm-field-hover) !important;
border-color: var(--dm-border-hover) !important;
}
html[data-theme="dark"] .btn-primary,
html[data-theme="dark"] .modal-footer .btn-primary {
background: linear-gradient(
135deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
) !important;
border: none !important;
color: white !important;
}
html[data-theme="dark"] .btn-primary:hover,
html[data-theme="dark"] .modal-footer .btn-primary:hover {
background: linear-gradient(
135deg,
var(--primary-dark, #1976d2),
var(--primary-darker, #1565c0)
) !important;
}
/* Модалка настроек */
.settings-modal {
display: none;
position: fixed;
z-index: 3000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.settings-modal-content {
background-color: #fff;
margin: 3vh auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 520px;
max-height: min(92vh, 720px);
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.16), 0 0 0 1px rgba(17, 24, 39, 0.06);
overflow: hidden;
font-family: var(--zootech-font);
font-size: var(--zt-font-body, 0.9375rem);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
}
.settings-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light, #e2e8f0);
background: var(--surface, #ffffff);
flex-shrink: 0;
}
.settings-modal-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--text, #1e293b);
}
.settings-modal-header h3 i {
margin-right: 0.5rem;
color: var(--primary-color, #2196f3);
}
.settings-close {
cursor: pointer;
font-size: 1.8rem;
line-height: 1;
opacity: 0.7;
user-select: none;
color: var(--text, #1e293b);
}
.settings-close:hover {
opacity: 1;
}
.settings-modal-body {
padding: 1rem 1.25rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.settings-field-hint {
margin: 0 0 0.75rem;
line-height: 1.45;
}
.settings-modal-body .form-group {
margin-bottom: 0.75rem;
}
.settings-user-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e2e8f0);
}
.settings-user-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-user-card--clickable:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-user-card--clickable .settings-card-title {
margin: 0;
justify-content: space-between;
}
.settings-chevron {
transition: transform 0.3s ease;
font-size: 0.8rem;
color: var(--muted, #64748b);
}
.settings-user-card--clickable.open .settings-chevron {
transform: rotate(180deg);
}
.settings-credentials-form {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
animation: wespSlideDown 0.3s ease;
}
@keyframes wespSlideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--text, #1e293b);
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-card-title i {
color: var(--primary-color, #2196f3);
}
.settings-theme-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
min-width: 380px;
}
.settings-theme-card .settings-card-title {
margin: 0 0 0.75rem 0;
justify-content: center;
position: relative;
}
.settings-theme-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.theme-toggle-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.theme-label {
font-size: 0.9rem;
color: var(--muted, #64748b);
}
.theme-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
flex-shrink: 0;
}
.theme-switch input {
opacity: 0;
width: 0;
height: 0;
}
.theme-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #e2e8f0;
border-radius: 26px;
transition: 0.3s;
}
.theme-slider::before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.theme-switch input:checked + .theme-slider {
background: var(--primary-color, #2196f3);
}
.theme-switch input:checked + .theme-slider::before {
transform: translateX(22px);
}
.settings-contrast-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light, #e2e8f0);
}
.settings-contrast-row__label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text, #1e293b);
}
.settings-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-light, #e2e8f0);
flex-shrink: 0;
background: var(--surface, #ffffff);
}
.settings-sync-card {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-light, #e2e8f0);
}
.settings-sync-card.settings-sync-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-sync-card.settings-sync-card--clickable:hover {
background: rgba(var(--primary-rgb, 33, 150, 243), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-sync-card.settings-sync-card--clickable.open .settings-chevron {
transform: rotate(180deg);
}
.settings-sync-card .settings-card-title {
margin: 0;
justify-content: center;
position: relative;
}
.settings-sync-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.settings-sync-card .settings-card-title .settings-chevron {
position: absolute;
right: 0;
}
.settings-sync-form {
background: var(--surface-muted, #f8fafc);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
border: 1px solid var(--border-light, #e2e8f0);
animation: wespSlideDown 0.3s ease;
}
.settings-sync-client-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-light, #e2e8f0);
}
.settings-sync-client-item:last-child {
border-bottom: none;
}
.settings-sync-client-name {
font-weight: 500;
color: var(--text, #1e293b);
}
.settings-sync-client-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.settings-sync-client-actions .btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.settings-sync-edit-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.settings-sync-edit-row select {
min-width: 140px;
}
/* Тёмная тема: модалка настроек и карточки */
html[data-theme="dark"] .settings-user-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--dm-border-hover);
}
html[data-theme="dark"] .settings-credentials-form {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-modal-content {
background-color: var(--dm-surface) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-header {
background: var(--dm-field) !important;
border-color: var(--dm-border) !important;
}
html[data-theme="dark"] .settings-modal-header h3 {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-header h3 i {
color: var(--primary-color, #2196f3) !important;
}
html[data-theme="dark"] .settings-card-title i {
color: var(--primary-color, #2196f3);
}
html[data-theme="dark"] .settings-close {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-sync-client-name {
color: #fff !important;
}
html[data-theme="dark"] .settings-sync-card,
html[data-theme="dark"] .settings-sync-form {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-sync-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
html[data-theme="dark"] .settings-user-card,
html[data-theme="dark"] .settings-theme-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border);
}
html[data-theme="dark"] .settings-card-title {
color: var(--dm-text);
}
html[data-theme="dark"] .settings-modal-body label,
html[data-theme="dark"] .settings-modal-body .form-label,
html[data-theme="dark"] .settings-credentials-form label,
html[data-theme="dark"] .settings-sync-form label {
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-body .text-muted,
html[data-theme="dark"] .settings-chevron {
color: var(--dm-muted) !important;
}
html[data-theme="dark"] .theme-label {
color: var(--dm-muted);
}
html[data-theme="dark"] .theme-switch input:checked + .theme-slider {
background: var(--primary-color, #2196f3) !important;
}
html[data-theme="dark"] .settings-modal-footer {
border-color: var(--dm-border);
background: var(--dm-field);
}
html[data-theme="dark"] .settings-modal-footer .btn-secondary {
background-color: var(--dm-field) !important;
border-color: var(--dm-border) !important;
color: var(--dm-text) !important;
}
html[data-theme="dark"] .settings-modal-footer .btn-primary {
background: linear-gradient(
135deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
) !important;
border: none !important;
color: #fff !important;
}
html[data-theme="dark"] .settings-modal-footer .btn-primary:hover {
background: linear-gradient(
135deg,
var(--primary-dark, #1976d2),
var(--primary-darker, #1565c0)
) !important;
filter: brightness(1.05);
}
/* Верхняя панель и мобильное меню: в тёмной теме без яркого синего (как фон/карточки) */
html[data-theme="dark"] .mobile-navbar {
background: linear-gradient(180deg, #2c3338 0%, #1e2529 100%) !important;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
html[data-theme="dark"] .mobile-menu {
background: linear-gradient(180deg, #2c3338 0%, #252b30 100%) !important;
box-shadow: -4px 0 28px rgba(0, 0, 0, 0.55) !important;
}
#wesp-recipes-boot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 900;
background: #f5f7fa;
pointer-events: none;
opacity: 1;
transition: opacity 0.28s ease;
}
html[data-theme="dark"] #wesp-recipes-boot {
background: #12181b;
}
#wesp-recipes-boot.wesp-recipes-boot--hide {
opacity: 0;
}
@@ -0,0 +1,335 @@
/**
* Organic Tech / Earthy Digital третья тема зоотехника.
* Подключать ПОСЛЕДНИМ в <head> (после page-inline стилей).
*/
/* —— Theme picker (3-way) —— */
.theme-picker {
display: flex;
align-items: stretch;
gap: 0.35rem;
flex-wrap: wrap;
justify-content: center;
}
.theme-picker__btn {
flex: 1 1 auto;
min-width: 5.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 40px;
background: var(--surface);
color: var(--text);
font-family: var(--zootech-font);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
}
.theme-picker__btn:hover {
border-color: var(--primary-color);
}
.theme-picker__btn.is-active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.22);
}
html[data-theme="dark"] .theme-picker__btn.is-active {
color: #fff;
}
/* —— Navbar glassmorphism —— */
html[data-theme="organic"] .mobile-navbar {
background: rgba(246, 246, 244, 0.9) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
will-change: backdrop-filter;
}
@media (max-width: 768px) {
html[data-theme="organic"] .mobile-navbar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* —— Logo (дублируем с !important поверх page-inline) —— */
html[data-theme="organic"] .nav-brand .logo.logo--light-theme,
html[data-theme="organic"] .mobile-brand .logo.logo--light-theme {
filter: var(--organic-logo-filter) !important;
}
html[data-theme="organic"] .nav-logo-link::after {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 30%,
rgba(72, 129, 109, 0.35) 50%,
rgba(255, 255, 255, 0) 70%,
rgba(255, 255, 255, 0) 100%
);
}
/* —— Cards & sections —— */
html[data-theme="organic"] .zt-card:hover,
html[data-theme="organic"] .zt-section:hover {
box-shadow: var(--box-shadow-hover);
}
html[data-theme="organic"] .zt-card,
html[data-theme="organic"] .zt-section {
transition: box-shadow 0.2s ease, border-color var(--transition);
}
/* —— Monospace accents —— */
html[data-theme="organic"] .zt-mono,
html[data-theme="organic"] code,
html[data-theme="organic"] .lab-sandbox-step span,
html[data-theme="organic"] .norm-calc-badge {
font-family: var(--zootech-font-mono);
}
/* —— Primary buttons (pill) —— */
html[data-theme="organic"] .zootech-app-body .btn-primary,
html[data-theme="organic"] .btn-primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
border-radius: 40px;
}
html[data-theme="organic"] .zootech-app-body .btn-primary:hover:not(:disabled),
html[data-theme="organic"] .btn-primary:hover:not(:disabled) {
background-color: var(--primary-dark) !important;
border-color: var(--primary-dark) !important;
}
html[data-theme="organic"] .btn-outline-primary,
html[data-theme="organic"] .btn-outline-secondary {
border-radius: 6px;
}
/* —— Settings cards —— */
html[data-theme="organic"] .settings-theme-card,
html[data-theme="organic"] .settings-user-card,
html[data-theme="organic"] .settings-sync-card {
background: var(--surface-muted);
border-color: var(--border-light);
}
html[data-theme="organic"] .settings-card-title {
color: var(--text);
}
html[data-theme="organic"] .settings-user-card--clickable:hover,
html[data-theme="organic"] .settings-sync-card--clickable:hover,
html[data-theme="organic"] .wesp-shell-bsmodal-body--settings .settings-user-card--clickable:hover,
html[data-theme="organic"] .wesp-shell-bsmodal-body--settings .settings-sync-card--clickable:hover {
border-color: var(--primary-color);
background: rgba(var(--primary-rgb), 0.06);
}
/* —— Login —— */
html[data-theme="organic"] body.zootech-login-body {
background-color: var(--z-login-bg, #f6f6f4);
}
/* —— Bootstrap tokens & ссылки —— */
html[data-theme="organic"] {
--bs-primary: #48816d;
--bs-primary-rgb: 72, 129, 109;
--bs-link-color: #48816d;
--bs-link-hover-color: #3a6b58;
--bs-success: #48816d;
--bs-success-rgb: 72, 129, 109;
}
html[data-theme="organic"] .text-primary,
html[data-theme="organic"] .text-primary i,
html[data-theme="organic"] i.text-primary {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .badge.bg-primary {
background-color: var(--primary-color) !important;
color: #fff !important;
}
html[data-theme="organic"] .btn-outline-primary {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="organic"] .btn-outline-primary:hover:not(:disabled) {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #fff !important;
}
html[data-theme="organic"] .zootech-app-body .btn-outline-success,
html[data-theme="organic"] .paste-recipe-btn.btn-outline-success {
color: var(--primary-color) !important;
border-color: rgba(var(--primary-rgb), 0.45) !important;
}
html[data-theme="organic"] .zootech-app-body .btn-outline-success:hover:not(:disabled),
html[data-theme="organic"] .paste-recipe-btn.btn-outline-success:hover:not(:disabled) {
color: var(--primary-darker) !important;
background: rgba(var(--primary-rgb), 0.1) !important;
border-color: var(--primary-color) !important;
}
html[data-theme="organic"] .btn-link {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
html[data-theme="organic"] .form-control:focus,
html[data-theme="organic"] .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.2);
}
/* —— Дашборд рецептов: иконки в строках (календарь и др.) —— */
html[data-theme="organic"] .dashboard-stat-card > .card-header i,
html[data-theme="organic"] .dashboard-stat-card > .card-header .route-icon,
html[data-theme="organic"] .dashboard-summary-row .list-item-title i,
html[data-theme="organic"] .dashboard-summary-row .list-item-title i.fas,
html[data-theme="organic"] .dashboard-summary-row .list-item-leading i {
color: var(--primary-color) !important;
}
html[data-theme="organic"] .dashboard-summary-row .list-container .list-item.active {
border-color: var(--primary-color) !important;
background-color: rgba(var(--primary-rgb), 0.08) !important;
}
html[data-theme="organic"] .dashboard-summary-row .list-container .list-item:hover {
border-color: var(--primary-color) !important;
background-color: rgba(var(--primary-rgb), 0.05) !important;
}
/* —— Иконки по всему UI —— */
html[data-theme="organic"] .mobile-navbar i.fas:not(.fa-sign-out-alt),
html[data-theme="organic"] .mobile-navbar i.far,
html[data-theme="organic"] .zt-section__title > i,
html[data-theme="organic"] .zt-empty__icon,
html[data-theme="organic"] .page-title > i,
html[data-theme="organic"] .page-header i.fas,
html[data-theme="organic"] .settings-card-title > i:first-child,
html[data-theme="organic"] .lab-sandbox-header .text-primary,
html[data-theme="organic"] .lab-sandbox-header i.fas,
html[data-theme="organic"] .recipe-table-move-group .btn i,
html[data-theme="organic"] .recipe-drag-handle i,
html[data-theme="organic"] .zt-k-hub-card__icon,
html[data-theme="organic"] .zt-k-hub-card__icon i,
html[data-theme="organic"] .info-label i.fas,
html[data-theme="organic"] .dispenser-card .list-item-title i,
html[data-theme="organic"] .alert-info i.fas {
color: var(--primary-color) !important;
}
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn {
color: var(--primary-color) !important;
}
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn:hover,
html[data-theme="organic"] #recipeEdit .recipe-help-icon-btn:focus {
color: var(--primary-dark) !important;
background: rgba(var(--primary-rgb), 0.14) !important;
}
html[data-theme="organic"] .z-login-field-icon svg {
stroke: var(--primary-color);
}
html[data-theme="organic"] .zootech-app-body .magic-button,
html[data-theme="organic"] .zootech-app-body .btn-primary-modern {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important;
}
html[data-theme="organic"] .zt-spinner {
border-top-color: var(--primary-color) !important;
}
@media (prefers-contrast: more) {
html:not([data-high-contrast="false"])[data-theme="organic"] .mobile-navbar,
html[data-high-contrast="true"][data-theme="organic"] .mobile-navbar {
background: var(--bg-white) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border-bottom: 1px solid var(--border-light);
}
html:not([data-high-contrast="false"])[data-theme="organic"] .zt-card,
html:not([data-high-contrast="false"])[data-theme="organic"] .zt-section,
html[data-high-contrast="true"][data-theme="organic"] .zt-card,
html[data-high-contrast="true"][data-theme="organic"] .zt-section {
border-color: var(--border-light);
box-shadow: none;
}
html:not([data-high-contrast="false"]) .form-control:focus,
html:not([data-high-contrast="false"]) .form-select:focus,
html[data-high-contrast="true"] .form-control:focus,
html[data-high-contrast="true"] .form-select:focus {
box-shadow: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--primary-color);
}
html:not([data-high-contrast="false"])[data-theme="organic"] .btn-outline-primary,
html:not([data-high-contrast="false"])[data-theme="organic"] .zootech-app-body .btn-outline-success,
html:not([data-high-contrast="false"])[data-theme="organic"] .paste-recipe-btn.btn-outline-success,
html[data-high-contrast="true"][data-theme="organic"] .btn-outline-primary,
html[data-high-contrast="true"][data-theme="organic"] .zootech-app-body .btn-outline-success,
html[data-high-contrast="true"][data-theme="organic"] .paste-recipe-btn.btn-outline-success {
border-width: 2px;
}
html:not([data-high-contrast="false"])[data-theme="organic"] .dashboard-summary-row .list-container .list-item.active,
html[data-high-contrast="true"] .dashboard-summary-row .list-container .list-item.active {
border-left-width: 3px;
border-left-style: solid;
border-left-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.12) !important;
}
}
html[data-high-contrast="true"][data-theme="organic"] .mobile-navbar {
background: var(--bg-white) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border-bottom: 1px solid var(--border-light);
}
html[data-high-contrast="true"] .zt-card,
html[data-high-contrast="true"] .zt-section {
border-color: var(--border-light);
box-shadow: none;
}
html[data-high-contrast="true"] .form-control:focus,
html[data-high-contrast="true"] .form-select:focus {
box-shadow: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--primary-color);
}
html[data-high-contrast="true"] .btn-outline-primary,
html[data-high-contrast="true"] .zootech-app-body .btn-outline-success,
html[data-high-contrast="true"] .paste-recipe-btn.btn-outline-success {
border-width: 2px;
}
html[data-high-contrast="true"] .dashboard-summary-row .list-container .list-item.active {
border-left-width: 3px;
border-left-style: solid;
border-left-color: var(--primary-color);
background-color: rgba(var(--primary-rgb), 0.12) !important;
}
+345
View File
@@ -0,0 +1,345 @@
/* Страница входа зоотехника — тёмная тема по референсу, светлая — отдельная палитра */
:root {
--z-login-bg: var(--bg-page, #f5f7fa);
--z-login-surface: var(--surface, #ffffff);
--z-login-input-bg: #e8edf2;
--z-login-text: var(--text, #1e293b);
--z-login-muted: var(--muted, #64748b);
--z-login-icon: #4b5563;
--z-login-btn: var(--primary-color, #2196f3);
--z-login-btn-hover: var(--primary-dark, #1976d2);
--z-login-shadow: 0 16px 48px rgba(17, 24, 39, 0.1),
0 0 0 1px rgba(17, 24, 39, 0.06);
--z-login-switch-track: #cbd5e1;
--z-login-switch-thumb: #ffffff;
}
html[data-theme="dark"] {
--z-login-bg: var(--bg-page, #12181b);
--z-login-surface: var(--surface, #2c3338);
--z-login-input-bg: var(--field, #3e444a);
--z-login-text: var(--text, #f5f5f5);
--z-login-muted: var(--muted, #a8b0b8);
--z-login-icon: #ffffff;
--z-login-btn: var(--primary-color, #2196f3);
--z-login-btn-hover: var(--primary-light, #42a5f5);
--z-login-shadow: 0 20px 56px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.04);
--z-login-switch-track: #252b30;
--z-login-switch-thumb: #ffffff;
}
html[data-theme="organic"] {
--z-login-bg: var(--bg-page, #f6f6f4);
--z-login-surface: var(--surface, #ffffff);
--z-login-input-bg: #eff4f2;
--z-login-text: var(--text, #1a1e1c);
--z-login-muted: var(--muted, #4a5a52);
--z-login-icon: #4a5a52;
--z-login-btn: var(--primary-color, #48816d);
--z-login-btn-hover: var(--primary-dark, #3a6b58);
--z-login-shadow: 0 16px 48px rgba(26, 30, 28, 0.08),
0 0 0 1px rgba(26, 30, 28, 0.04);
--z-login-switch-track: #e8e8e2;
--z-login-switch-thumb: #ffffff;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body.zootech-login-body {
margin: 0;
min-height: 100vh;
font-family: var(--zootech-font, "Inter", system-ui, sans-serif);
background: var(--z-login-bg);
color: var(--z-login-text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.z-login-page {
width: 100%;
max-width: 420px;
transition: opacity 0.25s ease, visibility 0.25s;
}
.z-login-page--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.z-login-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--z-login-bg);
}
.z-login-loader {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
height: min(240px, 36vh);
border-radius: 16px;
overflow: hidden;
background: transparent;
box-shadow: none;
}
.z-login-loader .wesp-logo-loader,
.z-login-loader .wesp-logo-loader--embedded,
.z-login-loader .wesp-logo-loader--plain {
background: transparent !important;
}
.z-login-loader .wesp-logo-loader__label,
.z-login-loader .wesp-logo-loader__percent {
color: var(--z-login-muted);
}
.z-login-loader .wesp-logo-loader__bar {
background: rgba(26, 30, 28, 0.1);
}
html[data-theme="dark"] .z-login-loader .wesp-logo-loader__bar {
background: rgba(255, 255, 255, 0.12);
}
.z-login-card {
border-radius: 18px;
overflow: hidden;
box-shadow: var(--z-login-shadow);
background: var(--z-login-surface);
}
.z-login-form-panel {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
.z-login-title {
font-family: inherit;
font-size: 1.75rem;
font-weight: 700;
text-align: center;
color: var(--z-login-text);
margin: 0 0 28px;
letter-spacing: 0.02em;
}
.z-login-field {
margin-bottom: 16px;
}
.z-login-field--with-icon {
display: flex;
align-items: stretch;
gap: 0;
background: var(--z-login-input-bg);
border-radius: 8px;
overflow: hidden;
border: none;
}
.z-login-field-icon {
flex: 0 0 48px;
display: flex;
align-items: center;
justify-content: center;
color: var(--z-login-icon);
}
.z-login-field-icon svg {
display: block;
}
.z-login-input {
flex: 1;
min-width: 0;
padding: 14px 14px 14px 0;
font-size: 1rem;
font-family: inherit;
border: none;
background: transparent;
color: var(--z-login-text);
outline: none;
}
.z-login-input::placeholder {
color: var(--z-login-muted);
opacity: 1;
}
.z-login-input:focus {
outline: none;
}
.z-login-input:-webkit-autofill,
.z-login-input:-webkit-autofill:hover,
.z-login-input:-webkit-autofill:focus,
.z-login-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
-webkit-text-fill-color: var(--z-login-text) !important;
caret-color: var(--z-login-text);
transition: background-color 600000s ease-in-out 0s;
}
.z-login-input:autofill {
box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
-webkit-text-fill-color: var(--z-login-text) !important;
caret-color: var(--z-login-text);
}
.z-login-field--with-icon:focus-within {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb, 33, 150, 243), 0.45);
}
html[data-theme="dark"] .z-login-field--with-icon:focus-within {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb, 33, 150, 243), 0.5);
}
.z-login-submit {
width: 100%;
margin-top: 8px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
font-family: inherit;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
background: var(--gradient-blue, var(--z-login-btn));
box-shadow: 0 4px 14px rgba(var(--primary-rgb, 33, 150, 243), 0.35);
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.z-login-submit:hover:not(:disabled) {
background: var(--z-login-btn-hover);
transform: translateY(-1px);
}
.z-login-submit:disabled {
opacity: 0.65;
cursor: not-allowed;
transform: none;
}
.z-login-remember {
display: flex;
align-items: center;
gap: 12px;
margin-top: 22px;
cursor: pointer;
user-select: none;
}
.z-login-switch-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.z-login-switch-ui {
flex-shrink: 0;
width: 44px;
height: 26px;
border-radius: 13px;
background: var(--z-login-switch-track);
position: relative;
transition: background 0.2s ease;
}
.z-login-switch-ui::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--z-login-switch-thumb);
top: 2px;
left: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transition: transform 0.2s ease;
}
.z-login-switch-input:checked + .z-login-switch-ui::after {
transform: translateX(18px);
}
.z-login-switch-input:focus-visible + .z-login-switch-ui {
outline: 2px solid var(--z-login-btn);
outline-offset: 2px;
}
.z-login-switch-input:disabled + .z-login-switch-ui {
opacity: 0.5;
}
.z-login-remember-text {
font-family: inherit;
font-size: 0.95rem;
color: var(--z-login-text);
}
.z-login-msg {
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.9rem;
display: none;
}
.z-login-msg--error {
background: #dc3545;
color: #fff;
}
.z-login-spinner {
display: none;
width: 22px;
height: 22px;
margin: 0 auto;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: z-login-spin 0.85s linear infinite;
}
@keyframes z-login-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 520px) {
.z-login-loader {
height: min(200px, 32vh);
}
}
@media (max-width: 400px) {
.z-login-form-panel {
padding: 28px 22px 32px;
}
.z-login-title {
font-size: 1.5rem;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Комптон">
<text x="16" y="24.5" text-anchor="middle"
font-family="Arial, Helvetica, 'DejaVu Sans', sans-serif"
font-size="22" font-weight="700" fill="#ffffff">К</text>
</svg>

After

Width:  |  Height:  |  Size: 284 B

File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 640 512" fill="currentColor" aria-hidden="true" focusable="false" role="img">
<path d="M634 276.8l-9.999-13.88L624 185.7c0-11.88-12.5-19.49-23.12-14.11c-10.88 5.375-19.5 13.5-26.38 23l-65.75-90.92C490.6 78.71 461.8 64 431 64H112C63.37 64 24 103.4 24 152v86.38C9.5 250.1 0 267.9 0 288v32h8c35.38 0 64-28.62 64-64L72 152c0-16.88 10.5-31.12 25.38-37C96.5 119.1 96 123.5 96 128l.0002 304c0 8.875 7.126 16 16 16h63.1c8.875 0 16-7.125 16-16l.0006-112c9.375 9.375 20.25 16.5 32 21.88V368c0 8.875 7.252 16 16 16c8.875 0 15.1-7.125 15.1-16v-17.25c9.125 1 12.88 2.25 32-.125V368c0 8.875 7.25 16 16 16c8.875 0 16-7.125 16-16v-26.12C331.8 336.5 342.6 329.2 352 320l-.0012 112c0 8.875 7.125 16 15.1 16h64c8.75 0 16-7.125 16-16V256l31.1 32l.0006 41.55c0 12.62 3.752 24.95 10.75 35.45l41.25 62C540.8 440.1 555.5 448 571.4 448c22.5 0 41.88-15.88 46.25-38l21.75-108.6C641.1 292.8 639.1 283.9 634 276.8zM377.3 167.4l-22.88 22.75C332.5 211.8 302.9 224 272.1 224S211.5 211.8 189.6 190.1L166.8 167.4C151 151.8 164.4 128 188.9 128h166.2C379.6 128 393 151.8 377.3 167.4zM576 352c-8.875 0-16-7.125-16-16s7.125-16 16-16s16 7.125 16 16S584.9 352 576 352z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="5 2 14 20" fill="currentColor" aria-hidden="true" role="img">
<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>

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+401
View File
@@ -0,0 +1,401 @@
/**
* Модалка «Учётные документы» на /feed_consumption
* Сценарий: Реквизиты Подписи Экспорт
*/
(function () {
const ORG_FIELDS = [
{ id: "faOrgName", key: "organization_name", label: "Организация" },
{ id: "faOkpo", key: "okpo", label: "ОКПО" },
{ id: "faDepartment", key: "department", label: "Отделение (участок)" },
{ id: "faFarm", key: "farm_name", label: "Ферма" },
{ id: "faBrigade", key: "brigade", label: "Бригада" },
{ id: "faAnimalType", key: "animal_type", label: "Вид / группа скота" },
{ id: "faResponsible", key: "responsible_person", label: "Ответственный" },
{ id: "faZootech", key: "zootechnician", label: "Зоотехник (ФИО)" },
{ id: "faWarehouse", key: "warehouse_keeper", label: "Кладовщик (ФИО)" },
{ id: "faDocPrefix", key: "document_number_prefix", label: "Префикс номера документа" },
];
const SIGNATURES = [
{ role: "zootechnician", label: "Зоотехник", canvasId: "faSigZootech" },
{ role: "warehouse_keeper", label: "Кладовщик", canvasId: "faSigWarehouse" },
{ role: "recipient", label: "Получатель", canvasId: "faSigRecipient" },
];
const canvases = {};
let lastSignatureStatus = {};
function getExportMonth() {
const monthEl = document.getElementById("faExportMonth");
if (monthEl && monthEl.value) {
return monthEl.value;
}
const fromEl = document.getElementById("consumptionDateFrom");
if (fromEl && fromEl.value) {
return fromEl.value.slice(0, 7);
}
const now = new Date();
return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
}
function getDateRange() {
const fromEl = document.getElementById("consumptionDateFrom");
const toEl = document.getElementById("consumptionDateTo");
let from = fromEl && fromEl.value;
let to = toEl && toEl.value;
if (!from || !to) {
const now = new Date();
from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
to = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
}
return { from, to, month: getExportMonth() };
}
function syncExportMonthField() {
const monthEl = document.getElementById("faExportMonth");
if (!monthEl || monthEl.value) return;
monthEl.value = getExportMonth();
}
async function refreshSp20Status() {
const el = document.getElementById("faSp20Status");
if (!el) return;
const month = getExportMonth();
el.textContent = "Проверка данных за " + month + "…";
try {
const r = await fetch("/api/feed-accounting/sp20-preview?month=" + encodeURIComponent(month), {
credentials: "same-origin",
});
if (!r.ok) {
el.textContent = "Не удалось загрузить сводку по СП-20.";
return;
}
const data = await r.json();
if (!data.lines_count) {
el.textContent = "За " + month + " нет строк расхода в отчётах загрузки. Проверьте месяц или наличие отчётов.";
el.className = "text-warning small mb-3";
return;
}
let msg = "СП-20 за " + month + ": " + data.lines_count + " строк, " + data.total_kg + " кг";
if (!data.has_requisites) {
msg += ". Заполните реквизиты на вкладке «Реквизиты СП-20».";
el.className = "text-warning small mb-3";
} else {
el.className = "text-success small mb-3";
}
el.textContent = msg;
} catch (err) {
el.textContent = "Не удалось загрузить сводку по СП-20.";
el.className = "text-danger small mb-3";
}
}
function userError(message, fallback) {
const notifyApi = window.WespZootechNotify;
if (notifyApi && typeof notifyApi.userFacingMessage === "function") {
return notifyApi.userFacingMessage(message, fallback);
}
return fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
}
function notify(msg, type) {
if (typeof notyf !== "undefined") {
if (type === "error") notyf.error(msg);
else notyf.success(msg);
}
}
async function downloadUrl(url, label) {
const fallback = "Не удалось скачать документ. Попробуйте ещё раз.";
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) {
let body = null;
try {
body = await r.json();
} catch (e) { /* ignore */ }
const notifyApi = window.WespZootechNotify;
const msg = notifyApi && typeof notifyApi.messageFromResponseBody === "function"
? notifyApi.messageFromResponseBody(body, fallback)
: userError(null, fallback);
notify(msg, "error");
return;
}
const blob = await r.blob();
const cd = r.headers.get("Content-Disposition") || "";
const m = cd.match(/filename="([^"]+)"/);
const filename = m ? m[1] : "download";
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
} catch (err) {
notify(userError(null, fallback), "error");
}
}
function showTab(name) {
document.querySelectorAll(".fa-tab-panel").forEach(function (el) {
el.hidden = el.dataset.tab !== name;
});
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
btn.classList.toggle("active", btn.dataset.tab === name);
});
if (name === "export") {
syncExportMonthField();
refreshSp20Status();
}
}
function setupCanvas(canvas) {
if (!canvas || canvases[canvas.id]) return;
const ctx = canvas.getContext("2d");
ctx.strokeStyle = "#1a1a1a";
ctx.lineWidth = 2;
ctx.lineCap = "round";
let drawing = false;
function pos(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const ev = e.touches ? e.touches[0] : e;
return {
x: (ev.clientX - rect.left) * scaleX,
y: (ev.clientY - rect.top) * scaleY,
};
}
function start(e) {
drawing = true;
const p = pos(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
e.preventDefault();
}
function move(e) {
if (!drawing) return;
const p = pos(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
e.preventDefault();
}
function end() { drawing = false; }
canvas.addEventListener("mousedown", start);
canvas.addEventListener("mousemove", move);
canvas.addEventListener("mouseup", end);
canvas.addEventListener("mouseleave", end);
canvas.addEventListener("touchstart", start, { passive: false });
canvas.addEventListener("touchmove", move, { passive: false });
canvas.addEventListener("touchend", end);
canvases[canvas.id] = { canvas: canvas, ctx: ctx };
}
function clearCanvas(canvasId) {
const item = canvases[canvasId];
if (!item) return;
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
}
function canvasHasInk(canvasId) {
const item = canvases[canvasId];
if (!item) return false;
const data = item.ctx.getImageData(0, 0, item.canvas.width, item.canvas.height).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true;
}
return false;
}
function fillFarmDatalist(farms) {
const list = document.getElementById("faFarmList");
if (!list) return;
list.innerHTML = "";
(farms || []).forEach(function (farm) {
const opt = document.createElement("option");
opt.value = farm;
list.appendChild(opt);
});
}
function isSetupComplete(settings, sigStatus) {
const hasRequisites = Boolean(
(settings.organization_name || "").trim() &&
(settings.zootechnician || "").trim() &&
(settings.warehouse_keeper || "").trim()
);
const hasSignatures = Boolean(sigStatus.zootechnician && sigStatus.warehouse_keeper);
return hasRequisites && hasSignatures;
}
async function loadOrgSettings() {
const r = await fetch("/api/feed-accounting/org-settings", { credentials: "same-origin" });
if (!r.ok) return null;
const data = await r.json();
ORG_FIELDS.forEach(function (f) {
const el = document.getElementById(f.id);
if (el) el.value = data[f.key] || "";
});
fillFarmDatalist(data.available_farms || []);
if (data.signatures) {
lastSignatureStatus = data.signatures;
}
return data;
}
async function loadSignaturesOntoCanvases() {
const roles = lastSignatureStatus;
await Promise.all(
SIGNATURES.map(function (s) {
if (!roles[s.role]) return Promise.resolve();
return new Promise(function (resolve) {
const img = new Image();
img.onload = function () {
const item = canvases[s.canvasId];
if (item) {
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
item.ctx.drawImage(img, 0, 0, item.canvas.width, item.canvas.height);
}
resolve();
};
img.onerror = function () { resolve(); };
img.src = "/api/feed-accounting/signatures/" + s.role + ".png?t=" + Date.now();
});
})
);
}
async function saveOrgSettings(silent) {
const payload = {};
ORG_FIELDS.forEach(function (f) {
const el = document.getElementById(f.id);
if (el) payload[f.key] = el.value.trim();
});
const r = await fetch("/api/feed-accounting/org-settings", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok) {
notify(userError(data.message, "Не удалось сохранить реквизиты"), "error");
return false;
}
if (!silent) notify("Реквизиты сохранены");
return true;
}
async function saveSignature(role, canvasId, silent) {
const item = canvases[canvasId];
if (!item || !canvasHasInk(canvasId)) return true;
const dataUrl = item.canvas.toDataURL("image/png");
const r = await fetch("/api/feed-accounting/signatures/" + role, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ png_base64: dataUrl }),
});
const data = await r.json();
if (!r.ok) {
notify(userError(data.message, "Не удалось сохранить подпись"), "error");
return false;
}
if (data.roles) lastSignatureStatus = data.roles;
if (!silent) notify("Подпись сохранена");
return true;
}
async function saveAll() {
const btn = document.getElementById("faSaveAllBtn");
if (btn) btn.disabled = true;
try {
const orgOk = await saveOrgSettings(true);
if (!orgOk) return;
for (const s of SIGNATURES) {
const ok = await saveSignature(s.role, s.canvasId, true);
if (!ok) return;
}
notify("Реквизиты и подписи сохранены");
const settings = await loadOrgSettings();
if (settings && isSetupComplete(settings, lastSignatureStatus)) {
showTab("export");
}
} finally {
if (btn) btn.disabled = false;
}
}
window.openFeedAccountingModal = async function () {
const modal = document.getElementById("feedAccountingModal");
if (!modal) return;
modal.style.display = "flex";
document.body.style.overflow = "hidden";
SIGNATURES.forEach(function (s) {
setupCanvas(document.getElementById(s.canvasId));
});
const settings = await loadOrgSettings();
await loadSignaturesOntoCanvases();
syncExportMonthField();
const complete = settings && isSetupComplete(settings, lastSignatureStatus);
showTab(complete ? "export" : "requisites");
};
window.closeFeedAccountingModal = function () {
const modal = document.getElementById("feedAccountingModal");
if (!modal) return;
modal.style.display = "none";
document.body.style.overflow = "";
};
document.addEventListener("DOMContentLoaded", function () {
const openBtn = document.getElementById("openFeedAccountingBtn");
if (openBtn) openBtn.addEventListener("click", function () { openFeedAccountingModal(); });
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
showTab(btn.dataset.tab);
});
});
const exportMonthEl = document.getElementById("faExportMonth");
if (exportMonthEl) {
exportMonthEl.addEventListener("change", refreshSp20Status);
}
const saveAllBtn = document.getElementById("faSaveAllBtn");
if (saveAllBtn) saveAllBtn.addEventListener("click", saveAll);
SIGNATURES.forEach(function (s) {
const clearBtn = document.getElementById("clear_" + s.canvasId);
if (clearBtn) clearBtn.addEventListener("click", function () { clearCanvas(s.canvasId); });
});
const exports = [
{ id: "faExportConsumptionXlsx", path: function (d) { return "/api/feed-accounting/consumption.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление Excel" },
{ id: "faExportConsumptionPdf", path: function (d) { return "/api/feed-accounting/consumption.pdf?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление PDF" },
{ id: "faExportStock", path: function (d) { return "/api/feed-accounting/stock-balances.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Остатки" },
{ id: "faExportSp20Xlsx", path: function (d) { return "/api/feed-accounting/sp20.xlsx?month=" + encodeURIComponent(d.month); }, label: "СП-20 Excel" },
{ id: "faExportSp20Pdf", path: function (d) { return "/api/feed-accounting/sp20.pdf?month=" + encodeURIComponent(d.month); }, label: "СП-20 PDF" },
{ id: "faExportJournalXlsx", path: function (d) { return "/api/feed-accounting/journal.xlsx?month=" + encodeURIComponent(d.month); }, label: "Журнал Excel" },
{ id: "faExportJournalPdf", path: function (d) { return "/api/feed-accounting/journal.pdf?month=" + encodeURIComponent(d.month); }, label: "Журнал PDF" },
{ id: "faExportZip", path: function (d) { return "/api/feed-accounting/documents.zip?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to) + "&month=" + encodeURIComponent(d.month); }, label: "ZIP" },
];
exports.forEach(function (ex) {
const btn = document.getElementById(ex.id);
if (!btn) return;
btn.addEventListener("click", function () {
const d = getDateRange();
downloadUrl(ex.path(d), ex.label);
});
});
const modal = document.getElementById("feedAccountingModal");
if (modal) {
modal.addEventListener("click", function (e) {
if (e.target === modal) closeFeedAccountingModal();
});
}
});
})();
+170
View File
@@ -0,0 +1,170 @@
/**
* Модальное окно «Калибровка»: iframe с /calibration?embed=1.
*/
(function () {
const LOG = "[WespKioskCalibration]";
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
function ensureStylesheet() {
if (document.getElementById("kioskCalibrationModalCss")) return;
const link = document.createElement("link");
link.id = "kioskCalibrationModalCss";
link.rel = "stylesheet";
link.href = "/static/css/kiosk-calibration-modal.css";
document.head.appendChild(link);
}
function resizeFrame(iframe) {
if (!iframe || !iframe.contentDocument) return;
try {
const doc = iframe.contentDocument;
const height = Math.max(
doc.body?.scrollHeight || 0,
doc.documentElement?.scrollHeight || 0,
320,
);
const max = Math.floor(window.innerHeight * 0.82) - 64;
iframe.style.height = `${Math.min(height + 4, max)}px`;
} catch (e) {
console.warn(LOG, "iframe resize", e);
}
}
function createModal(options) {
ensureStylesheet();
const opts = options || {};
const zIndex = Number(opts.zIndex) > 0 ? Number(opts.zIndex) : 5000;
const light = opts.theme === "light";
const panelTheme = light ? "light" : "dark";
const modal = document.createElement("div");
modal.id = "kioskCalibrationModal";
modal.className = "kiosk-calibration-backdrop";
modal.setAttribute("aria-hidden", "true");
modal.style.zIndex = String(zIndex);
modal.innerHTML = `
<div class="kiosk-calibration-panel kiosk-calibration-panel--${panelTheme}" role="dialog" aria-modal="true" aria-labelledby="kioskCalibrationTitle">
<div class="kiosk-calibration-head">
<h2 class="kiosk-calibration-title" id="kioskCalibrationTitle">Калибровка весов</h2>
<button type="button" class="kiosk-calibration-close" id="kioskCalibrationClose">Закрыть</button>
</div>
<iframe
id="kioskCalibrationFrame"
class="kiosk-calibration-frame"
title="Калибровка весов"
></iframe>
</div>
`;
document.body.appendChild(modal);
const iframe = modal.querySelector("#kioskCalibrationFrame");
const closeBtn = modal.querySelector("#kioskCalibrationClose");
const panel = modal.querySelector(".kiosk-calibration-panel");
function closeModal() {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
try {
iframe.src = "about:blank";
iframe.style.height = "";
} catch (e) {
console.warn(LOG, "iframe reset", e);
}
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
}
function openModal() {
const menu = document.getElementById(MENU_OVERLAY_ID);
if (menu) menu.style.display = "none";
iframe.onload = function () {
window.setTimeout(function () {
resizeFrame(iframe);
}, 50);
};
iframe.src = "/calibration?embed=1";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) closeModal();
});
if (panel) {
panel.addEventListener("click", function (e) {
e.stopPropagation();
});
}
return { modal, iframe, openModal, closeModal, resizeFrame };
}
function ensureModal(options) {
const opts = options || {};
if (!window.__wespKioskCalibrationApi) {
window.__wespKioskCalibrationApi = createModal(opts);
} else if (Number(opts.zIndex) > 0) {
window.__wespKioskCalibrationApi.modal.style.zIndex = String(opts.zIndex);
}
return window.__wespKioskCalibrationApi;
}
function openModal(options) {
const api = ensureModal(options);
const panel = api.modal.querySelector(".kiosk-calibration-panel");
if (panel && options && options.theme) {
const dark = options.theme === "dark";
panel.classList.toggle("kiosk-calibration-panel--dark", dark);
panel.classList.toggle("kiosk-calibration-panel--light", !dark);
}
api.openModal();
return api;
}
function setup(opts) {
const options = opts || {};
const linkSel = options.menuLinkSelector || "#calibrationLink";
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
window.WespKioskMenu.ensureOverlay();
}
const link = document.querySelector(linkSel);
if (!link) {
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
return {};
}
const api = ensureModal(options);
if (link.dataset.wespCalibrationBound === "1") {
return { openModal: api.openModal, closeModal: api.closeModal };
}
link.dataset.wespCalibrationBound = "1";
link.addEventListener(
"click",
function (e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (window.frameElement) return;
e.preventDefault();
e.stopPropagation();
api.openModal();
},
true,
);
return { openModal: api.openModal, closeModal: api.closeModal };
}
window.WespKioskCalibration = { setup, ensureModal, openModal };
})();
+147
View File
@@ -0,0 +1,147 @@
/**
* Модальное окно «Проверка БД» в стиле киоска (как привязка терминала):
* тёмная рамка, iframe с /db_check?embed=1.
*/
(function () {
const LOG = "[WespKioskDbCheck]";
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
function createModal() {
const modal = document.createElement("div");
modal.id = "kioskDbCheckModal";
modal.setAttribute("aria-hidden", "true");
modal.style.cssText =
"display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:5000;" +
"align-items:center;justify-content:center;padding:12px;box-sizing:border-box;";
modal.innerHTML = `
<div class="kiosk-db-check-panel" style="
background:#1f1f1f;
color:#fff;
border-radius:14px;
border:1px solid #2f2f2f;
box-shadow:0 0 20px rgba(0,0,0,.45);
width:min(920px,100%);
max-height:min(92vh,900px);
display:flex;
flex-direction:column;
overflow:hidden;
">
<div style="
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 16px;
border-bottom:1px solid #2f2f2f;
flex-shrink:0;
">
<h2 style="margin:0;font-size:1.125rem;font-weight:600;line-height:1.3">Проверка баз данных</h2>
<button type="button" id="kioskDbCheckClose" style="
padding:10px 16px;
border-radius:8px;
border:1px solid #7a7a7a;
background:#3a3a3a;
color:#fff;
cursor:pointer;
font-size:15px;
flex-shrink:0;
">Закрыть</button>
</div>
<iframe
id="kioskDbCheckFrame"
title="Проверка баз данных"
style="border:0;width:100%;flex:1;min-height:min(70vh,560px);background:#f9fafb;"
></iframe>
</div>
`;
document.body.appendChild(modal);
const iframe = modal.querySelector("#kioskDbCheckFrame");
const closeBtn = modal.querySelector("#kioskDbCheckClose");
const panel = modal.querySelector(".kiosk-db-check-panel");
function closeModal() {
modal.style.display = "none";
modal.setAttribute("aria-hidden", "true");
try {
iframe.src = "about:blank";
} catch (e) {
console.warn(LOG, "iframe reset", e);
}
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
}
function openModal() {
const menu = document.getElementById(MENU_OVERLAY_ID);
if (menu) menu.style.display = "none";
iframe.src = "/db_check?embed=1";
modal.style.display = "flex";
modal.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) closeModal();
});
if (panel) {
panel.addEventListener("click", function (e) {
e.stopPropagation();
});
}
return { modal, openModal, closeModal };
}
function setup(opts) {
const options = opts || {};
const linkSel = options.menuLinkSelector || "#dbCheckLink";
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
window.WespKioskMenu.ensureOverlay();
}
const link = document.querySelector(linkSel);
if (!link) {
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
return {};
}
let api;
if (!window.__wespKioskDbCheckApi) {
api = createModal();
window.__wespKioskDbCheckApi = api;
} else {
api = window.__wespKioskDbCheckApi;
}
if (link.dataset.wespDbCheckBound === "1") {
return { openModal: api.openModal, closeModal: api.closeModal };
}
link.dataset.wespDbCheckBound = "1";
link.addEventListener(
"click",
function (e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (window.frameElement) return;
e.preventDefault();
e.stopPropagation();
api.openModal();
},
true
);
return { openModal: api.openModal, closeModal: api.closeModal };
}
window.WespKioskDbCheck = { setup };
})();
+271
View File
@@ -0,0 +1,271 @@
/**
* Диалоги для страниц киоска / разгрузки / отбора (терминал). Не подключать на зоотех-оболочку там wesp-dialog.js (WespDialog).
* API: WespKioskDialog.alert(msg, opts?) -> Promise<void>
* WespKioskDialog.confirm(msg, opts?) -> Promise<boolean>
* opts.timerOnly закрытие только через WespKioskDialog.closeActive() (таймер); без кнопки, без Escape и клика по фону.
*/
(function (global) {
var OVERLAY_ID = "wespKioskDialogOverlay";
var STYLE_ID = "wesp-kiosk-dialog-styles";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
var s = document.createElement("style");
s.id = STYLE_ID;
s.textContent =
"#" +
OVERLAY_ID +
"{" +
"position:fixed;inset:0;z-index:5000;" +
"display:none;align-items:center;justify-content:center;" +
"padding:16px;background:rgba(0,0,0,.55);" +
"box-sizing:border-box;" +
"}" +
"#" +
OVERLAY_ID +
'[aria-hidden="false"]{display:flex;}' +
".wesp-kiosk-dialog{" +
"width:min(420px,100%);max-height:min(80vh,520px);overflow:auto;" +
"background:#fff;color:#111827;border-radius:8px;" +
"border:1px solid #e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,.18);" +
"padding:20px 22px;font-family:Inter,system-ui,sans-serif;" +
"text-align:left;" +
"}" +
".wesp-kiosk-dialog-title{" +
"margin:0 0 12px 0;font-size:1.125rem;font-weight:600;color:#111827;" +
"}" +
".wesp-kiosk-dialog-body{" +
"margin:0;font-size:0.95rem;line-height:1.5;color:#374151;white-space:pre-wrap;word-break:break-word;" +
"}" +
".wesp-kiosk-dialog-actions{" +
"display:flex;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:20px;" +
"}" +
".wesp-kiosk-dialog-btn{" +
"min-width:120px;padding:10px 18px;border-radius:6px;font-size:0.95rem;font-weight:500;" +
"cursor:pointer;font-family:inherit;border:1px solid #e5e7eb;background:#fff;color:#374151;" +
"transition:background .2s,border-color .2s,box-shadow .2s;" +
"}" +
".wesp-kiosk-dialog-btn:hover{filter:brightness(.97);}" +
".wesp-kiosk-dialog-btn-primary{" +
"background:#2563eb;border-color:#2563eb;color:#fff;" +
"}" +
".wesp-kiosk-dialog-btn-primary:hover{box-shadow:0 4px 12px rgba(37,99,235,.25);}" +
".wesp-kiosk-dialog-btn-danger{" +
"background:#ef4444;border-color:#ef4444;color:#fff;" +
"}" +
".wesp-kiosk-dialog--danger{border-top:4px solid #ef4444;}" +
".wesp-kiosk-dialog--success{border-top:4px solid #10b981;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog{" +
"background:#242424;color:#e6e6e6;border-color:#3a3a3a;" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-title{color:#f3f4f6;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-body{color:#d1d5db;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn{" +
"background:#2d2d2d;border-color:#3a3a3a;color:#e6e6e6;" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn-primary{background:#2563eb;border-color:#2563eb;color:#fff;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}" +
"";
document.head.appendChild(s);
}
function ensureDom() {
injectStyles();
var el = document.getElementById(OVERLAY_ID);
if (el) return el;
el = document.createElement("div");
el.id = OVERLAY_ID;
el.setAttribute("aria-hidden", "true");
el.innerHTML =
'<div class="wesp-kiosk-dialog" role="dialog" aria-modal="true" aria-labelledby="wespKioskDialogTitle">' +
'<h2 class="wesp-kiosk-dialog-title" id="wespKioskDialogTitle"></h2>' +
'<p class="wesp-kiosk-dialog-body" id="wespKioskDialogBody"></p>' +
'<div class="wesp-kiosk-dialog-actions" id="wespKioskDialogActions"></div>' +
"</div>";
el.addEventListener("click", function (e) {
if (e.target === el) {
backdropDismiss();
}
});
document.body.appendChild(el);
return el;
}
var pendingResolve = null;
var pendingMode = null;
/** true: alert с opts.timerOnly — пользователь не закрывает окно вручную */
var blockUserDismiss = false;
function backdropDismiss() {
if (blockUserDismiss) return;
if (pendingMode === "confirm" && pendingResolve) {
var fn = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fn2 = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn2();
}
}
function hide() {
var el = document.getElementById(OVERLAY_ID);
if (!el) return;
var dlg = el.querySelector(".wesp-kiosk-dialog");
if (dlg) dlg.removeAttribute("tabindex");
blockUserDismiss = false;
el.setAttribute("aria-hidden", "true");
document.removeEventListener("keydown", onKey);
}
/** Закрыть активный alert/confirm без клика (таймер, программное закрытие). */
function closeActive() {
if (pendingMode === "confirm" && pendingResolve) {
var fnC = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnC(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fnA = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnA();
}
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
if (blockUserDismiss) return;
backdropDismiss();
}
}
function show() {
var el = document.getElementById(OVERLAY_ID);
if (!el) return;
el.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
function setDialogVariant(dialogEl, variant) {
dialogEl.classList.remove("wesp-kiosk-dialog--danger", "wesp-kiosk-dialog--success");
if (variant === "danger") dialogEl.classList.add("wesp-kiosk-dialog--danger");
else if (variant === "success") dialogEl.classList.add("wesp-kiosk-dialog--success");
}
function displayDialogMessage(message, opts) {
const variant = opts && opts.variant;
const fallback = opts && opts.fallback;
if (variant === "danger" && global.WespUserMessages?.userFacingMessage) {
return global.WespUserMessages.userFacingMessage(message, fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.");
}
return message == null ? "" : String(message);
}
function alert(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
var titleEl = document.getElementById("wespKioskDialogTitle");
var bodyEl = document.getElementById("wespKioskDialogBody");
var actions = document.getElementById("wespKioskDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Сообщение";
bodyEl.textContent = displayDialogMessage(message, opts);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
blockUserDismiss = !!opts.timerOnly;
pendingMode = "alert";
pendingResolve = resolve;
if (!opts.timerOnly) {
var ok = document.createElement("button");
ok.type = "button";
ok.className = "wesp-kiosk-dialog-btn wesp-kiosk-dialog-btn-primary";
ok.textContent = opts.okText || "Понятно";
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve();
});
actions.appendChild(ok);
show();
ok.focus();
} else {
dialog.setAttribute("tabindex", "-1");
show();
dialog.focus();
}
});
}
function confirm(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
blockUserDismiss = false;
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
var titleEl = document.getElementById("wespKioskDialogTitle");
var bodyEl = document.getElementById("wespKioskDialogBody");
var actions = document.getElementById("wespKioskDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Подтверждение";
bodyEl.textContent = message == null ? "" : String(message);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
var cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "wesp-kiosk-dialog-btn";
cancel.textContent = opts.cancelText || "Отмена";
var ok = document.createElement("button");
ok.type = "button";
ok.className =
"wesp-kiosk-dialog-btn " +
(opts.danger ? "wesp-kiosk-dialog-btn-danger" : "wesp-kiosk-dialog-btn-primary");
ok.textContent = opts.okText || "Да";
cancel.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(false);
});
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(true);
});
actions.appendChild(cancel);
actions.appendChild(ok);
pendingMode = "confirm";
pendingResolve = resolve;
show();
ok.focus();
});
}
global.WespKioskDialog = {
alert: alert,
confirm: confirm,
closeActive: closeActive,
};
})(typeof window !== "undefined" ? window : globalThis);
+169
View File
@@ -0,0 +1,169 @@
(function () {
const STYLE_ID = "wespKioskMenuStyles";
const OVERLAY_ID = "wespKioskMenuOverlay";
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${OVERLAY_ID} {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
z-index: 4300;
align-items: center;
justify-content: center;
padding: 16px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-panel {
width: min(420px, 96vw);
background: #1f1f1f;
border: 1px solid #2f2f2f;
border-radius: 14px;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
color: #fff;
padding: 16px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-logo-wrap {
text-align: center;
margin-bottom: 12px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-logo {
height: 32px;
width: auto;
}
#${OVERLAY_ID} .wesp-kiosk-menu-links {
display: flex;
flex-direction: column;
gap: 8px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-link {
display: block;
text-decoration: none;
color: #fff;
background: #2b2b2b;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 12px 14px;
font-size: 15px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-link:hover {
background: #1b66a8;
border-color: #4ea8ff;
}
#${OVERLAY_ID} .wesp-kiosk-menu-close {
margin-top: 10px;
width: 100%;
border: 1px solid #7a7a7a;
border-radius: 10px;
background: #3a3a3a;
color: #fff;
padding: 10px 12px;
font-size: 15px;
cursor: pointer;
}
#${OVERLAY_ID} .wesp-kiosk-menu-close:hover {
background: #505050;
}
.wesp-kiosk-menu-button {
position: fixed !important;
top: 16px !important;
right: 16px !important;
z-index: 4250 !important;
width: 56px !important;
height: 56px !important;
border-radius: 50% !important;
border: 1px solid #1f6fd6 !important;
background: #1f6fd6 !important;
color: #fff !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
cursor: pointer !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35) !important;
font-size: 30px !important;
line-height: 1 !important;
}
.wesp-kiosk-menu-button:hover {
background: #1859a8 !important;
transform: scale(1.04);
}
.wesp-kiosk-menu-button img {
display: none !important;
}
@media (max-width: 768px) {
.wesp-kiosk-menu-button {
top: 12px !important;
right: 12px !important;
width: 50px !important;
height: 50px !important;
font-size: 28px !important;
}
}
`;
document.head.appendChild(style);
}
function ensureOverlay() {
ensureStyles();
let overlay = document.getElementById(OVERLAY_ID);
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.id = OVERLAY_ID;
overlay.innerHTML = `
<div class="wesp-kiosk-menu-panel" role="dialog" aria-modal="true" aria-label="Меню киоска">
<div class="wesp-kiosk-menu-logo-wrap">
<img src="/static/logo2.png" alt="WESP" class="wesp-kiosk-menu-logo">
</div>
<div class="wesp-kiosk-menu-links">
<a class="wesp-kiosk-menu-link" href="/scales">Главная</a>
<a class="wesp-kiosk-menu-link" href="#" id="calibrationLink">Калибровка</a>
<a class="wesp-kiosk-menu-link" href="#" id="dbCheckLink">Проверка БД</a>
<a class="wesp-kiosk-menu-link" href="#" id="pairTerminalLink">Привязать терминал</a>
</div>
<button type="button" class="wesp-kiosk-menu-close" id="wespKioskMenuClose">Закрыть</button>
</div>
`;
document.body.appendChild(overlay);
const panel = overlay.querySelector(".wesp-kiosk-menu-panel");
const closeBtn = overlay.querySelector("#wespKioskMenuClose");
const close = () => {
overlay.style.display = "none";
};
closeBtn.addEventListener("click", close);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
panel.addEventListener("click", (e) => e.stopPropagation());
return overlay;
}
function setup(opts) {
const options = opts || {};
const selector =
options.menuButtonSelector || "#kioskMenuButton, .kiosk-menu-btn, .nav-button";
const menuButton = document.querySelector(selector);
if (!menuButton) return;
ensureStyles();
const overlay = ensureOverlay();
if (menuButton.dataset.wespMenuBound === "1") return;
menuButton.classList.add("wesp-kiosk-menu-button");
menuButton.setAttribute("title", "Меню");
menuButton.setAttribute("aria-label", "Открыть меню");
menuButton.textContent = "☰";
menuButton.dataset.wespMenuBound = "1";
menuButton.removeAttribute("onclick");
menuButton.addEventListener("click", function (e) {
e.preventDefault();
overlay.style.display = "flex";
});
}
window.WespKioskMenu = { setup, ensureOverlay };
})();
+285
View File
@@ -0,0 +1,285 @@
(function () {
const LOG = "[WespKioskPairing]";
const API = {
status: "/api/kiosk/status",
accessLink: "/api/kiosk/access-link",
setupAvailable: "/api/kiosk/setup-available",
};
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isLocalhostContext() {
return LOCALHOST_HOSTS.has((window.location.hostname || "").toLowerCase());
}
function ensureModalStyles() {
const criticalId = "wespKioskPairCriticalCss";
if (!document.getElementById(criticalId)) {
const style = document.createElement("style");
style.id = criticalId;
style.textContent =
"#kioskPairModal{display:none;position:fixed;inset:0;z-index:5000;padding:8px;background:rgba(0,0,0,.55);overflow:hidden}" +
"#kioskPairModal.kiosk-pair-open{display:flex;align-items:stretch;justify-content:center;height:100vh;height:100dvh}" +
"body.kiosk-pair-modal-open{overflow:hidden!important}" +
"#kioskPairModal .kiosk-pair-panel{display:flex;flex-direction:column;width:min(480px,100%);height:100%;max-height:100%;min-height:0;overflow:hidden;box-sizing:border-box}" +
"#kioskPairModal .kiosk-pair-body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch}" +
"#kioskPairModal .kiosk-pair-actions{flex:0 0 auto;display:flex;flex-wrap:wrap;gap:6px}";
document.head.appendChild(style);
}
if (document.getElementById("wespKioskModalsCss")) return;
if (window.WespKioskTheme && typeof window.WespKioskTheme.ensureModalStyles === "function") {
window.WespKioskTheme.ensureModalStyles();
return;
}
const link = document.createElement("link");
link.id = "wespKioskModalsCss";
link.rel = "stylesheet";
link.href = "/static/css/kiosk-modals.css";
document.head.appendChild(link);
}
function createModal() {
ensureModalStyles();
const modal = document.createElement("div");
modal.id = "kioskPairModal";
modal.innerHTML = `
<div class="kiosk-pair-panel" role="dialog" aria-modal="true" aria-labelledby="kioskPairTitle">
<div class="kiosk-pair-header">
<h3 id="kioskPairTitle">Привязать терминал</h3>
<p class="kiosk-pair-subtitle">Start URL для Fully Kiosk Browser</p>
</div>
<div class="kiosk-pair-body">
<div class="kiosk-pair-steps">
1) Скопируйте ссылку или отсканируйте QR.<br/>
2) Fully Kiosk Settings Start URL вставьте ссылку Save.<br/>
3) Перезапустите Fully Kiosk.
</div>
<div id="kioskPairLoading" class="kiosk-pair-loading">
<span class="kiosk-pair-spinner"></span>
<span>Загрузка ссылки...</span>
</div>
<div class="kiosk-pair-qr-wrap">
<img id="kioskPairQr" class="kiosk-pair-qr" alt="QR Start URL" />
</div>
<p id="kioskPairUrl" class="kiosk-pair-url"></p>
<p id="kioskPairStatus" class="kiosk-pair-status"></p>
</div>
<div class="kiosk-pair-actions">
<button id="kioskPairCopy" type="button" class="kiosk-pair-btn kiosk-pair-btn--primary">Скопировать ссылку</button>
<button id="kioskPairRefresh" type="button" class="kiosk-pair-btn kiosk-pair-btn--secondary">Обновить ссылку</button>
<button id="kioskPairClose" type="button" class="kiosk-pair-btn kiosk-pair-btn--ghost">Закрыть</button>
</div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function createBlockOverlay() {
const el = document.createElement("div");
el.id = "kioskBlockedOverlay";
el.style.cssText =
"display:none;position:fixed;inset:0;background:rgba(10,10,10,.78);z-index:4500;color:#fff;align-items:center;justify-content:center;text-align:center;padding:20px;";
el.innerHTML = `
<div style="max-width:520px">
<h2 style="margin:0 0 10px 0">Терминал не привязан</h2>
<p style="margin:0;line-height:1.4">Обратитесь к администратору для настройки терминала.</p>
</div>
`;
document.body.appendChild(el);
return el;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
console.warn(LOG, url, response.status, data.message || data);
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
}
function setup(opts) {
const options = opts || {};
const modal = createModal();
const block = createBlockOverlay();
const qrImg = modal.querySelector("#kioskPairQr");
const qrWrap = modal.querySelector(".kiosk-pair-qr-wrap");
const loadingEl = modal.querySelector("#kioskPairLoading");
const urlEl = modal.querySelector("#kioskPairUrl");
const statusEl = modal.querySelector("#kioskPairStatus");
const copyBtn = modal.querySelector("#kioskPairCopy");
const refreshBtn = modal.querySelector("#kioskPairRefresh");
const closeBtn = modal.querySelector("#kioskPairClose");
let currentStartUrl = "";
let canIssueAccessLink = isLocalhostContext();
function isEnforcePairedOnly(data) {
if (!data || typeof data !== "object") return true;
return data.enforce_paired_only !== false;
}
async function refreshSetupAvailability() {
if (isLocalhostContext()) {
canIssueAccessLink = true;
return true;
}
try {
const data = await fetchJson(API.setupAvailable);
canIssueAccessLink = !!data.can_issue_access_link;
} catch (e) {
console.warn(LOG, "setup-available error", e);
canIssueAccessLink = false;
}
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.style.display = canIssueAccessLink ? "" : "none";
}
}
return canIssueAccessLink;
}
async function checkStatus() {
if (isLocalhostContext()) {
block.style.display = "none";
if (typeof options.onPaired === "function") options.onPaired({ paired: true, localhost: true });
return true;
}
try {
const data = await fetchJson(API.status);
const enforce = isEnforcePairedOnly(data);
const paired = !!data.paired || !enforce;
console.debug(LOG, "/api/kiosk/status", {
paired: !!data.paired,
enforce_paired_only: enforce,
device_id: data.device_id,
status: data.status,
});
block.style.display = paired ? "none" : "flex";
if (paired && typeof options.onPaired === "function") options.onPaired(data);
if (!paired && typeof options.onUnpaired === "function") options.onUnpaired(data);
return paired;
} catch (e) {
console.warn(LOG, "checkStatus error", e);
block.style.display = "flex";
if (typeof options.onUnpaired === "function") options.onUnpaired({});
return false;
}
}
async function loadAccessLink(refresh) {
if (!canIssueAccessLink) {
statusEl.textContent = "Выдача Start URL доступна только администратору или с localhost.";
statusEl.style.color = "#ff9f9f";
return;
}
loadingEl.style.display = "flex";
qrWrap.classList.remove("kiosk-pair-qr-visible");
qrImg.removeAttribute("src");
statusEl.textContent = refresh ? "Обновление ссылки..." : "Загрузка ссылки...";
try {
const data = await fetchJson(API.accessLink, {
method: refresh ? "POST" : "GET",
headers: refresh ? { "Content-Type": "application/json" } : undefined,
body: refresh ? JSON.stringify({ refresh: true }) : undefined,
});
currentStartUrl = data.start_url || data.pair_url || "";
console.info(LOG, "access-link OK", { start_url: currentStartUrl.slice(0, 80) });
const qrSrc = data.qr_image_url || "";
await new Promise((resolve) => {
const done = () => {
qrImg.onload = null;
qrImg.onerror = null;
resolve();
};
qrImg.onload = done;
qrImg.onerror = done;
qrImg.src = qrSrc;
});
loadingEl.style.display = "none";
qrWrap.classList.add("kiosk-pair-qr-visible");
qrImg.style.display = "block";
urlEl.textContent = currentStartUrl;
statusEl.textContent = "Постоянная ссылка. Вставьте её в Fully Kiosk → Start URL.";
statusEl.style.color = "";
} catch (e) {
loadingEl.style.display = "none";
statusEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось получить ссылку") || "Не удалось получить ссылку";
statusEl.style.color = "#ff9f9f";
}
}
async function openModal() {
if (!canIssueAccessLink) {
window.alert("Выдача Start URL доступна только администратору или при открытии с localhost.");
return;
}
modal.classList.add("kiosk-pair-open");
document.body.classList.add("kiosk-pair-modal-open");
loadAccessLink(false);
}
function closeModal() {
modal.classList.remove("kiosk-pair-open");
document.body.classList.remove("kiosk-pair-modal-open");
}
async function copyStartUrl() {
if (!currentStartUrl) {
statusEl.textContent = "Сначала дождитесь загрузки ссылки";
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(currentStartUrl);
} else {
const ta = document.createElement("textarea");
ta.value = currentStartUrl;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
statusEl.textContent = "Ссылка скопирована";
statusEl.style.color = "#9fd89f";
} catch (e) {
statusEl.textContent = "Не удалось скопировать — выделите ссылку вручную";
statusEl.style.color = "#ff9f9f";
}
}
async function refreshLink() {
if (!window.confirm("Старая ссылка перестанет работать. Обновить Start URL?")) {
return;
}
await loadAccessLink(true);
}
copyBtn.addEventListener("click", copyStartUrl);
refreshBtn.addEventListener("click", refreshLink);
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal();
});
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.addEventListener("click", (e) => {
e.preventDefault();
openModal();
});
}
}
refreshSetupAvailability().then(function () {
checkStatus();
});
return { checkStatus, openModal };
}
window.WespKioskPairing = { setup };
})();
+153
View File
@@ -0,0 +1,153 @@
(function () {
const KEY = "theme";
const STYLE_ID = "wespKioskThemeStyles";
const TOGGLE_ID = "wespThemeToggleWrap";
function savedTheme() {
const t = (localStorage.getItem(KEY) || "").trim().toLowerCase();
return t === "light" ? "light" : "dark";
}
function applyTheme(theme) {
const next = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(KEY, next);
}
function injectModalChromeStyles() {
const id = "wespKioskModalsCss";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = "/static/css/kiosk-modals.css";
document.head.appendChild(link);
}
function ensureModalStyles() {
injectModalChromeStyles();
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
[data-theme="dark"] body {
background: #1a1a1a !important;
color: #e6e6e6 !important;
}
[data-theme="dark"] .card,
[data-theme="dark"] .data-container,
[data-theme="dark"] .admin-panel,
[data-theme="dark"] .modal-content,
[data-theme="dark"] .modal,
[data-theme="dark"] .stat-card,
[data-theme="dark"] .table-section,
[data-theme="dark"] .loading-content,
[data-theme="dark"] .error-modal {
background: #242424 !important;
color: #f0f0f0 !important;
border-color: #3a3a3a !important;
}
[data-theme="dark"] .subtitle,
[data-theme="dark"] .meta,
[data-theme="dark"] .stat-label,
[data-theme="dark"] .text-muted {
color: #bdbdbd !important;
}
[data-theme="dark"] .btn,
[data-theme="dark"] .control-button,
[data-theme="dark"] .numpad-btn {
color: #fff !important;
}
[data-theme="dark"] pre {
background: #181818 !important;
color: #dcdcdc !important;
border: 1px solid #353535 !important;
}
#${TOGGLE_ID} {
position: fixed;
bottom: 12px;
right: 16px;
z-index: 4249;
display: inline-flex;
align-items: center;
user-select: none;
background: transparent;
border: none;
padding: 0;
}
#${TOGGLE_ID} input {
--s: 40px;
height: var(--s);
aspect-ratio: 2.5;
width: auto;
border-radius: var(--s);
padding: calc(var(--s) / 10);
margin: 0;
cursor: pointer;
background:
radial-gradient(farthest-side, #15202a 96%, #0000)
var(--_p, 0%) / var(--s) content-box no-repeat,
var(--_c, #ff7a7a);
box-sizing: content-box;
transform-origin: calc(3 * var(--s) / 5) 50%;
transition:
transform cubic-bezier(0, 300, 1, 300) .5s,
background .3s .1s ease-in;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
#${TOGGLE_ID} input:checked {
--_c: #85ff7a;
--_p: 100%;
transform-origin: calc(100% - 3 * var(--s) / 5) 50%;
transform: rotate(0.1deg);
}
@media (max-width: 768px) {
#${TOGGLE_ID} {
bottom: 12px;
right: 12px;
transform: scale(0.9);
transform-origin: bottom right;
}
}
`;
document.head.appendChild(style);
}
function ensureToggle() {
let wrap = document.getElementById(TOGGLE_ID);
if (wrap) return wrap;
wrap = document.createElement("button");
wrap.id = TOGGLE_ID;
wrap.type = "button";
wrap.innerHTML = `<input id="wespThemeSwitch" type="checkbox" aria-label="Тёмная тема" title="Выключено — светлая тема. Включено — тёмная.">`;
const input = wrap.querySelector("#wespThemeSwitch");
input.addEventListener("change", function () {
const next = input.checked ? "dark" : "light";
applyTheme(next);
});
document.body.appendChild(wrap);
return wrap;
}
function setup(opts) {
const options = opts || {};
const theme = savedTheme();
injectModalChromeStyles();
injectStyles();
applyTheme(theme);
if (options.withToggle !== false) {
const t = ensureToggle();
const input = t.querySelector("#wespThemeSwitch");
if (input) input.checked = theme === "dark";
}
}
window.WespKioskTheme = { setup, applyTheme, ensureModalStyles };
})();
+26
View File
@@ -0,0 +1,26 @@
export class AppState {
constructor(initialState = {}) {
this._state = { ...initialState };
this._listeners = new Set();
}
get(key) {
return this._state[key];
}
getAll() {
return { ...this._state };
}
set(patch) {
this._state = { ...this._state, ...patch };
for (const listener of this._listeners) {
listener(this.getAll());
}
}
subscribe(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
}
@@ -0,0 +1,27 @@
export class CrossTabSync {
constructor(channelName) {
this._channel = null;
if (typeof BroadcastChannel !== "undefined") {
this._channel = new BroadcastChannel(channelName);
}
}
post(type, payload = {}) {
if (!this._channel) return;
this._channel.postMessage({ type, ...payload });
}
onMessage(handler) {
if (!this._channel) return () => {};
const wrapped = (event) => handler(event.data || {});
this._channel.addEventListener("message", wrapped);
return () => this._channel.removeEventListener("message", wrapped);
}
close() {
if (this._channel) {
this._channel.close();
this._channel = null;
}
}
}
@@ -0,0 +1,185 @@
/**
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatKg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const rounded = Math.round(n * 100) / 100;
return String(rounded).replace(/\.?0+$/, "");
}
function renderComponentPickList(items, emptyText) {
if (!items.length) {
return `<p class="zt-k-hub-plan-replace__empty">${escapeHtml(emptyText)}</p>`;
}
return items
.map(
(item) =>
`<button type="button" class="zt-k-hub-plan-replace__pick" data-pick-component-id="${escapeHtml(item.id)}" ` +
`data-pick-component-name="${escapeHtml(item.name)}">` +
`<span class="zt-k-hub-plan-replace__pick-name">${escapeHtml(item.name)}</span>` +
`<span class="zt-k-hub-plan-replace__pick-meta">` +
`${escapeHtml(item.type || "—")} · СВ ${formatKg(item.dryMatter)}%</span></button>`
)
.join("");
}
async function fetchComponentAlternatives(componentId, query) {
const params = new URLSearchParams({
component_id: componentId,
q: query || "",
limit: "100",
});
const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось загрузить компоненты");
}
return resp.json();
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.componentId
* @param {string} options.label
* @param {HTMLElement} [options.overlayHost]
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
* @param {string} [options.subtitle]
* @param {(replacementComponentId: string, replacementName: string) => void|Promise<void>} options.onPick
* @param {(message: string) => void} [options.onError]
*/
async function openReplaceComponentModal(options) {
const {
componentId,
label,
overlayHost = document.body,
keepOverlays = [],
subtitle = "",
onPick,
onError = () => {},
} = options || {};
if (!componentId || !onPick) return;
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace";
const subtitleHtml = subtitle
? `<p class="zt-k-hub-plan-replace__lead">${escapeHtml(subtitle)}</p>`
: "";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-replace__dialog" role="dialog" aria-modal="true">' +
`<h3 class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-replace__title">Заменить «${escapeHtml(label)}»</h3>` +
subtitleHtml +
'<label class="zt-k-hub-plan-replace__search-label">Поиск по всем компонентам</label>' +
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-replace-search placeholder="Начните вводить название…" autocomplete="off">' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Похожие по параметрам</p>' +
'<div data-replace-similar class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Все компоненты</p>' +
'<div data-replace-results class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-replace__actions">' +
'<button type="button" class="btn btn-secondary" data-replace-cancel>Отмена</button></div></div>';
dismissOverlays(overlayHost, keepOverlays);
overlay.setAttribute("data-daily-plan-overlay", "1");
overlayHost.appendChild(overlay);
const similarEl = overlay.querySelector("[data-replace-similar]");
const resultsEl = overlay.querySelector("[data-replace-results]");
const searchEl = overlay.querySelector("[data-replace-search]");
let catalog = null;
const close = () => {
if (overlay.isConnected) overlay.remove();
};
function filterCatalog(query) {
const q = query.trim().toLowerCase();
if (!q) {
return {
similar: catalog?.similar || [],
items: catalog?.results || [],
q: "",
};
}
const items = (catalog?.results || []).filter((item) =>
String(item.name || "").toLowerCase().includes(q)
);
const similar = items.filter((item) => item.similar);
return { similar, items, q };
}
function paintLists(query = "") {
if (!overlay.isConnected) return;
const { similar, items, q } = filterCatalog(query);
if (similarEl) {
similarEl.innerHTML = renderComponentPickList(
similar,
q ? "Нет похожих по запросу" : "Нет похожих компонентов"
);
}
if (resultsEl) {
resultsEl.innerHTML = renderComponentPickList(
items,
q ? "Ничего не найдено" : "Нет доступных компонентов"
);
}
}
async function loadCatalog() {
try {
catalog = await fetchComponentAlternatives(componentId, "");
paintLists(searchEl?.value.trim() || "");
} catch (err) {
if (!overlay.isConnected) return;
onError(err.message || "Не удалось загрузить компоненты");
}
}
overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close();
});
searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim()));
overlay.addEventListener("click", async (event) => {
const pick = event.target.closest("[data-pick-component-id]");
if (!pick) return;
event.preventDefault();
const replacementComponentId = pick.dataset.pickComponentId;
const replacementName = pick.dataset.pickComponentName || "Компонент";
if (!replacementComponentId) return;
close();
try {
await onPick(replacementComponentId, replacementName);
} catch (err) {
onError(err.message || "Не удалось заменить компонент");
}
});
await loadCatalog();
searchEl?.focus();
}
global.WespDailyPlanReplaceModal = {
open: openReplaceComponentModal,
fetchComponentAlternatives,
};
})(window);
@@ -0,0 +1,124 @@
/**
* Диалог «На какой срок» для skip/replace/adjustment плана на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function weekEndIso(planDate) {
const parts = String(planDate || "").split("-");
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
const jsDay = d.getDay();
const pyWeekday = jsDay === 0 ? 6 : jsDay - 1;
d.setDate(d.getDate() + (6 - pyWeekday));
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.planDate YYYY-MM-DD
* @param {string} [options.title]
* @param {HTMLElement} [options.overlayHost] куда монтировать overlay
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
* @param {function} [options.onError]
* @param {function} [options.beforeAppend] (overlay) => void dismiss other overlays
*/
function promptSkipDuration(options) {
const planDate = options.planDate || todayIso();
const title = options.title || "На какой срок исключить?";
const onError = options.onError || function () {};
const host = options.overlayHost || document.body;
const keepOverlays = options.keepOverlays || [];
return new Promise((resolve) => {
const weekEnd = weekEndIso(planDate);
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-skip-duration";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-skip-duration__dialog" role="dialog" aria-modal="true">' +
`<p class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-skip-duration__title">${escapeHtml(title)}</p>` +
'<div class="zt-k-hub-plan-skip-duration__options">' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
'<input type="radio" name="skip-duration" value="today" checked> Только сегодня</label>' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
`<input type="radio" name="skip-duration" value="week"> До конца недели (до ${escapeHtml(weekEnd)})</label>` +
'<label class="zt-k-hub-plan-skip-duration__option zt-k-hub-plan-skip-duration__option--date">' +
'<input type="radio" name="skip-duration" value="date"> До даты ' +
`<input type="date" class="form-control" data-skip-until-date min="${escapeHtml(planDate)}" value="${escapeHtml(planDate)}">` +
"</label></div>" +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-skip-duration__actions">' +
'<button type="button" class="btn btn-secondary" data-skip-duration-cancel>Отмена</button>' +
'<button type="button" class="btn btn-primary" data-skip-duration-confirm>Применить</button>' +
"</div></div>";
if (typeof options.beforeAppend === "function") {
options.beforeAppend(overlay);
} else if (keepOverlays.length) {
dismissOverlays(host, keepOverlays);
}
overlay.setAttribute("data-daily-plan-overlay", "1");
host.appendChild(overlay);
const dateInput = overlay.querySelector("[data-skip-until-date]");
const finish = (value) => {
if (overlay.isConnected) overlay.remove();
resolve(value);
};
overlay.addEventListener("click", (event) => {
if (event.target === overlay) finish(null);
});
overlay.querySelector("[data-skip-duration-cancel]")?.addEventListener("click", () => finish(null));
overlay.querySelector("[data-skip-duration-confirm]")?.addEventListener("click", () => {
const selected = overlay.querySelector('input[name="skip-duration"]:checked');
const mode = selected?.value || "today";
if (mode === "week") {
finish({ duration: "week" });
return;
}
if (mode === "date") {
const untilDate = dateInput?.value || planDate;
if (untilDate < planDate) {
onError("Дата окончания не может быть раньше начала");
return;
}
finish({ duration: "date", untilDate });
return;
}
finish({ duration: "today" });
});
});
}
function skipDurationBody(planDate, extra, title, options) {
return promptSkipDuration({ planDate, title, ...options }).then((choice) => {
if (!choice) return null;
return { date: planDate, ...choice, ...(extra || {}) };
});
}
global.WespDailyPlanSkipDuration = {
weekEndIso,
todayIso,
promptSkipDuration,
skipDurationBody,
escapeHtml,
};
})(window);
@@ -0,0 +1,209 @@
const KIOSK_SKELETON_MIN_MS = 300;
const KIOSK_SKELETON_EXIT_MS = 160;
const GRID_CARD_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-title"></div>
<div class="kiosk-skeleton-info">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
</div>`;
const GROUP_ITEM_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>`;
export function buildGridSkeletonCards(rowCount = 4) {
return Array.from({ length: rowCount }, () =>
`<div class="dispenser-card kiosk-skeleton-card" aria-hidden="true">${GRID_CARD_INNER}</div>`
).join("");
}
export function buildDispenserGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) {
const items = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-group-item">${GROUP_ITEM_INNER}</div>`
).join("");
return `<div class="kiosk-skeleton-groups" data-skeleton-variant="groups" aria-busy="true" aria-label="Загрузка групп выгрузки">${items}</div>`;
}
export function buildDuplicatePanelSkeletonHtml() {
return `<div class="kiosk-skeleton-panel" data-skeleton-variant="duplicate" aria-busy="true" aria-label="Загрузка данных">
<div class="kiosk-skeleton-recipe-header">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle"></div>
</div>
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) {
const rows = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-table-row">
<div class="dashboard-skeleton-bar"></div>
<div class="dashboard-skeleton-bar"></div>
</div>`
).join("");
return `<div class="kiosk-skeleton-table" data-skeleton-variant="ingredients" aria-busy="true" aria-label="Загрузка ингредиентов">${rows}</div>`;
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
function isGridSkeletonHost(container) {
if (!container) return false;
return (
container.getAttribute("aria-busy") === "true" &&
container.querySelector(":scope > .kiosk-skeleton-card") != null
);
}
function findSkeletonRoot(container) {
if (!container) return null;
if (isGridSkeletonHost(container)) return container;
return container.querySelector(
":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel"
);
}
export function mountKioskSkeleton(container, html, label) {
if (!container) return performance.now();
container.innerHTML = html;
container.classList.remove("kiosk-skeleton-host--exit");
if (container.querySelector(":scope > .kiosk-skeleton-card")) {
container.setAttribute("aria-busy", "true");
if (label) container.setAttribute("aria-label", label);
}
return performance.now();
}
export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) {
const elapsed = performance.now() - startedAt;
const rest = minMs - elapsed;
if (rest > 0) {
await new Promise((resolve) => setTimeout(resolve, rest));
}
}
async function fadeOutSkeleton(skel) {
if (!skel || prefersReducedMotion()) return;
const exitClass = isGridSkeletonHost(skel)
? "kiosk-skeleton-host--exit"
: skel.classList.contains("kiosk-skeleton-groups")
? "kiosk-skeleton-groups--exit"
: skel.classList.contains("kiosk-skeleton-table")
? "kiosk-skeleton-table--exit"
: "kiosk-skeleton-panel--exit";
skel.classList.add(exitClass);
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
skel.removeEventListener("transitionend", onEnd);
resolve();
};
const onEnd = (event) => {
if (event.target === skel && event.propertyName === "opacity") finish();
};
skel.addEventListener("transitionend", onEnd);
globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40);
});
}
function clearSkeletonHostAttrs(container) {
if (!container) return;
container.classList.remove("kiosk-skeleton-host--exit");
container.removeAttribute("aria-busy");
container.removeAttribute("aria-label");
}
export async function replaceKioskContent(container, html, options = {}) {
if (!container) return;
const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options;
const skel = findSkeletonRoot(container);
if (skel) {
await awaitKioskSkeletonMin(startedAt, minMs);
await fadeOutSkeleton(skel);
} else {
await awaitKioskSkeletonMin(startedAt, minMs);
}
container.innerHTML = html;
clearSkeletonHostAttrs(container);
}
export function clearStaleKioskSkeleton(container) {
if (!container) return;
if (findSkeletonRoot(container)) {
container.innerHTML = "";
clearSkeletonHostAttrs(container);
}
}
export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
const html = buildDuplicatePanelSkeletonHtml();
const startedAt = performance.now();
if (recipeInfoEl) {
recipeInfoEl.style.display = "block";
recipeInfoEl.innerHTML = html;
}
if (weightSectionEl) {
weightSectionEl.classList.add("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host) {
host.dataset.kioskSkeletonBackup = host.innerHTML;
host.innerHTML = `<div class="kiosk-skeleton-panel" aria-busy="true" aria-hidden="true">
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
}
return startedAt;
}
export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
if (recipeInfoEl) {
const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel");
if (skel) {
recipeInfoEl.style.display = "none";
recipeInfoEl.innerHTML = `
<h3>Рецепт: <span id="recipeName"></span></h3>
<p>Компонент <span id="currentIndex">0</span> из <span id="totalComponents">0</span></p>`;
}
}
if (weightSectionEl) {
weightSectionEl.classList.remove("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host?.dataset.kioskSkeletonBackup) {
host.innerHTML = host.dataset.kioskSkeletonBackup;
delete host.dataset.kioskSkeletonBackup;
}
}
}
const api = {
buildGridSkeletonCards,
buildDispenserGridSkeletonHtml,
buildPeriodRecipeGridSkeletonHtml,
buildUnloadingGroupsSkeletonHtml,
buildDuplicatePanelSkeletonHtml,
buildRecipeIngredientsSkeletonHtml,
mountKioskSkeleton,
replaceKioskContent,
awaitKioskSkeletonMin,
clearStaleKioskSkeleton,
mountDuplicatePanelSkeleton,
clearDuplicatePanelSkeleton,
};
if (typeof globalThis !== "undefined") {
globalThis.WespKioskSkeleton = api;
}
@@ -0,0 +1,78 @@
export class DispenserSelector {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
const recipeId = actionEl.dataset.recipeId;
const periodId = actionEl.dataset.periodId;
const dispenserId = actionEl.dataset.dispenserId;
const index = Number(actionEl.dataset.index);
switch (action) {
case "select-dispenser":
if (dispenserId) await this.handlers.selectDispenser(dispenserId);
return true;
case "select-recipe":
if (recipeId) await this.handlers.selectRecipe(recipeId);
return true;
case "select-period":
if (periodId) await this.handlers.selectPeriod(periodId);
return true;
case "copy-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.copyRecipe(recipeId);
return true;
case "delete-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.deleteRecipe(recipeId);
return true;
case "paste-recipe":
event.stopPropagation();
if (periodId) await this.handlers.pasteRecipe(periodId);
return true;
case "create-recipe":
event.stopPropagation();
if (periodId) await this.handlers.createNewRecipe(periodId);
return true;
case "move-recipe-up":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeUp(recipeId, index);
}
return true;
case "move-recipe-down":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeDown(recipeId, index);
}
return true;
case "unskip-recipe-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipRecipeToday === "function") {
await this.handlers.unskipRecipeToday(recipeId);
}
return true;
case "unskip-ingredient-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipIngredientPartsToday === "function") {
await this.handlers.unskipIngredientPartsToday(recipeId);
}
return true;
case "unskip-group-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipGroupPartsToday === "function") {
await this.handlers.unskipGroupPartsToday(recipeId);
}
return true;
case "recipe-drag-handle":
event.stopPropagation();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,95 @@
export class RecipeEditor {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "add-ingredient":
this.handlers.addIngredient();
return true;
case "toggle-unloading-link":
this.handlers.toggleUnloadingLink();
return true;
case "add-unloading-group":
this.handlers.addUnloadingGroup();
return true;
case "show-recipe-edit-off":
this.handlers.showRecipeEdit(false);
return true;
case "print-all-recipes":
this.handlers.printAllRecipesInPeriod();
return true;
case "print-recipe":
this.handlers.printRecipe();
return true;
case "save-and-exit":
await this.handlers.saveRecipe(null, true);
return true;
case "move-group-up":
this.handlers.moveGroupUp(actionEl);
return true;
case "move-group-down":
this.handlers.moveGroupDown(actionEl);
return true;
case "remove-unloading-group":
this.handlers.removeUnloadingGroup(actionEl);
return true;
case "move-ingredient-up":
this.handlers.moveIngredientUp(actionEl);
return true;
case "move-ingredient-down":
this.handlers.moveIngredientDown(actionEl);
return true;
case "remove-ingredient":
this.handlers.removeIngredient(actionEl);
return true;
case "open-ingredient-sheet": {
const row = actionEl.closest(".ingredient-row");
this.handlers.openIngredientMobileSheet(row);
return true;
}
case "close-ingredient-sheet":
this.handlers.closeIngredientMobileSheet();
return true;
case "remove-ingredient-sheet":
this.handlers.removeIngredientFromSheet();
return true;
case "open-unloading-group-sheet": {
const row = actionEl.closest(".unloading-group-row");
this.handlers.openUnloadingGroupMobileSheet(row);
return true;
}
case "close-unloading-group-sheet":
this.handlers.closeUnloadingGroupMobileSheet();
return true;
default:
return false;
}
}
handleChange(action, actionEl) {
switch (action) {
case "group-type-change":
this.handlers.handleGroupTypeChange(actionEl);
return true;
case "group-value-change":
this.handlers.validateGroupValue(actionEl);
return true;
case "ingredient-change":
this.handlers.handleIngredientChange(actionEl);
return true;
case "dry-matter-percent-change":
this.handlers.handleDryMatterPercentChange(actionEl);
return true;
case "recalculate-weights":
this.handlers.recalculateWeights();
return true;
case "recalculate-total-weight":
this.handlers.recalculateFromTotalWeight(actionEl);
return true;
default:
return false;
}
}
}
@@ -0,0 +1,875 @@
/**
* Drag-and-drop reorder for period recipe cards (#recipesList).
* Drag starts from .recipe-drag-handle; uses the same API as arrow buttons.
* Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line.
*/
import { cloneTemplateInto } from "./recipe-skeleton.js";
const MOBILE_REORDER_MQ = "(max-width: 768px)";
const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 };
const AUTO_SCROLL_EDGE_PX = 72;
const AUTO_SCROLL_MAX_STEP = 28;
const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking";
function isMobileReorderUi() {
return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false;
}
function activationThresholdPx() {
return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop;
}
function transferBlocksReorder() {
const listRoot = document.getElementById("recipesList");
if (
listRoot?.classList.contains("recipe-list-reorder-active") ||
document.body.classList.contains("recipe-list-reorder-active")
) {
return false;
}
return (
document.body.classList.contains("recipe-mobile-transfer-armed") ||
document.body.classList.contains("recipe-period-transfer-active")
);
}
function removeRecipeDragGhost() {
const existing = document.getElementById("recipe-card-drag-ghost-root");
if (existing?.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function removeInsertIndicator(listRoot) {
listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove();
}
function mountRecipeCardSkeletonInner(container) {
if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) {
container.innerHTML =
'<div class="dashboard-skeleton-card-inner"><div class="dashboard-skeleton-bar"></div></div>';
}
}
function getRecipeListItems(listRoot) {
return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
}
function resolveRawInsertIndexAtY(listRoot, clientY) {
const items = getRecipeListItems(listRoot);
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
return i;
}
}
return items.length;
}
function resolveInsertIndexAtY(listRoot, clientY, draggingItem) {
const items = getRecipeListItems(listRoot);
if (!draggingItem) {
return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length));
}
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return 0;
let toIndex = items.length;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
const inCard = clientY >= rect.top && clientY <= rect.bottom;
if (inCard && fromIndex > i) {
toIndex = i;
break;
}
if (inCard && fromIndex < i) {
toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1;
break;
}
if (clientY < rect.top + rect.height / 2) {
toIndex = i;
break;
}
toIndex = i + 1;
}
if (fromIndex < toIndex) {
toIndex -= 1;
}
return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1)));
}
function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return null;
const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem);
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0 || dropToIndex == null) return null;
const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1)));
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function mountRecipeCardDragGhost(item, dragEvent) {
removeRecipeDragGhost();
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
const w = item.offsetWidth;
ghost.style.cssText = [
"position:fixed",
"left:-10000px",
"top:0",
"width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
void ghost.offsetWidth;
const rect = item.getBoundingClientRect();
const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left));
const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top));
try {
dragEvent.dataTransfer.setDragImage(ghost, ox, oy);
} catch (_) {
/* старые движки без кастомного изображения */
}
}
function sanitizeDragGhostClone(node) {
node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action"));
node.querySelectorAll("button").forEach((btn) => {
btn.disabled = true;
btn.tabIndex = -1;
});
node.classList.remove("active", "recipe-card-drop-target");
}
function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) {
removeRecipeDragGhost();
if (isMobileReorderUi()) {
const ghost = item.cloneNode(true);
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone";
ghost.setAttribute("aria-hidden", "true");
sanitizeDragGhostClone(ghost);
const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24));
ghost.style.cssText = [
"position:fixed",
"left:0",
"top:0",
"width:" + width + "px",
"margin:0",
"transition:none",
"transform:none",
"z-index:1300",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY);
return ghost;
}
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`;
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY);
return ghost;
}
function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) {
if (!ghost) return;
if (grabOffsetX != null && grabOffsetY != null) {
ghost.style.left = `${clientX - grabOffsetX}px`;
ghost.style.top = `${clientY - grabOffsetY}px`;
ghost.style.transform = "none";
return;
}
ghost.style.left = `${clientX}px`;
ghost.style.top = `${clientY}px`;
ghost.style.transform = "translate(-50%, -50%)";
}
function slotPickActive() {
return Boolean(document.querySelector(".recipe-transfer-slot"));
}
function clearDropTargets(listRoot) {
listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => {
el.classList.remove("recipe-card-drop-target");
});
removeInsertIndicator(listRoot);
}
function updateInsertIndicator(listRoot, draggingItem, clientY) {
if (!isMobileReorderUi()) return;
const items = getRecipeListItems(listRoot);
if (!items.length) return;
let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator");
if (!line) {
line = document.createElement("div");
line.className = "recipe-card-insert-indicator";
line.setAttribute("aria-hidden", "true");
listRoot.appendChild(line);
}
const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY);
let yInList;
if (insertIndex >= items.length) {
const last = items[items.length - 1];
yInList = last.offsetTop + last.offsetHeight + 6;
} else {
yInList = items[insertIndex].offsetTop - 4;
}
line.style.top = `${yInList}px`;
line.hidden = false;
}
function highlightDropTarget(listRoot, draggingItem, clientY) {
clearDropTargets(listRoot);
if (isMobileReorderUi()) {
updateInsertIndicator(listRoot, draggingItem, clientY);
return;
}
const items = getRecipeListItems(listRoot);
for (const item of items) {
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
item.classList.add("recipe-card-drop-target");
break;
}
}
}
function configureMobileDragHandles(listRoot) {
if (!isMobileReorderUi()) return;
listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => {
handle.setAttribute("draggable", "false");
});
}
function listCanScroll(listRoot) {
return listRoot.scrollHeight > listRoot.clientHeight + 2;
}
function getPageScrollElement() {
return document.scrollingElement || document.documentElement;
}
function pageCanScrollUp() {
return getPageScrollElement().scrollTop > 0;
}
function pageCanScrollDown() {
const scrollEl = getPageScrollElement();
return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2;
}
function ensureMobileEdgeScrollHints() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return;
const wrap = document.createElement("div");
wrap.id = "recipe-reorder-edge-hints";
wrap.className = "recipe-reorder-edge-hints";
wrap.setAttribute("aria-hidden", "true");
wrap.innerHTML =
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--top">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-up" aria-hidden="true"></i><span>К краю — вверх</span></span>' +
"</div>" +
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--bottom">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-down" aria-hidden="true"></i><span>К краю — вниз</span></span>' +
"</div>";
document.body.appendChild(wrap);
}
function updateMobileEdgeScrollHints(clientY) {
if (!isMobileReorderUi()) return;
const wrap = document.getElementById("recipe-reorder-edge-hints");
if (!wrap) return;
const vh = window.innerHeight;
wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle(
"is-active",
clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp()
);
wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle(
"is-active",
clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown()
);
}
function removeReorderUiChrome() {
document.body.classList.remove(REORDER_TRACKING_BODY_CLASS);
document.getElementById("recipe-reorder-edge-hints")?.remove();
document.getElementById("recipe-reorder-floating-hint")?.remove();
}
function showRecipeReorderHint() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return;
const hint = document.createElement("div");
hint.id = "recipe-reorder-floating-hint";
hint.className = "recipe-reorder-hint recipe-reorder-hint--floating";
hint.setAttribute("role", "status");
hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка.";
document.body.appendChild(hint);
requestAnimationFrame(() => hint.classList.add("is-visible"));
globalThis.setTimeout(() => {
hint.classList.remove("is-visible");
globalThis.setTimeout(() => hint.remove(), 320);
}, 3200);
}
function autoScrollWindowAtEdge(clientY) {
const vh = window.innerHeight;
const scrollEl = getPageScrollElement();
let delta = 0;
if (clientY < AUTO_SCROLL_EDGE_PX) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (clientY > vh - AUTO_SCROLL_EDGE_PX) {
const distBottom = vh - clientY;
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += delta;
return scrollEl.scrollTop !== prev;
}
function autoScrollRecipeList(listRoot, clientY) {
if (!listCanScroll(listRoot)) return false;
const rect = listRoot.getBoundingClientRect();
const distTop = clientY - rect.top;
const distBottom = rect.bottom - clientY;
let delta = 0;
if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
listRoot.scrollTop += delta;
return true;
}
function autoScrollDuringReorder(listRoot, clientY) {
if (isMobileReorderUi()) {
return autoScrollWindowAtEdge(clientY);
}
return autoScrollRecipeList(listRoot, clientY);
}
function findTouchById(touchList, touchId) {
for (let i = 0; i < touchList.length; i++) {
if (touchList[i].identifier === touchId) {
return touchList[i];
}
}
return null;
}
function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) {
let touchDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let touchMoveListener = null;
let touchEndListener = null;
const detachTouchListeners = () => {
if (touchMoveListener) {
document.removeEventListener("touchmove", touchMoveListener, true);
touchMoveListener = null;
}
if (touchEndListener) {
document.removeEventListener("touchend", touchEndListener, true);
document.removeEventListener("touchcancel", touchEndListener, true);
touchEndListener = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!touchDrag?.active) {
stopAutoScroll();
return;
}
const y = touchDrag.lastY;
autoScrollDuringReorder(listRoot, y);
updateMobileEdgeScrollHints(y);
highlightDropTarget(listRoot, touchDrag.item, y);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishTouchDrag = () => {
detachTouchListeners();
stopAutoScroll();
if (!touchDrag) return;
touchDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
touchDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const activateTouchDrag = (touch) => {
if (!touchDrag || touchDrag.active) return;
touchDrag.active = true;
touchDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
document.body.classList.add(REORDER_TRACKING_BODY_CLASS);
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
ensureMobileEdgeScrollHints();
showRecipeReorderHint();
touchDrag.ghost = mountPointerFollowGhost(
touchDrag.item,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
startAutoScroll();
globalThis.navigator?.vibrate?.(8);
};
const handleTouchMove = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.touches, touchDrag.touchId);
if (!touch) return;
e.preventDefault();
if (transferBlocksReorder()) {
finishTouchDrag();
return;
}
touchDrag.lastY = touch.clientY;
const dx = Math.abs(touch.clientX - touchDrag.startX);
const dy = Math.abs(touch.clientY - touchDrag.startY);
if (!touchDrag.active && dx + dy < activationThresholdPx()) return;
if (!touchDrag.active) {
activateTouchDrag(touch);
}
positionPointerFollowGhost(
touchDrag.ghost,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
updateMobileEdgeScrollHints(touch.clientY);
highlightDropTarget(listRoot, touchDrag.item, touch.clientY);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item);
};
const handleTouchEnd = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.changedTouches, touchDrag.touchId);
if (!touch) return;
const { item, active, dropToIndex } = touchDrag;
finishTouchDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, touch, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
listRoot.addEventListener("recipe-transfer-armed", () => {
if (touchDrag?.active) {
finishTouchDrag();
} else if (touchDrag) {
detachTouchListeners();
removeReorderUiChrome();
touchDrag = null;
}
});
if (typeof MutationObserver !== "undefined") {
const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot));
handleObserver.observe(listRoot, { childList: true });
configureMobileDragHandles(listRoot);
}
listRoot.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
const touch = e.touches[0];
const handleRect = handle.getBoundingClientRect();
touchDrag = {
item,
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
lastY: touch.clientY,
grabOffsetX: touch.clientX - handleRect.left,
grabOffsetY: touch.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
e.stopPropagation();
touchMoveListener = handleTouchMove;
touchEndListener = handleTouchEnd;
document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false });
document.addEventListener("touchend", touchEndListener, { capture: true });
document.addEventListener("touchcancel", touchEndListener, { capture: true });
},
{ capture: true, passive: true }
);
}
function attachPointerReorder(listRoot, { moveRecipe, notyf }) {
if (isMobileReorderUi()) {
attachMobileTouchReorder(listRoot, { moveRecipe, notyf });
return;
}
let pointerDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let documentDragMove = null;
let documentDragEnd = null;
const detachDocumentDragListeners = () => {
if (documentDragMove) {
document.removeEventListener("pointermove", documentDragMove, true);
documentDragMove = null;
}
if (documentDragEnd) {
document.removeEventListener("pointerup", documentDragEnd, true);
document.removeEventListener("pointercancel", documentDragEnd, true);
documentDragEnd = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!pointerDrag?.active) {
stopAutoScroll();
return;
}
const y = pointerDrag.lastY;
autoScrollDuringReorder(listRoot, y);
highlightDropTarget(listRoot, pointerDrag.item, y);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishPointerDrag = () => {
detachDocumentDragListeners();
stopAutoScroll();
if (!pointerDrag) return;
pointerDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
pointerDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const isOverTransferHoverTarget = (clientX, clientY) => {
const el = document.elementFromPoint(clientX, clientY);
return !!(
el?.closest("#periodsList .list-item[data-period-id]") ||
el?.closest("#dispensersList .list-item[data-dispenser-id]")
);
};
const handlePointerMove = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
if (transferBlocksReorder()) {
finishPointerDrag();
return;
}
if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) {
pointerDrag.lastY = e.clientY;
return;
}
e.preventDefault();
pointerDrag.lastY = e.clientY;
const dx = Math.abs(e.clientX - pointerDrag.startX);
const dy = Math.abs(e.clientY - pointerDrag.startY);
if (!pointerDrag.active && dx + dy < activationThresholdPx()) return;
if (!pointerDrag.active) {
pointerDrag.active = true;
pointerDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
pointerDrag.ghost = mountPointerFollowGhost(
pointerDrag.item,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
startAutoScroll();
}
positionPointerFollowGhost(
pointerDrag.ghost,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
updateMobileEdgeScrollHints(e.clientY);
highlightDropTarget(listRoot, pointerDrag.item, e.clientY);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item);
};
const onPointerEnd = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
const { item, active, dropToIndex } = pointerDrag;
finishPointerDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, e, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
const attachDocumentDragListeners = () => {
if (documentDragMove) return;
documentDragMove = handlePointerMove;
documentDragEnd = onPointerEnd;
document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false });
document.addEventListener("pointerup", documentDragEnd, { capture: true });
document.addEventListener("pointercancel", documentDragEnd, { capture: true });
};
const cancelPointerDrag = () => {
if (!pointerDrag) return;
if (pointerDrag.active) return;
finishPointerDrag();
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag);
listRoot.addEventListener("recipe-transfer-armed", () => {
if (pointerDrag && !pointerDrag.active) {
detachDocumentDragListeners();
pointerDrag = null;
} else if (pointerDrag?.active) {
finishPointerDrag();
}
});
listRoot.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
const handleRect = handle.getBoundingClientRect();
pointerDrag = {
item,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
lastY: e.clientY,
grabOffsetX: e.clientX - handleRect.left,
grabOffsetY: e.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
attachDocumentDragListeners();
},
{ capture: true, passive: false }
);
}
export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) {
if (!listRoot || typeof moveRecipe !== "function") return;
let draggingItem = null;
let dropPending = false;
listRoot.addEventListener("dragstart", (e) => {
if (isMobileReorderUi()) {
e.preventDefault();
return;
}
if (transferBlocksReorder()) {
e.preventDefault();
return;
}
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
draggingItem = item;
item.classList.add("recipe-card-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.dataset.recipeId || "");
mountRecipeCardDragGhost(item, e);
});
listRoot.addEventListener("dragend", () => {
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
}
draggingItem = null;
dropPending = false;
clearDropTargets(listRoot);
});
listRoot.addEventListener("dragover", (e) => {
if (!draggingItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const target = e.target.closest(".list-item[data-recipe-id]");
clearDropTargets(listRoot);
if (target && target !== draggingItem) {
target.classList.add("recipe-card-drop-target");
}
});
listRoot.addEventListener("dragleave", (e) => {
if (!listRoot.contains(e.relatedTarget)) {
clearDropTargets(listRoot);
}
});
listRoot.addEventListener("drop", async (e) => {
if (!draggingItem || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
e.preventDefault();
e.stopPropagation();
clearDropTargets(listRoot);
const indices = resolveReorderDropIndex(listRoot, e, draggingItem);
if (!indices) return;
void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex);
});
const cancelHtml5Drag = () => {
if (listRoot.classList.contains("recipe-list-reorder-active")) return;
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
draggingItem = null;
}
dropPending = false;
clearDropTargets(listRoot);
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag);
if (!listRoot.dataset.recipeDragCancelBound) {
listRoot.dataset.recipeDragCancelBound = "1";
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
cancelHtml5Drag();
listRoot.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
},
true
);
}
attachPointerReorder(listRoot, { moveRecipe, notyf });
}
@@ -0,0 +1,166 @@
const MOBILE_SHEET_MQ = "(max-width: 768px)";
function getSheetParts(root) {
if (!root) return null;
const panel = root.querySelector(
".recipe-mobile-edit-sheet-panel, .ingredient-mobile-sheet-panel, .recipe-help-modal-panel"
);
const body = root.querySelector(
".recipe-mobile-edit-sheet-body, .ingredient-mobile-sheet-body, .recipe-help-modal-body"
);
const backdrop = root.querySelector(".recipe-mobile-edit-sheet-backdrop");
return panel ? { root, panel, body, backdrop } : null;
}
export function resetRecipeMobileSheetPresentation(root) {
const parts = getSheetParts(root);
if (!parts) return;
const { panel, backdrop } = parts;
panel.style.transform = "";
panel.style.transition = "";
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
if (backdrop) {
backdrop.style.opacity = "";
backdrop.style.transition = "";
}
}
function canStartSheetDrag(target, body) {
if (target.closest(".recipe-mobile-edit-sheet-done")) return false;
if (target.closest("button, input, select, textarea, a, label")) {
return !!target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-title");
}
if (target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-header")) {
return true;
}
if (body?.contains(target) && body.scrollTop <= 0) {
return true;
}
return false;
}
function bindSheetGestures(root, onClose, mobileQuery) {
const parts = getSheetParts(root);
if (!parts || typeof onClose !== "function") return;
const { panel, body, backdrop } = parts;
let dragState = null;
function clearDragState() {
dragState = null;
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
}
function applyDrag(deltaY) {
const offset = Math.max(0, deltaY);
panel.style.transition = "none";
panel.style.transform = `translateY(${offset}px)`;
if (backdrop) {
backdrop.style.transition = "none";
backdrop.style.opacity = String(Math.max(0, 1 - offset / 280));
}
}
function snapBack() {
panel.style.transition = "transform 0.24s ease";
panel.style.transform = "";
if (backdrop) {
backdrop.style.transition = "opacity 0.24s ease";
backdrop.style.opacity = "";
}
window.setTimeout(() => {
panel.style.transition = "";
if (backdrop) backdrop.style.transition = "";
clearDragState();
}, 240);
}
function dismissWithAnimation() {
panel.style.transition = "transform 0.22s ease";
panel.style.transform = "translateY(100%)";
if (backdrop) {
backdrop.style.transition = "opacity 0.22s ease";
backdrop.style.opacity = "0";
}
window.setTimeout(() => {
resetRecipeMobileSheetPresentation(root);
onClose();
}, 220);
}
function onPointerDown(event) {
if (!mobileQuery.matches || root.hidden) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
if (!canStartSheetDrag(event.target, body)) return;
dragState = {
pointerId: event.pointerId,
startY: event.clientY,
fromScrollableBody: !!(body && body.contains(event.target) && !event.target.closest(".recipe-mobile-edit-sheet-header, .recipe-mobile-edit-sheet-grab")),
};
panel.classList.add("recipe-mobile-edit-sheet--dragging");
panel.setPointerCapture(event.pointerId);
}
function onPointerMove(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
if (dragState.fromScrollableBody) {
if (deltaY <= 0 || (body && body.scrollTop > 0)) {
clearDragState();
resetRecipeMobileSheetPresentation(root);
return;
}
if (deltaY > 8) {
dragState.fromScrollableBody = false;
} else {
return;
}
}
if (deltaY > 0) {
event.preventDefault();
applyDrag(deltaY);
}
}
function onPointerEnd(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
const threshold = Math.min(96, panel.offsetHeight * 0.2);
clearDragState();
if (deltaY > threshold) {
dismissWithAnimation();
return;
}
snapBack();
}
panel.addEventListener("pointerdown", onPointerDown);
panel.addEventListener("pointermove", onPointerMove, { passive: false });
panel.addEventListener("pointerup", onPointerEnd);
panel.addEventListener("pointercancel", onPointerEnd);
}
export function initRecipeMobileSheetGestures(handlers = {}) {
const mobileQuery = window.matchMedia(MOBILE_SHEET_MQ);
const sheets = [
{
root: document.getElementById("ingredientMobileSheet"),
onClose: handlers.closeIngredientMobileSheet,
},
{
root: document.getElementById("unloadingGroupMobileSheet"),
onClose: handlers.closeUnloadingGroupMobileSheet,
},
{
root: document.getElementById("recipeHelpModal"),
onClose: handlers.closeRecipeHelpModal,
},
];
sheets.forEach(({ root, onClose }) => bindSheetGestures(root, onClose, mobileQuery));
}
@@ -0,0 +1,655 @@
/**
* Перенос рейса в другой период/оборудование: drag с ручки, удержание 12 с над целью, выбор позиции.
* Отмена: отпустить не над целью / Escape; другой период или оборудование снова удержать над нужной карточкой или заново перетащить с ручки.
*/
const LONG_HOVER_MS = 1400;
const MOBILE_TRANSFER_MQ = "(max-width: 768px)";
const MOBILE_TRANSFER_LONG_PRESS_MS = 750;
const MOBILE_TRANSFER_MOVE_CANCEL_PX = 14;
const TRANSFER_MIME = "application/x-wesp-recipe-transfer";
export function attachRecipePeriodTransfer({
getCurrentDispenserType,
getSelectedDispenser,
getSelectedPeriod,
selectDispenser,
selectPeriod,
loadRecipes,
loadPeriods,
notyf,
}) {
const recipesList = document.getElementById("recipesList");
let session = null;
let slotPick = false;
let transferInFlight = false;
let longHoverState = { kind: null, id: null, since: 0, fired: false };
let rafHoverId = null;
let listMutationTimer = null;
function clearLongHover() {
longHoverState = { kind: null, id: null, since: 0, fired: false };
if (rafHoverId != null) {
cancelAnimationFrame(rafHoverId);
rafHoverId = null;
}
}
function isTransferAllowed() {
return (
typeof getCurrentDispenserType === "function" &&
getCurrentDispenserType() === "dispenser" &&
getSelectedPeriod()
);
}
function clearEligibleHighlights() {
document.querySelectorAll(".recipe-transfer-eligible").forEach((el) => {
el.classList.remove("recipe-transfer-eligible");
});
}
function applyEligibleHighlights() {
clearEligibleHighlights();
if (!session || slotPick) return;
document.querySelectorAll("#dispensersList .list-item[data-dispenser-id]").forEach((el) => {
if (el.dataset.dispenserType === "mill") return;
el.classList.add("recipe-transfer-eligible");
});
document.querySelectorAll("#periodsList .list-item[data-period-id]").forEach((el) => {
const pid = el.dataset.periodId;
if (
String(pid) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
return;
}
el.classList.add("recipe-transfer-eligible");
});
}
function startSession(recipeId, fromDispenserId, fromPeriodId) {
clearSlotPickUi();
session = { recipeId, fromDispenserId, fromPeriodId };
slotPick = false;
/* Подсветку целей не включаем здесь — иначе reorder в том же периоде блокируется с первого mousedown. */
}
function setMobileTransferOverlay(active) {
if (!globalThis.matchMedia?.("(max-width: 768px)")?.matches) return;
document.body.classList.toggle("recipe-transfer-session", !!active);
}
let pointerCaptureEl = null;
let pointerCaptureId = null;
let pendingDragItem = null;
function releaseTransferPointerCapture() {
if (pointerCaptureEl && pointerCaptureId != null) {
try {
pointerCaptureEl.releasePointerCapture(pointerCaptureId);
} catch (_) {
/* ignore */
}
}
pointerCaptureEl = null;
pointerCaptureId = null;
}
function clearTransferSessionState({ emitDragCancel = true } = {}) {
if (listMutationTimer != null) {
clearTimeout(listMutationTimer);
listMutationTimer = null;
}
clearMobilePressState();
releaseTransferPointerCapture();
pendingDragItem = null;
mouseSessionActive = false;
session = null;
slotPick = false;
pointerSessionId = null;
document.body.classList.remove("recipe-period-transfer-active");
document.body.classList.remove("recipe-mobile-transfer-armed");
setMobileTransferOverlay(false);
clearEligibleHighlights();
clearSlotPickUi();
clearLongHover();
if (emitDragCancel) {
recipesList?.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
}
}
function endSession() {
clearTransferSessionState({ emitDragCancel: true });
}
function clearSlotPickUi() {
document.querySelectorAll(".recipe-transfer-slot").forEach((n) => n.remove());
}
function isValidDropTarget(toDispenser, toPeriod) {
if (!toDispenser || !toPeriod || !session) return false;
return !(
String(toDispenser) === String(session.fromDispenserId) &&
String(toPeriod) === String(session.fromPeriodId)
);
}
/** Позиция вставки по Y относительно карточек (без учёта кнопок-слотов). */
function insertIndexFromRecipesListDrop(listRoot, e) {
if (!listRoot) return 0;
const y = e.clientY;
const items = [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
if (y < r.top + r.height / 2) return i;
}
return items.length;
}
async function commitTransfer(toIndex) {
if (!session || transferInFlight) return;
transferInFlight = true;
const sess = session;
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
try {
if (!sess || !toDispenser || !toPeriod || !isValidDropTarget(toDispenser, toPeriod)) {
endSession();
return;
}
const url = `/api/feed_dispensers/${encodeURIComponent(toDispenser)}/periods/${encodeURIComponent(toPeriod)}/recipes/${encodeURIComponent(sess.recipeId)}/transfer`;
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from_dispenser_id: sess.fromDispenserId,
from_period_id: sess.fromPeriodId,
to_index: toIndex,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j.message || "Ошибка переноса";
if (notyf) {
if (r.status === 409) {
if (typeof notyf.warning === "function") notyf.warning(msg);
else notyf.error(msg);
} else {
notyf.error(msg);
}
}
clearSlotPickUi();
slotPick = false;
if (r.status === 409) {
try {
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
} catch (_) {
/* ignore */
}
}
endSession();
return;
}
clearSlotPickUi();
slotPick = false;
if (notyf) notyf.success(j.message || "Рейс перенесён");
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
endSession();
} catch (err) {
console.error(err);
if (notyf) notyf.error(err.message, { fallback: "Ошибка переноса" });
endSession();
} finally {
transferInFlight = false;
}
}
function scheduleHoverCheck() {
if (rafHoverId != null) return;
const tick = () => {
rafHoverId = null;
if (!session || longHoverState.kind == null) return;
const elapsed = performance.now() - longHoverState.since;
if (!longHoverState.fired && elapsed >= LONG_HOVER_MS) {
longHoverState.fired = true;
void fireLongHover(longHoverState.kind, longHoverState.id);
return;
}
if (!longHoverState.fired && session) {
rafHoverId = requestAnimationFrame(tick);
}
};
rafHoverId = requestAnimationFrame(tick);
}
function trackLongHover(kind, id) {
if (!session) return;
if (longHoverState.kind !== kind || longHoverState.id !== id) {
longHoverState = { kind, id, since: performance.now(), fired: false };
applyEligibleHighlights();
}
setMobileTransferOverlay(true);
scheduleHoverCheck();
}
async function fireLongHover(kind, id) {
if (!session) return;
transferHoverCommitPending = true;
try {
await fireLongHoverImpl(kind, id);
} finally {
transferHoverCommitPending = false;
}
}
async function fireLongHoverImpl(kind, id) {
if (!session) return;
if (kind === "dispenser") {
if (String(id) === String(getSelectedDispenser())) return;
await selectDispenser(id);
document.body.classList.add("recipe-period-transfer-active");
applyEligibleHighlights();
return;
}
if (kind === "period") {
if (
String(id) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
if (notyf) notyf.error("Выберите другой период");
return;
}
if (slotPick && String(id) === String(getSelectedPeriod())) return;
await selectPeriod(id);
document.body.classList.add("recipe-period-transfer-active");
enterSlotPickMode();
}
}
function enterSlotPickMode() {
slotPick = true;
clearEligibleHighlights();
clearLongHover();
setMobileTransferOverlay(true);
requestAnimationFrame(() => injectSlots());
}
/** Список рейсов перерисован (loadRecipes, смена периода, перестановка) — убрать «Вставить здесь», если они уже не в DOM. */
function syncSlotPickAfterListMutation() {
if (!session || !slotPick) return;
const list = document.getElementById("recipesList");
if (!list || list.querySelector(".recipe-transfer-slot")) return;
endSession();
}
function scheduleSyncAfterListMutation() {
clearTimeout(listMutationTimer);
listMutationTimer = setTimeout(() => {
listMutationTimer = null;
syncSlotPickAfterListMutation();
}, 80);
}
if (recipesList) {
new MutationObserver(() => scheduleSyncAfterListMutation()).observe(recipesList, {
childList: true,
subtree: false,
});
}
function injectSlots() {
clearSlotPickUi();
const list = document.getElementById("recipesList");
if (!list || !session) return;
const items = [...list.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i <= items.length; i++) {
const slot = document.createElement("button");
slot.type = "button";
slot.className = "recipe-transfer-slot";
slot.dataset.insertIndex = String(i);
slot.innerHTML =
'<span class="recipe-transfer-slot-inner"><span class="recipe-transfer-slot-label">Вставить здесь</span></span>';
slot.setAttribute("aria-label", `Вставить рейс на позицию ${i + 1}`);
slot.addEventListener("click", onSlotClick);
if (i < items.length) {
list.insertBefore(slot, items[i]);
} else {
list.appendChild(slot);
}
}
}
function onSlotClick(e) {
const slot = e.target.closest(".recipe-transfer-slot");
if (!slot || !session) return;
const toIndex = Number(slot.dataset.insertIndex);
if (Number.isNaN(toIndex)) return;
void commitTransfer(toIndex);
}
function tryBeginSessionFromPendingDrag(clientX, clientY) {
if (session || !pendingDragItem) return false;
const target = document.elementFromPoint(clientX, clientY);
if (target?.closest("#recipesList .list-item[data-recipe-id]")) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (!dItem && !pItem) return false;
if (dItem?.dataset.dispenserType === "mill") return false;
beginHandleSession(pendingDragItem);
return true;
}
function onDocumentDragOver(e) {
if (!session) {
if (tryBeginSessionFromPendingDrag(e.clientX, e.clientY)) {
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
return;
}
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
function handleSessionHoverAtPoint(clientX, clientY, dragEvent) {
if (!session) return;
if (slotPick && recipesList && recipesList.contains(document.elementFromPoint(clientX, clientY))) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
return;
}
if (slotPick) return;
const target = document.elementFromPoint(clientX, clientY);
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem) {
if (dItem.dataset.dispenserType === "mill") {
clearLongHover();
return;
}
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("dispenser", dItem.dataset.dispenserId);
return;
}
if (pItem) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("period", pItem.dataset.periodId);
return;
}
clearLongHover();
}
function handleSessionDropAtPoint(clientX, clientY, dragEvent) {
if (!session) return false;
const target = document.elementFromPoint(clientX, clientY);
if (slotPick && recipesList && target && recipesList.contains(target)) {
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
if (!isValidDropTarget(toDispenser, toPeriod)) {
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
endSession();
return true;
}
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
const toIndex = insertIndexFromRecipesListDrop(recipesList, { clientY });
void commitTransfer(toIndex);
return true;
}
if (slotPick) return false;
const recipeDrop = target?.closest("#recipesList .list-item[data-recipe-id]");
if (recipeDrop) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem || pItem) {
dragEvent?.preventDefault?.();
return true;
}
dragEvent?.preventDefault?.();
endSession();
return true;
}
function onDocumentDropCapture(e) {
if (!session) return;
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function beginHandleSession(item) {
if (!item || item.dataset.index === undefined) return;
if (!isTransferAllowed()) return;
const recipeId = item.dataset.recipeId;
const fromDispenserId = getSelectedDispenser();
const fromPeriodId = getSelectedPeriod();
startSession(recipeId, fromDispenserId, fromPeriodId);
}
function onDragStartCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
/* Сессию transfer не открываем сразу — иначе ломается повторный reorder в том же списке. */
pendingDragItem = item;
try {
e.dataTransfer.setData(
TRANSFER_MIME,
JSON.stringify({
recipeId: item.dataset.recipeId,
fromDispenserId: getSelectedDispenser(),
fromPeriodId: getSelectedPeriod(),
})
);
} catch (_) {
/* ignore */
}
}
function onDragEndCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
if (slotPick && session) return;
pendingDragItem = null;
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
if (recipesList.classList.contains("recipe-list-reorder-active")) {
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
return;
}
if (session) clearTransferSessionState({ emitDragCancel: true });
}
let pointerSessionId = null;
let mouseSessionActive = false;
let transferHoverCommitPending = false;
let mobilePressState = null;
function isMobileTransferUi() {
return globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches ?? false;
}
function clearMobilePressState() {
if (mobilePressState?.longPressTimer) {
clearTimeout(mobilePressState.longPressTimer);
}
mobilePressState = null;
}
function cancelMobilePressForReorder() {
clearMobilePressState();
}
function armMobileTransferSession(item) {
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
document.body.classList.add("recipe-mobile-transfer-armed");
recipesList?.dispatchEvent(new CustomEvent("recipe-transfer-armed", { bubbles: true }));
globalThis.navigator?.vibrate?.(12);
if (notyf) {
notyf.success("Перетащите рейс на период или оборудование");
}
}
function onPointerDownCapture(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
if (!isTransferAllowed()) return;
if (isMobileTransferUi()) {
clearMobilePressState();
mobilePressState = {
pointerId: e.pointerId,
item,
startX: e.clientX,
startY: e.clientY,
longPressTimer: globalThis.setTimeout(() => {
if (!mobilePressState) return;
if (recipesList?.classList.contains("recipe-list-reorder-active")) {
clearMobilePressState();
return;
}
mobilePressState.armed = true;
pointerSessionId = mobilePressState.pointerId;
armMobileTransferSession(mobilePressState.item);
}, MOBILE_TRANSFER_LONG_PRESS_MS),
};
return;
}
/* Desktop: reorder владеет ручкой через pointer/HTML5; transfer — через dragstart + hover. */
}
function onPointerMoveCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId && !mobilePressState.armed) {
const dx = Math.abs(e.clientX - mobilePressState.startX);
const dy = Math.abs(e.clientY - mobilePressState.startY);
if (dx + dy >= MOBILE_TRANSFER_MOVE_CANCEL_PX) {
clearMobilePressState();
}
}
if (!session || pointerSessionId == null || e.pointerId !== pointerSessionId) return;
if (globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches && e.clientY < window.innerHeight * 0.38) {
setMobileTransferOverlay(true);
}
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onPointerUpCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
const wasArmed = mobilePressState.armed;
clearMobilePressState();
if (!wasArmed) return;
}
const ownsPointer = pointerSessionId != null && e.pointerId === pointerSessionId;
if (ownsPointer) {
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
}
if (!session) return;
if (!ownsPointer) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
clearTransferSessionState({ emitDragCancel: true });
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function onPointerCancelCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
clearMobilePressState();
}
if (pointerSessionId == null || e.pointerId !== pointerSessionId) return;
pointerSessionId = null;
if (slotPick && session) return;
endSession();
}
recipesList?.addEventListener("dragstart", onDragStartCapture, true);
recipesList?.addEventListener("dragend", onDragEndCapture, true);
recipesList?.addEventListener("recipe-reorder-active", () => {
cancelMobilePressForReorder();
pendingDragItem = null;
/* Не шлём recipe-list-drag-cancel — иначе reorder обрывается в pointer-path. */
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
});
function onMouseDownCapture(e) {
/* Desktop mousedown-сессия ломала повторный reorder; transfer на десктопе — dragstart + dragover. */
if (!isMobileTransferUi()) return;
if (e.button !== 0 || pointerSessionId != null) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
mouseSessionActive = true;
}
function onMouseMoveCapture(e) {
if (!session || !mouseSessionActive || slotPick) return;
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onMouseUpCapture(e) {
if (!mouseSessionActive) return;
mouseSessionActive = false;
if (!session) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
endSession();
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
recipesList?.addEventListener("pointerdown", onPointerDownCapture, true);
recipesList?.addEventListener("mousedown", onMouseDownCapture, true);
document.addEventListener("mousemove", onMouseMoveCapture, true);
document.addEventListener("mouseup", onMouseUpCapture, true);
document.addEventListener("pointermove", onPointerMoveCapture, true);
document.addEventListener("pointerup", onPointerUpCapture, true);
document.addEventListener("pointercancel", onPointerCancelCapture, true);
document.addEventListener("dragover", onDocumentDragOver, true);
document.addEventListener("drop", onDocumentDropCapture, true);
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !session) return;
endSession();
});
return { endSession };
}
@@ -0,0 +1,35 @@
const LIST_LABELS = {
dispensers: "Загрузка кормораздатчиков",
periods: "Загрузка периодов",
recipes: "Загрузка рейсов",
mill: "Загрузка рецептов",
period: "Загрузка рейсов",
};
/**
* Structural loading один spinner на список (не 4 копии).
*/
export function buildRecipeRowsSkeletonHtml(variant = "period") {
const label = LIST_LABELS[variant] || LIST_LABELS.period;
return `<div class="zt-loading-list dashboard-list-skeleton" data-skeleton-variant="${variant}" aria-busy="true" aria-label="${label}">
<div class="zt-loading-placeholder">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">${label}</p>
</div>
</div>`;
}
export function cloneTemplateOuterHtml(templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el) return "";
return el.outerHTML;
}
export function cloneTemplateInto(container, templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el || !container) return false;
container.appendChild(el.cloneNode(true));
return true;
}
@@ -0,0 +1,42 @@
export class SyncPanel {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "open-settings":
event.preventDefault();
await this.handlers.openSettings(event);
return true;
case "close-settings":
this.handlers.closeSettings();
return true;
case "toggle-credentials-form":
this.handlers.toggleCredentialsForm();
return true;
case "toggle-sync-form":
this.handlers.toggleSyncForm();
return true;
case "change-credentials":
await this.handlers.changeCredentials();
return true;
case "sync-edit-client":
event.stopPropagation();
this.handlers.editSyncClientSettings(actionEl);
return true;
case "sync-delete-client":
event.stopPropagation();
this.handlers.deleteSyncClientSettings(actionEl);
return true;
case "sync-save-client":
this.handlers.saveSyncClientDisplayName(actionEl.dataset.nodeId || "");
return true;
case "sync-cancel-edit":
await this.handlers.loadSyncClientsSettings();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,40 @@
export class ReconnectingEventSource {
constructor(url, { retryDelayMs = 2000, maxRetryDelayMs = 15000 } = {}) {
this.url = url;
this.retryDelayMs = retryDelayMs;
this.maxRetryDelayMs = maxRetryDelayMs;
this.onmessage = null;
this.onerror = null;
this._closed = false;
this._es = null;
this._currentDelay = retryDelayMs;
this._connect();
}
_connect() {
if (this._closed) return;
this._es = new EventSource(this.url);
this._es.onmessage = (event) => {
this._currentDelay = this.retryDelayMs;
if (this.onmessage) this.onmessage(event);
};
this._es.onerror = (event) => {
if (this.onerror) this.onerror(event);
this._scheduleReconnect();
};
}
_scheduleReconnect() {
if (this._closed) return;
if (this._es) this._es.close();
const delay = this._currentDelay;
this._currentDelay = Math.min(this._currentDelay * 2, this.maxRetryDelayMs);
setTimeout(() => this._connect(), delay);
}
close() {
this._closed = true;
if (this._es) this._es.close();
this._es = null;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,367 @@
/**
* Редактор серверной SQLite: дерево bind таблицы, сетка с сохранением через /api/admin/db/*.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function parseInputValue(raw, sampleOriginal) {
const t = raw.trim();
if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null;
if (t === "") return "";
if (/^-?\d+$/.test(t)) return Number(t);
if (/^-?\d+\.\d+$/.test(t)) return Number(t);
if (t === "true") return 1;
if (t === "false") return 0;
return raw;
}
function closeModal() {
const m = document.getElementById("adminSqliteModal");
if (m) m.hidden = true;
document.body.style.overflow = "";
}
let browseState = {
bind: "recipes",
table: null,
limit: 50,
offset: 0,
page: null,
};
/** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */
let dbTreePickCallback = null;
async function fetchJson(url, options) {
const r = await fetch(url, options);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(data.message || `HTTP ${r.status}`);
}
return data;
}
function renderTree(binds, preferredBind, onPickTable) {
dbTreePickCallback = onPickTable;
const root = document.getElementById("adminDbTree");
if (!root) return;
root.innerHTML = binds
.map((b) => {
const open = b.bind === preferredBind ? "is-open" : "";
const rows = (b.tables || [])
.map(
(t) =>
`<li class="wesp-db-tree-leaf"><button type="button" class="wesp-db-tree-table ant-btn ant-btn-sm" data-bind="${escHtml(b.bind)}" data-table="${escHtml(t.name)}">${escHtml(t.name)}</button></li>`,
)
.join("");
return `<li class="wesp-db-tree-node ${open}">
<button type="button" class="wesp-db-tree-caret" aria-expanded="${b.bind === preferredBind ? "true" : "false"}"><span class="wesp-db-tree-caret-icon"></span></button>
<span class="wesp-db-tree-folder">${escHtml(b.bind)}</span>
<ul class="wesp-db-tree-children">${rows}</ul>
</li>`;
})
.join("");
root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => {
btn.addEventListener("click", () => {
const li = btn.closest(".wesp-db-tree-node");
if (!li) return;
const open = li.classList.toggle("is-open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
});
root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
btn.addEventListener("click", () => {
const bind = btn.getAttribute("data-bind");
const table = btn.getAttribute("data-table");
root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
onPickTable(bind, table);
});
});
}
function renderGrid(data, setStatus) {
const meta = document.getElementById("adminDbGridMeta");
const wrap = document.getElementById("adminDbGridWrap");
const pager = document.getElementById("adminDbPager");
if (!meta || !wrap || !pager) return;
browseState.page = data;
const { columns, rows, total, has_rowid: hasRowid } = data;
meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`;
const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c]));
const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid");
let thead =
"<thead><tr>" +
(hasRowid ? "<th>rowid</th>" : "") +
displayCols.map((n) => `<th title="${escHtml(colMetaByName[n]?.type || "")}">${escHtml(n)}</th>`).join("") +
"<th class=\"wesp-db-grid-actions-col\">Действия</th></tr></thead>";
const body = rows
.map((row) => {
const rowKey = {};
if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid;
(data.pk_names || []).forEach((pk) => {
if (pk in row) rowKey[pk] = row[pk];
});
const cells = displayCols
.map((name) => {
const cm = colMetaByName[name];
const val = row[name];
if (cm && !cm.editable) {
return `<td class="wesp-db-cell-readonly">(BLOB)</td>`;
}
const v = val == null ? "" : String(val);
return `<td><input type="text" class="ant-input wesp-db-cell" data-col="${escHtml(name)}" value="${escHtml(v)}"/></td>`;
})
.join("");
const ridCell = hasRowid
? `<td class="wesp-db-cell-readonly">${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}</td>`
: "";
const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey));
const rowJsonEnc = encodeURIComponent(JSON.stringify(row));
return `<tr data-row-key="${rowKeyEnc}" data-row-json="${rowJsonEnc}">
${ridCell}${cells}
<td class="wesp-db-row-actions">
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary wesp-db-row-save"><span>Сохранить</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger wesp-db-row-delete"><span>Удалить</span></button>
</td>
</tr>`;
})
.join("");
wrap.innerHTML = `<table class="ant-table wesp-db-grid-table">${thead}<tbody>${body}</tbody></table>`;
wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
btn.disabled = true;
try {
const prev = await fetchJson("/api/admin/db/row/delete-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`;
if (prev.truncated) msg += "\n\n… (в списке не все строки)";
msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?";
if (!window.confirm(msg)) {
setStatus("Удаление отменено");
return;
}
await fetchJson("/api/admin/db/row/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
}),
});
setStatus(`Удалено строк: ${prev.total}`);
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка удаления");
} finally {
btn.disabled = false;
}
});
});
wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => {
btn.addEventListener("click", async () => {
const tr = btn.closest("tr");
if (!tr) return;
let rowKey;
let original;
try {
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D"));
} catch (e) {
setStatus("Ошибка разбора строки", true);
return;
}
const changes = {};
tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => {
const col = inp.getAttribute("data-col");
if (!col || inp.readOnly) return;
const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined;
const newVal = parseInputValue(inp.value, oldVal);
const same =
(oldVal == null && (newVal === "" || newVal === null)) ||
JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal);
if (!same) {
changes[col] = newVal === "" && oldVal == null ? null : newVal;
}
});
if (!Object.keys(changes).length) {
setStatus("Нет изменений в строке");
return;
}
btn.disabled = true;
try {
await fetchJson("/api/admin/db/row", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bind: browseState.bind,
table: browseState.table,
row_key: rowKey,
changes,
}),
});
setStatus("Строка сохранена на сервере");
await loadTablePage(setStatus);
} catch (e) {
setStatus(e.message, true, "Ошибка сохранения");
} finally {
btn.disabled = false;
}
});
});
const prev = browseState.offset > 0;
const next = browseState.offset + rows.length < total;
pager.innerHTML = `
<button type="button" class="ant-btn ant-btn-sm" id="adminDbPrev" ${prev ? "" : "disabled"}><span>Назад</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="adminDbNext" ${next ? "" : "disabled"}><span>Вперёд</span></button>
`;
document.getElementById("adminDbPrev")?.addEventListener("click", async () => {
browseState.offset = Math.max(0, browseState.offset - browseState.limit);
await loadTablePage(setStatus);
});
document.getElementById("adminDbNext")?.addEventListener("click", async () => {
browseState.offset += browseState.limit;
await loadTablePage(setStatus);
});
}
async function loadTablePage(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (errEl) errEl.textContent = "";
if (!browseState.table) return;
const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`;
const data = await fetchJson(u);
renderGrid(data, setStatus);
}
export async function refreshAdminDbTree(setStatus) {
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || modal.hidden) return;
try {
const tree = await fetchJson("/api/admin/db/tree");
const pref = browseState.bind || "recipes";
const cb = dbTreePickCallback;
if (typeof cb !== "function") {
if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается");
return;
}
renderTree(tree.binds || [], pref, cb);
document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
if (
btn.getAttribute("data-bind") === browseState.bind &&
btn.getAttribute("data-table") === browseState.table
) {
document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
}
});
if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function refreshAdminDbGrid(setStatus) {
const errEl = document.getElementById("adminSqliteError");
if (!browseState.table) {
if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве");
return;
}
if (errEl) errEl.textContent = "";
try {
await loadTablePage(setStatus);
if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера");
} catch (e) {
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export async function openAdminDbBrowser(options = {}) {
const { preferredBind = "recipes", setStatus } = options;
const modal = document.getElementById("adminSqliteModal");
const errEl = document.getElementById("adminSqliteError");
if (!modal || !errEl) {
if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true);
return;
}
browseState = {
bind: preferredBind,
table: null,
limit: 50,
offset: 0,
page: null,
};
modal.hidden = false;
document.body.style.overflow = "hidden";
errEl.textContent = "";
document.getElementById("adminDbGridMeta").textContent = "";
document.getElementById("adminDbGridWrap").innerHTML =
'<p class="wesp-db-browser__muted">Выберите таблицу в дереве слева.</p>';
document.getElementById("adminDbPager").innerHTML = "";
try {
const tree = await fetchJson("/api/admin/db/tree");
renderTree(tree.binds || [], preferredBind, async (bind, table) => {
browseState.bind = bind;
browseState.table = table;
browseState.offset = 0;
errEl.textContent = "";
try {
await loadTablePage(setStatus);
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
});
if (typeof setStatus === "function") setStatus("Дерево БД загружено");
} catch (e) {
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
}
}
export function wireAdminDbBrowserModal() {
document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal);
document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
/**
* Единый словарь подписей analytics для зоотехника.
*/
(function (global) {
global.WespAnalyticsCopy = {
tabs: {
summary: "Итоги",
comparison: "Сравнение",
journal: "Подробно",
reports: "Отчёты",
alerts: "Отклонения",
},
summary: {
overloadTitle: "Перерасход",
overloadHint: "Насыпали больше, чем в плане",
overloadHintZero: "Перерасхода нет — загрузка в пределах плана",
underloadTitle: "Недогруз",
underloadHint: "Риск для надоя: коровы могли недополучить корм",
underloadHintZero: "Недогруза нет — план выполнен",
netTitle: "Итого по деньгам",
netHintOverload: "Преобладает перерасход — прямой убыток бюджета",
netHintUnderload: "Преобладает недогруз — экономия сейчас, риск падения надоев",
netHintBalanced: "Перерасход и недогруз за период уравновешены",
topTitle: "Где больше всего потеряли",
allAlertsLink: "Все отклонения →",
openStockLink: "Открыть склад →",
empty: "За выбранный период отчётов нет",
exportExcel: "Excel",
},
comparison: {
sectionLoading: "Загрузка",
sectionUnloading: "Выгрузка",
legendBase: "По рецепту",
legendPlan: "На сегодня",
legendActual: "Сделали",
noteExecution: "Ошибка при загрузке",
noteUnloadingExecution: "Ошибка при выгрузке",
mixerRemainderLabel: "Остаток в миксере",
noteExcluded: "Исключено из плана",
noteAdjusted: "План скорректирован",
empty: "За этот период рейсов с отчётами нет",
},
stock: {
bannerTitle: "Обратите внимание:",
daysRecipe: "Хватит по рецепту",
daysAdjusted: "Хватит (с учётом плана)",
},
formatRub(value) {
const n = Number(value) || 0;
return `${Math.round(n).toLocaleString("ru-RU")}`;
},
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,196 @@
/**
* Вкладка «Сравнение» на /reports.
*/
(function (global) {
const COPY = () => global.WespAnalyticsCopy || {};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getDateParams() {
const from = document.getElementById("date-from")?.value || "";
const to = document.getElementById("date-to")?.value || "";
return { date_from: from, date_to: to };
}
function buildUrl() {
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
return `/api/analytics/plan-fact?${params.toString()}`;
}
function barWidth(value, max) {
if (!max || max <= 0) return 0;
return Math.min(100, (Number(value) / max) * 100);
}
function faultBadge(fault, executionLabel) {
const c = COPY().comparison || {};
if (fault === "execution") {
return `<span class="analytics-pf-fault analytics-pf-fault--execution">${escapeHtml(
executionLabel || c.noteExecution || ""
)}</span>`;
}
if (fault === "excluded" || fault === "zootech_ok") {
return `<span class="analytics-pf-fault analytics-pf-fault--excluded">${escapeHtml(c.noteExcluded || "")}</span>`;
}
if (fault === "adjusted" || fault === "zootech") {
return `<span class="analytics-pf-fault analytics-pf-fault--adjusted">${escapeHtml(c.noteAdjusted || "")}</span>`;
}
return "";
}
function legendHtml() {
const c = COPY().comparison || {};
return (
`<div class="analytics-pf-legend">` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--base"></i>${escapeHtml(c.legendBase)}</span>` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--plan"></i>${escapeHtml(c.legendPlan)}</span>` +
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--actual"></i>${escapeHtml(c.legendActual)}</span>` +
`</div>`
);
}
function renderMetricRows(rows, maxKg, options) {
const executionLabel = options?.executionLabel;
return rows
.map((c) => {
const notes = (c.notes || [])
.map((n) => {
const note = typeof n === "string" ? { text: n, kind: "execution" } : n;
const kind = note.kind === "zootech" ? "zootech" : "execution";
const cls =
kind === "zootech"
? "analytics-pf-note analytics-pf-note--zootech"
: "analytics-pf-note analytics-pf-note--execution";
return `<li class="${cls}">${escapeHtml(note.text)}</li>`;
})
.join("");
const faultBadgeHtml = faultBadge(c.fault, executionLabel);
const compCls =
c.skippedToday && (c.fault === "excluded" || c.fault === "zootech_ok")
? "analytics-pf-comp analytics-pf-comp--excluded"
: options?.rowClass || "analytics-pf-comp";
return (
`<div class="${compCls}">` +
`<div class="analytics-pf-comp__head">` +
`<div class="analytics-pf-comp__name">${escapeHtml(c.name)}</div>` +
faultBadgeHtml +
`</div>` +
`<div class="analytics-pf-bars">` +
`<div class="analytics-pf-bar analytics-pf-bar--base" title="${escapeHtml(COPY().comparison?.legendBase)}"><span style="width:${barWidth(c.baseKg, maxKg)}%"></span><em>${Number(c.baseKg).toFixed(1)}</em></div>` +
`<div class="analytics-pf-bar analytics-pf-bar--plan" title="${escapeHtml(COPY().comparison?.legendPlan)}"><span style="width:${barWidth(c.planTodayKg, maxKg)}%"></span><em>${Number(c.planTodayKg).toFixed(1)}</em></div>` +
`<div class="analytics-pf-bar analytics-pf-bar--actual" title="${escapeHtml(COPY().comparison?.legendActual)}"><span style="width:${barWidth(c.actualKg, maxKg)}%"></span><em>${Number(c.actualKg).toFixed(1)}</em></div>` +
`</div>` +
(notes ? `<ul class="analytics-pf-notes">${notes}</ul>` : "") +
`</div>`
);
})
.join("");
}
function sectionHtml(title, body) {
if (!body) return "";
return (
`<section class="analytics-pf-section">` +
`<h3 class="analytics-pf-section__title">${escapeHtml(title)}</h3>` +
body +
`</section>`
);
}
function renderItem(item) {
const comps = item.components || [];
const groups = item.unloadingGroups || [];
const mixer = item.mixerRemainder || null;
const compMax = Math.max(...comps.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
const unloadMax = Math.max(...groups.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
const compHtml = renderMetricRows(comps, compMax, {
executionLabel: COPY().comparison?.noteExecution,
});
const unloadHtml = renderMetricRows(groups, unloadMax, {
executionLabel: COPY().comparison?.noteUnloadingExecution,
});
const mixerHtml = mixer
? (() => {
const kg = Number(mixer.actualKg);
const sign = kg > 0 ? "+" : "";
const cls =
kg < 0
? "analytics-pf-mixer-line analytics-pf-mixer-line--negative"
: "analytics-pf-mixer-line";
return (
`<p class="${cls}">${escapeHtml(
COPY().comparison?.mixerRemainderLabel || "Остаток в миксере"
)}: <strong>${sign}${kg.toFixed(1)}</strong> кг</p>`
);
})()
: "";
const when = item.startTime ? escapeHtml(item.startTime.slice(0, 16).replace("T", " ")) : "";
const unloadingSection =
groups.length || mixer
? sectionHtml(
COPY().comparison?.sectionUnloading || "Выгрузка",
legendHtml() + unloadHtml + mixerHtml
)
: "";
return (
`<article class="analytics-pf-card" data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
`<header class="analytics-pf-card__head">` +
`<strong>${escapeHtml(item.recipeName)}</strong>` +
`<span class="text-muted small">${when}</span>` +
`</header>` +
sectionHtml(
COPY().comparison?.sectionLoading || "Загрузка",
legendHtml() + compHtml
) +
unloadingSection +
`</article>`
);
}
function render(data) {
const el = document.getElementById("analyticsPlanFactList");
if (!el) return;
const items = data.items || [];
if (!items.length) {
el.innerHTML = `<div class="empty-state zt-empty wesp-content-reveal"><p class="zt-empty__hint">${escapeHtml(COPY().comparison?.empty || "")}</p></div>`;
return;
}
el.innerHTML = `<div class="analytics-pf-list">${items.map(renderItem).join("")}</div>`;
el.querySelectorAll("[data-loading-report-id]").forEach((card) => {
card.addEventListener("click", () => {
const id = card.getAttribute("data-loading-report-id");
if (id) global.WespFeedAlerts?.openReportFromAlert?.(id);
});
});
global.WespZootechModals?.revealContent(el.querySelector(".analytics-pf-list"));
}
async function load() {
const el = document.getElementById("analyticsPlanFactList");
if (!el) return;
el.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка сравнения…</p></div>';
try {
const resp = await fetch(buildUrl());
if (!resp.ok) throw new Error("Не удалось загрузить сравнение");
render(await resp.json());
} catch (err) {
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function onFiltersApplied() {
if (global.WespFeedAlerts?.getCurrentView?.() === "comparison") load();
}
global.WespAnalyticsPlanFact = { load, onFiltersApplied };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,151 @@
/**
* Вкладка «Итоги» на /reports.
*/
(function (global) {
const COPY = () => global.WespAnalyticsCopy || {};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getDateParams() {
const from = document.getElementById("date-from")?.value || "";
const to = document.getElementById("date-to")?.value || "";
return { date_from: from, date_to: to };
}
function buildUrl() {
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
return `/api/analytics/finance?${params.toString()}`;
}
function valueToneClass(kind) {
if (kind === "loss") return "analytics-kpi-card__value--loss";
if (kind === "risk") return "analytics-kpi-card__value--risk";
return "";
}
function netHint(data) {
const c = COPY().summary || {};
if (data.dominantIssue === "overload") return c.netHintOverload || "";
if (data.dominantIssue === "underload") return c.netHintUnderload || "";
return c.netHintBalanced || "";
}
function overloadHint(data) {
const c = COPY().summary || {};
if (Number(data.overloadRub) > 0) return c.overloadHint || "";
return c.overloadHintZero || c.overloadHint || "";
}
function underloadHint(data) {
const c = COPY().summary || {};
if (Number(data.underloadRub) > 0) return c.underloadHint || "";
return c.underloadHintZero || c.underloadHint || "";
}
function netTone(data) {
if (data.dominantIssue === "overload") return "loss";
if (data.dominantIssue === "underload") return "risk";
return "";
}
function renderTopBar(data) {
const top = data.topComponents || [];
if (!top.length) {
return `<p class="text-muted small mb-0">${escapeHtml(COPY().summary?.empty || "")}</p>`;
}
const maxVal = Math.max(
...top.map((row) => Math.max(row.overloadRub, row.underloadRub)),
1
);
const rows = top
.map((row) => {
const isOverload = row.overloadRub > row.underloadRub;
const main = isOverload
? `${COPY().formatRub(row.overloadRub)} перерасход`
: `${COPY().formatRub(row.underloadRub)} недогруз`;
const tone = isOverload ? "analytics-top-row__value--loss" : "analytics-top-row__value--risk";
const widthPct = Math.round(
Math.min(100, (Math.max(row.overloadRub, row.underloadRub) / maxVal) * 1000)
) / 10;
return (
`<div class="analytics-top-row">` +
`<span class="analytics-top-row__name">${escapeHtml(row.name)}</span>` +
`<span class="analytics-top-row__bar analytics-top-row__bar--${isOverload ? "loss" : "risk"}">` +
`<span style="width:${widthPct}%"></span></span>` +
`<span class="analytics-top-row__value ${tone}">${escapeHtml(main)}</span>` +
`</div>`
);
})
.join("");
return `<div class="analytics-top-list">${rows}</div>`;
}
function render(data) {
const el = document.getElementById("analyticsSummaryList");
if (!el) return;
const c = COPY().summary || {};
const overloadTone = Number(data.overloadRub) > 0 ? "loss" : "";
const underloadTone = Number(data.underloadRub) > 0 ? "risk" : "";
const netT = netTone(data);
el.innerHTML =
`<div class="analytics-kpi-grid wesp-content-reveal">` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.overloadTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(overloadTone)}">${escapeHtml(COPY().formatRub(data.overloadRub))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(overloadHint(data))}</div>` +
`</div>` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.underloadTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(underloadTone)}">${escapeHtml(COPY().formatRub(data.underloadRub))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(underloadHint(data))}</div>` +
`</div>` +
`<div class="analytics-kpi-card">` +
`<div class="analytics-kpi-card__title">${escapeHtml(c.netTitle)}</div>` +
`<div class="analytics-kpi-card__value ${valueToneClass(netT)}">${escapeHtml(COPY().formatRub(Math.abs(data.netRub)))}</div>` +
`<div class="analytics-kpi-card__hint">${escapeHtml(netHint(data))}</div>` +
`</div>` +
`</div>` +
`<div class="analytics-top-block zt-section wesp-content-reveal">` +
`<h3 class="analytics-section-title">${escapeHtml(c.topTitle)}</h3>` +
renderTopBar(data) +
`</div>` +
`<div class="analytics-links wesp-content-reveal">` +
`<button type="button" class="btn btn-link btn-sm p-0" data-analytics-goto-alerts>${escapeHtml(c.allAlertsLink)}</button>` +
`</div>`;
el.querySelector("[data-analytics-goto-alerts]")?.addEventListener("click", () => {
global.WespFeedAlerts?.openJournalAlerts?.();
});
global.WespZootechModals?.revealContent(el);
}
async function load() {
const el = document.getElementById("analyticsSummaryList");
if (!el) return;
el.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка итогов…</p></div>';
try {
const resp = await fetch(buildUrl());
if (!resp.ok) throw new Error("Не удалось загрузить итоги");
render(await resp.json());
} catch (err) {
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function onFiltersApplied() {
if (global.WespFeedAlerts?.getCurrentView?.() === "summary") load();
}
function init() {
/* экспорт — WespReportsExport */
}
global.WespAnalyticsSummary = { init, load, onFiltersApplied };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,665 @@
/**
* Коррекция СВ% компонентов в плане overlay в стиле K-hub.
* Редактируется только СВ%; превью через /api/recipes/calculate.
*/
(function (global) {
function todayIso() {
return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10);
}
function escapeHtml(value) {
return (
global.WespDailyPlanSkipDuration?.escapeHtml?.(value) ||
String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;")
);
}
function fmtNum(value, digits) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
return n.toFixed(digits).replace(/\.?0+$/, "");
}
function roundWphNum(value) {
const n = Number(value);
if (!Number.isFinite(n)) return NaN;
if (n > 0 && n < 0.01) return 0.01;
return Math.round(n * 100) / 100;
}
function fmtRange(values, digits) {
const nums = values.filter((n) => Number.isFinite(n));
if (!nums.length) return "—";
const min = Math.min(...nums);
const max = Math.max(...nums);
const a = fmtNum(min, digits);
const b = fmtNum(max, digits);
return a === b ? a : `${a}${b}`;
}
function usageEntries(item) {
const usages = Array.isArray(item.usages) ? item.usages : [];
if (usages.length) {
return usages.map((u) => ({
wph: Number(u.masterWeightPerHead),
dmPh: Number(u.masterDryMatterPerHead),
dryMatterLocked: !!u.dryMatterLocked,
}));
}
return [
{
wph: Number(item.masterWeightPerHead),
dmPh: Number(item.masterDryMatterPerHead),
dryMatterLocked: false,
},
];
}
const PHYS_WEIGHT_HINT = "Этот компонент выдается строго по физическому весу";
function itemIsDmEditable(item) {
return itemHasLockedUsage(item);
}
function componentCellHtml(item) {
const editable = itemIsDmEditable(item);
const lockedCount = lockedUsageEntries(item).length;
const trips = Number(item.usageCount);
let meta = "";
if (editable && lockedCount > 0) {
meta = `<div class="zt-k-hub-plan-norms__meta">${lockedCount} рец. с замком СВ</div>`;
} else if (!editable) {
meta = `<div class="zt-k-hub-plan-norms__meta zt-k-hub-plan-norms__meta--weight">${escapeHtml(PHYS_WEIGHT_HINT)}</div>`;
} else if (Number.isFinite(trips) && trips > 1) {
meta = `<div class="zt-k-hub-plan-norms__meta">${trips} рейсов в плане</div>`;
}
const lockIcon = editable
? ""
: `<i class="fas fa-lock zt-k-hub-plan-norms__weight-lock" title="${escapeHtml(PHYS_WEIGHT_HINT)}" aria-hidden="true"></i>`;
return (
`<div class="zt-k-hub-plan-norms__component">` +
`<div class="zt-k-hub-plan-norms__name-row">` +
lockIcon +
`<div class="zt-k-hub-plan-norms__name">${escapeHtml(item.componentName)}</div>` +
`</div>` +
meta +
"</div>"
);
}
function flashWeightLockedHint(input) {
const cell = input?.closest(".zt-k-hub-plan-norms__dm-cell");
if (!cell) return;
let hint = cell.querySelector("[data-norms-weight-hint]");
if (!hint) {
hint = document.createElement("span");
hint.className = "zt-k-hub-plan-norms__weight-hint";
hint.dataset.normsWeightHint = "1";
hint.textContent = PHYS_WEIGHT_HINT;
cell.appendChild(hint);
}
hint.classList.add("is-visible");
clearTimeout(hint._hideTimer);
hint._hideTimer = setTimeout(() => hint.classList.remove("is-visible"), 2800);
}
function notify(msg, type) {
const n = global.WespZootechNotify?.createAdapter?.();
if (!n) return;
if (type === "error") n.error(msg, { skipRecord: true });
else n.success(msg, { skipRecord: true });
}
function overlayHost() {
return document.getElementById("zootechNotificationCenterModal") || document.body;
}
let overlayEl = null;
let items = [];
const rowPreviewTimers = new Map();
let recalcSeq = 0;
let lockedOnlyFilter = true;
function itemHasLockedUsage(item) {
return usageEntries(item).some((entry) => entry.dryMatterLocked);
}
function lockedUsageEntries(item) {
return usageEntries(item).filter((entry) => entry.dryMatterLocked);
}
function clearRowPreviewTimers() {
rowPreviewTimers.forEach((timer) => clearTimeout(timer));
rowPreviewTimers.clear();
}
function closeModal() {
clearRowPreviewTimers();
recalcSeq += 1;
if (overlayEl?.isConnected) overlayEl.remove();
overlayEl = null;
}
function actionBtn(action, attrs, title, icon) {
return (
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
`data-${action}="1" ${attrs} title="${escapeHtml(title)}">` +
`<i class="fas ${icon}" aria-hidden="true"></i></button>`
);
}
function masterPreviewForItem(item) {
const wphs = [];
const dmPhs = [];
for (const entry of usageEntries(item)) {
if (Number.isFinite(entry.wph)) wphs.push(roundWphNum(entry.wph));
if (Number.isFinite(entry.dmPh)) dmPhs.push(entry.dmPh);
}
return {
dmPh: fmtRange(dmPhs, 4),
wph: fmtRange(wphs, 2),
};
}
function previewCompareHtml(before, after) {
const was = before || "—";
const now = after || "—";
if (was === "—" && now === "—") return "—";
if (was === now) {
return `<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>`;
}
return (
`<span class="zt-k-hub-plan-norms-preview zt-k-hub-plan-norms-preview--compare">` +
`<span class="zt-k-hub-plan-norms-preview__was">${escapeHtml(was)}</span>` +
`<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>` +
`</span>`
);
}
async function previewForItem(item, planPct) {
const pct = Number(planPct);
if (!Number.isFinite(pct) || pct < 0) return { dmPh: "—", wph: "—" };
const lockedEntries = lockedUsageEntries(item);
const wphs = [];
const dmPhs = [];
await Promise.all(
lockedEntries.map(async (entry) => {
if (!Number.isFinite(entry.dmPh)) return;
try {
const response = await fetch("/api/recipes/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ingredients: [
{
component_id: item.componentId,
dryMatter: pct,
dryMatterPerHead: entry.dmPh,
},
],
headsCount: 1,
tripPercent: 100,
calculateFromDryMatter: true,
}),
});
if (!response.ok) return;
const result = await response.json();
const calc = result.ingredients?.[0];
if (!calc) return;
dmPhs.push(Number(entry.dmPh));
const rawWph = calc.weightPerHead !== undefined ? calc.weightPerHead : calc.weight_per_head;
if (rawWph != null) wphs.push(roundWphNum(rawWph));
} catch (_) {
/* ignore */
}
})
);
for (const entry of usageEntries(item)) {
if (entry.dryMatterLocked) continue;
if (!Number.isFinite(entry.wph)) continue;
wphs.push(roundWphNum(entry.wph));
if (pct >= 0) dmPhs.push(entry.wph * (pct / 100));
}
return {
dmPh: fmtRange(dmPhs, 4),
wph: fmtRange(wphs, 2),
};
}
function planEditValue(item) {
if (item.planDryMatterPct != null) return item.planDryMatterPct;
return item.masterDryMatterPct ?? item.dryMatterPct;
}
function masterBaseDryMatterLabel(item) {
const catalog = Number(item.dryMatterPct);
if (Number.isFinite(catalog)) {
return `${fmtNum(catalog, 2)}%`;
}
const min = Number(item.masterDryMatterPctMin);
const max = Number(item.masterDryMatterPctMax);
if (Number.isFinite(min) && Number.isFinite(max)) {
const a = fmtNum(min, 2);
const b = fmtNum(max, 2);
return a === b ? `${a}%` : `${a}${b}%`;
}
const master = Number(item.masterDryMatterPct);
if (Number.isFinite(master)) return `${fmtNum(master, 2)}%`;
return null;
}
function baseDryMatterHintHtml(item) {
const label = masterBaseDryMatterLabel(item);
if (!label) return "";
return (
`<span class="zt-k-hub-plan-norms__base" data-norms-base title="СВ% в справочнике компонентов">` +
`База: ${escapeHtml(label)}</span>`
);
}
function setRowPreviewLoading(row) {
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
const wphEl = row.querySelector("[data-norms-preview-wph]");
if (dmPhEl) {
dmPhEl.textContent = "…";
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
}
if (wphEl) {
wphEl.textContent = "…";
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
}
}
function setPreviewCell(el, before, after) {
if (!el) return;
const was = before || "—";
const now = after || "—";
if (was === now) {
el.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
el.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(now)}</span>`;
return;
}
el.classList.add("zt-k-hub-plan-norms-preview-cell--compare");
el.innerHTML = previewCompareHtml(was, now);
}
async function updateRowPreview(row, seq) {
const componentId = row.dataset.componentId;
const item = items.find((x) => String(x.componentId) === String(componentId));
if (!item) return;
const input = row.querySelector("[data-norms-input]");
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
const wphEl = row.querySelector("[data-norms-preview-wph]");
const masterPrev = masterPreviewForItem(item);
if (row.dataset.normsEditable !== "1") {
if (dmPhEl) {
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
dmPhEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.dmPh)}</span>`;
}
if (wphEl) {
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
wphEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.wph)}</span>`;
}
return;
}
const raw = input?.value;
if (raw === "" || raw == null) {
setPreviewCell(dmPhEl, null, null);
setPreviewCell(wphEl, null, null);
return;
}
setRowPreviewLoading(row);
const next = await previewForItem(item, raw);
if (seq !== recalcSeq || !overlayEl?.contains(row)) return;
setPreviewCell(dmPhEl, masterPrev.dmPh, next.dmPh);
setPreviewCell(wphEl, masterPrev.wph, next.wph);
}
function scheduleRowPreview(row, debounceMs) {
if (!row) return;
const componentId = row.dataset.componentId;
if (!componentId) return;
const existing = rowPreviewTimers.get(componentId);
if (existing) clearTimeout(existing);
const seq = recalcSeq;
const delay = debounceMs == null ? 300 : debounceMs;
if (delay <= 0) {
rowPreviewTimers.delete(componentId);
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
return;
}
rowPreviewTimers.set(
componentId,
setTimeout(() => {
rowPreviewTimers.delete(componentId);
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
}, delay)
);
}
function updateAllPreviews() {
overlayEl?.querySelectorAll("[data-norms-row]").forEach((row) => scheduleRowPreview(row, 0));
}
function buildRowHtml(item) {
const editable = itemIsDmEditable(item);
const val = planEditValue(item);
const inputVal = val != null && Number.isFinite(Number(val)) ? Number(val) : "";
const undo = item.adjustedToday
? actionBtn("norms-undo", "", "Вернуть как в рецепте", "fa-undo")
: "";
const replaceBtn = actionBtn(
"norms-replace",
"",
"Заменить компонент во всех рецептах",
"fa-exchange-alt"
);
const applyBtn = editable ? actionBtn("norms-apply", "", "Сохранить для плана", "fa-check") : "";
const rowMods = [
item.adjustedToday ? "ingredient-row--adjusted" : "",
editable ? "" : "ingredient-row--weight-locked",
]
.filter(Boolean)
.join(" ");
const inputClass = editable
? "form-control zt-k-hub-plan-norms-input dry-matter-percent-input"
: "form-control zt-k-hub-plan-norms-input zt-k-hub-plan-norms-input--locked dry-matter-percent-input";
const inputAttrs = editable
? `data-norms-input aria-label="СВ% на план"`
: `readonly tabindex="-1" data-norms-input data-norms-input-locked aria-label="СВ% — только чтение" aria-disabled="true" title="${escapeHtml(PHYS_WEIGHT_HINT)}"`;
return (
`<div class="zt-data-grid__row ingredient-row${rowMods ? " " + rowMods : ""}" ` +
`data-norms-row data-component-id="${escapeHtml(item.componentId)}" ` +
`data-norms-editable="${editable ? "1" : "0"}" role="row">` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-k-hub-plan-norms__component-cell" data-label="Компонент" role="gridcell">` +
componentCellHtml(item) +
"</div>" +
`<div class="zt-data-grid__cell ingredient-detail-cell dry-matter-percent zt-data-grid__cell--num" data-label="СВ, %" role="gridcell">` +
`<div class="zt-k-hub-plan-norms__dm-cell">` +
`<input type="number" class="${inputClass}" step="0.01" min="0" max="100" ` +
`value="${inputVal === "" ? "" : escapeHtml(String(inputVal))}" inputmode="decimal" ${inputAttrs}>` +
baseDryMatterHintHtml(item) +
`</div></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="СВ/гол, кг" role="gridcell">` +
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-dmph>…</span></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="Вес/гол, кг" role="gridcell">` +
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-wph>…</span></div>` +
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--actions recipe-table-actions-cell" data-label="Действия" role="gridcell">` +
`<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions" role="group">` +
applyBtn +
replaceBtn +
undo +
"</div></div></div>"
);
}
function ensureOverlay() {
closeModal();
overlayEl = document.createElement("div");
overlayEl.className = "zt-k-hub-plan-overlay zt-k-hub-plan-norms";
overlayEl.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-norms__dialog" role="dialog" aria-modal="true">' +
'<div class="zt-k-hub-plan-subdialog__header">' +
'<h3 class="zt-k-hub-plan-subdialog__title">Коррекция СВ% в плане</h3>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-norms-close aria-label="Закрыть">×</button>' +
"</div>" +
'<p class="zt-k-hub-plan-norms__lead">Только на выбранный срок. Рецепты в справочнике не меняются. Пересчёт кг/гол — только где в рецепте включён замок СВ.</p>' +
'<label class="zt-k-hub-plan-replace__search-label">Поиск компонента</label>' +
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-norms-search placeholder="Начните вводить название…" autocomplete="off">' +
'<label class="zt-k-hub-plan-norms__filter">' +
'<span class="zt-checkbox">' +
'<input type="checkbox" class="zt-checkbox__input" data-norms-locked-only checked>' +
'<span class="zt-checkbox__box" aria-hidden="true"></span>' +
"</span>" +
'<span class="zt-k-hub-plan-norms__filter-text">Только компоненты в рецептах с замком СВ</span></label>' +
'<div class="zt-k-hub-plan-norms__table-wrap">' +
'<div class="zt-data-grid zt-data-grid--norms" id="planNormsGrid" 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--actions" role="columnheader">Действия</div>' +
"</div>" +
'<div class="zt-data-grid__body" data-norms-body role="rowgroup"></div>' +
"</div></div></div>";
overlayEl.querySelector("[data-norms-close]")?.addEventListener("click", closeModal);
overlayEl.addEventListener("click", (e) => {
if (e.target === overlayEl) closeModal();
});
overlayEl.querySelector("[data-norms-search]")?.addEventListener("input", renderRows);
overlayEl.querySelector("[data-norms-locked-only]")?.addEventListener("change", (event) => {
lockedOnlyFilter = !!event.target.checked;
renderRows();
});
overlayEl.querySelector("[data-norms-body]")?.addEventListener("input", (event) => {
const row = event.target.closest("[data-norms-row]");
if (row && event.target.matches("[data-norms-input]:not([data-norms-input-locked])")) {
scheduleRowPreview(row);
}
});
overlayEl.querySelector("[data-norms-body]")?.addEventListener("click", onTableClick);
overlayEl.setAttribute("data-daily-plan-overlay", "1");
const host = overlayHost();
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (el !== overlayEl) el.remove();
});
host.appendChild(overlayEl);
return overlayEl;
}
function filteredItems() {
let list = items;
if (lockedOnlyFilter) {
list = list.filter(itemHasLockedUsage);
}
const q = (overlayEl?.querySelector("[data-norms-search]")?.value || "").trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
if ((item.componentName || "").toLowerCase().includes(q)) return true;
return (item.recipes || []).some((name) => String(name).toLowerCase().includes(q));
});
}
function renderRows() {
const body = overlayEl?.querySelector("[data-norms-body]");
if (!body) return;
const rows = filteredItems();
if (!rows.length) {
body.innerHTML =
'<div class="zt-k-hub-plan-replace__empty zt-k-hub-plan-norms__empty">' +
(lockedOnlyFilter
? "Нет компонентов в рецептах с замком СВ на эту дату"
: "Нет компонентов в плане на эту дату") +
"</div>";
return;
}
body.innerHTML = rows.map((item) => buildRowHtml(item)).join("");
updateAllPreviews();
}
async function loadItems() {
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
if (!dispenserId) {
notify("Сначала выберите кормораздатчик в плане", "error");
return false;
}
const date = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const r = await fetch(
"/api/daily-plan/component-norms?dispenser_id=" +
encodeURIComponent(dispenserId) +
"&date=" +
encodeURIComponent(date),
{ credentials: "same-origin" }
);
if (!r.ok) {
notify("Не удалось загрузить компоненты плана", "error");
return false;
}
const data = await r.json();
items = Array.isArray(data.items) ? data.items : [];
return true;
}
async function applyRow(row) {
if (row.dataset.normsEditable !== "1") {
flashWeightLockedHint(row.querySelector("[data-norms-input-locked]"));
return;
}
const componentId = row.dataset.componentId;
const input = row.querySelector("[data-norms-input]");
const raw = input?.value;
if (raw === "" || raw == null) {
notify("Введите СВ%", "error");
return;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0 || value > 100) {
notify("СВ% должно быть от 0 до 100", "error");
return;
}
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const payload = await skip.skipDurationBody(
planDate,
{ componentId, dryMatter: value },
"На какой срок сохранить коррекцию?",
{
overlayHost: overlayHost(),
keepOverlays: [".zt-k-hub-plan-norms"],
onError: (msg) => notify(msg, "error"),
}
);
if (!payload) return;
const r = await fetch("/api/daily-plan/adjustments/components", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
notify(data.message || "Ошибка сохранения", "error");
return;
}
notify("Коррекция сохранена");
await refreshAfterChange();
}
async function undoRow(row) {
const componentId = row.dataset.componentId;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const r = await fetch(
"/api/daily-plan/adjustments/components?component_id=" +
encodeURIComponent(componentId) +
"&date=" +
encodeURIComponent(planDate),
{ method: "DELETE", credentials: "same-origin" }
);
if (!r.ok) {
const data = await r.json().catch(() => ({}));
notify(data.message || "Не удалось сбросить", "error");
return;
}
notify("СВ% как в рецепте");
await refreshAfterChange();
}
async function replaceRow(row) {
const componentId = row.dataset.componentId;
const item = items.find((x) => String(x.componentId) === String(componentId));
const label = item?.componentName || "Компонент";
const replaceModal = global.WespDailyPlanReplaceModal;
const skip = global.WespDailyPlanSkipDuration;
if (!replaceModal || !skip) return;
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
if (!dispenserId) {
notify("Сначала выберите кормораздатчик в плане", "error");
return;
}
const durationHost = overlayHost();
await replaceModal.open({
componentId,
label,
overlayHost: overlayHost(),
keepOverlays: [".zt-k-hub-plan-norms"],
subtitle: "Замена применится во всех рецептах плана, где используется этот компонент.",
onError: (msg) => notify(msg, "error"),
onPick: async (replacementComponentId, replacementName) => {
const body = await skip.skipDurationBody(
planDate,
{ componentId, replacementComponentId, dispenserId },
"На какой срок заменить?",
{
overlayHost: durationHost,
keepOverlays: [".zt-k-hub-plan-norms"],
onError: (msg) => notify(msg, "error"),
}
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/components", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.message || "Не удалось заменить компонент");
}
const count = Number(data.count) || 0;
notify(
count > 1
? `Компонент заменён на «${replacementName}» в ${count} рецептах`
: `Компонент заменён на «${replacementName}»`
);
await refreshAfterChange();
},
});
}
async function refreshAfterChange() {
await loadItems();
renderRows();
global.WespDailyPlanPanel?.reload?.();
if (typeof global.reloadAfterSkipRestore === "function") global.reloadAfterSkipRestore();
}
function onTableClick(event) {
const lockedInput = event.target.closest("[data-norms-input-locked]");
if (lockedInput) {
event.preventDefault();
flashWeightLockedHint(lockedInput);
return;
}
const row = event.target.closest("[data-norms-row]");
if (!row) return;
if (event.target.closest("[data-norms-apply]")) {
event.preventDefault();
applyRow(row);
} else if (event.target.closest("[data-norms-replace]")) {
event.preventDefault();
replaceRow(row);
} else if (event.target.closest("[data-norms-undo]")) {
event.preventDefault();
undoRow(row);
}
}
async function openModal() {
lockedOnlyFilter = true;
ensureOverlay();
const ok = await loadItems();
if (ok) renderRows();
}
global.openDailyPlanComponentNormsModal = openModal;
global.closeDailyPlanComponentNormsModal = closeModal;
})(window);
@@ -0,0 +1,930 @@
/**
* Панель «План на день» внутри hub-модалки «К».
*/
(function (global) {
const DAILY_PLAN_DISPENSER_KEY = "wesp-daily-plan-dispenser";
const ALL_DISPENSERS_ID = "__all_dispensers__";
const ALL_MILLS_ID = "__all_mills__";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatDistribution(group) {
if (group?.distributionLabel) return group.distributionLabel;
const type = group?.distributionType || "percent";
const value = group?.value ?? "";
if (type === "heads") return `${value} гол.`;
return `${value}%`;
}
function todayIso() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function createDailyPlanPanel() {
let rootEl = null;
let dateEl = null;
let dispenserEl = null;
let contentEl = null;
let dispensers = [];
let undoToastTimer = null;
function getSelectedDispenserId() {
return dispenserEl?.value || "";
}
function getSelectedPlanDate() {
return planDateValue();
}
function notifySuccess(message) {
try {
global.WespZootechNotify?.createAdapter?.()?.success?.(message, { skipRecord: true });
} catch {
/* ignore */
}
}
function notifyError(message, fallback) {
try {
global.WespZootechNotify?.createAdapter?.()?.error?.(message, {
fallback: fallback || message,
skipRecord: true,
});
} catch {
/* ignore */
}
}
function renderToolbar() {
return (
'<div class="zt-k-hub-plan__toolbar">' +
'<div class="zt-k-hub-plan__field">' +
'<label class="form-label">Дата</label>' +
'<input type="date" class="form-control form-control-sm" data-daily-plan-date>' +
"</div>" +
'<div class="zt-k-hub-plan__field">' +
'<label class="form-label">Кормораздатчик</label>' +
'<select class="form-control form-control-sm" data-daily-plan-dispenser>' +
'<option value="">Загрузка…</option></select>' +
"</div>" +
'<button type="button" class="btn btn-primary btn-sm zt-k-hub-plan__pdf" data-action="daily-plan-pdf">' +
'<i class="fas fa-file-pdf"></i> Скачать PDF</button>' +
"</div>"
);
}
function mount(container) {
rootEl = container;
rootEl.innerHTML =
'<div class="zt-k-hub-plan">' +
renderToolbar() +
'<div class="zt-k-hub-plan__content" data-daily-plan-content>' +
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>' +
"</div></div>";
dateEl = rootEl.querySelector("[data-daily-plan-date]");
dispenserEl = rootEl.querySelector("[data-daily-plan-dispenser]");
contentEl = rootEl.querySelector("[data-daily-plan-content]");
if (dateEl) dateEl.value = todayIso();
dateEl?.addEventListener("change", () => loadPlan());
dispenserEl?.addEventListener("change", () => {
if (dispenserEl.value) {
try {
localStorage.setItem(DAILY_PLAN_DISPENSER_KEY, dispenserEl.value);
} catch {
/* ignore */
}
}
loadPlan();
});
rootEl.querySelector("[data-action='daily-plan-pdf']")?.addEventListener("click", downloadPdf);
rootEl.addEventListener("click", onPanelClick);
loadDispensers();
}
function planDateValue() {
return dateEl?.value || todayIso();
}
async function onPanelClick(event) {
const btn = event.target.closest("[data-action]");
if (!btn || !rootEl?.contains(btn)) return;
const action = btn.dataset.action;
if (action === "daily-plan-skip-trip") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const recipeName = btn.dataset.recipeName || "Рейс";
if (recipeId) await skipTrip(recipeId, recipeName);
return;
}
if (action === "daily-plan-unskip-trip") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
if (recipeId) await unskipTrip(recipeId);
return;
}
if (action === "daily-plan-skip-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
const label = btn.dataset.label || "Компонент";
if (recipeId && ingredientId) await skipIngredient(recipeId, ingredientId, label);
return;
}
if (action === "daily-plan-unskip-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
if (recipeId && ingredientId) await unskipIngredient(recipeId, ingredientId);
return;
}
if (action === "daily-plan-skip-unloading-group") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const groupId = btn.dataset.groupId;
const label = btn.dataset.label || "Группа";
if (recipeId && groupId) await skipUnloadingGroup(recipeId, groupId, label);
return;
}
if (action === "daily-plan-unskip-unloading-group") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const groupId = btn.dataset.groupId;
if (recipeId && groupId) await unskipUnloadingGroup(recipeId, groupId);
return;
}
if (action === "daily-plan-replace-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
const componentId = btn.dataset.componentId;
const label = btn.dataset.label || "Компонент";
if (recipeId && ingredientId && componentId) {
await openReplaceIngredientModal(recipeId, ingredientId, componentId, label);
}
return;
}
if (action === "daily-plan-undo-replace-ingredient") {
event.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
if (recipeId && ingredientId) await undoReplaceIngredient(recipeId, ingredientId);
return;
}
if (action === "daily-plan-undo-adjust-norm") {
event.preventDefault();
const componentId = btn.dataset.componentId;
if (componentId) await undoComponentNormAdjustment(componentId);
}
}
function formatKg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
if (Number.isInteger(n)) return String(n);
return n.toFixed(2).replace(/\.?0+$/, "");
}
function replacementRowTitle(ing) {
if (!ing?.replacedToday) return "";
if (ing.recalculationMode === "dry_matter") {
return (
`Пересчёт по СВ: ${formatKg(ing.originalDryMatterPerHead)} кг/гол сохранено, ` +
`вес ${formatKg(ing.originalWeightPerHead)}${formatKg(ing.weightPerHead)} кг/гол ` +
`(СВ ${formatKg(ing.originalDryMatterPct)}% → ${formatKg(ing.dryMatterPct)}%)`
);
}
return (
`Пересчёт по весу: ${formatKg(ing.weightPerHead)} кг/гол сохранено, ` +
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)}${formatKg(ing.dryMatterPerHead)} кг/гол`
);
}
function adjustmentRowTitle(ing) {
if (!ing?.adjustedToday) return "";
return (
`Норма в плане: ${formatKg(ing.originalWeightPerHead)}${formatKg(ing.weightPerHead)} кг/гол, ` +
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)}${formatKg(ing.dryMatterPerHead)}`
);
}
function skippedIngredientHint(ing) {
const wph = Number(ing?.baselineWeightPerHead ?? ing?.weightPerHead);
const total = Number(ing?.baselineTotalKg);
if (!Number.isFinite(wph) || !Number.isFinite(total)) return "";
return `Было: ${formatKg(wph)} кг/гол (${formatKg(total)} кг)`;
}
function renderSkippedValueCell(hint) {
if (!hint) {
return '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>';
}
return (
`<td class="zt-k-hub-plan-row--skipped text-muted zt-k-hub-plan-cell--hint" ` +
`data-hint="${escapeHtml(hint)}" title="${escapeHtml(hint)}" aria-label="${escapeHtml(hint)}">—</td>`
);
}
function renderPlanDot(title) {
return (
`<span class="recipe-skip-badge__dot zt-k-hub-plan-row__skip-dot" ` +
`title="${escapeHtml(title)}" aria-hidden="true"></span>`
);
}
function renderSkipDot() {
return renderPlanDot("Исключён из плана на этот день");
}
function renderReplaceDot(title) {
return renderPlanDot(title || "Сегодня заменили компонент в плане");
}
function renderAdjustDot(title = "Сегодня изменили норму в плане") {
return `<span class="zt-k-hub-plan-row__adjust-dot" title="${escapeHtml(title)}" aria-hidden="true"></span>`;
}
function renderRowActionBtn(action, attrs, title, iconClass) {
return (
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
`data-action="${action}" ${attrs} title="${escapeHtml(title)}">` +
`<i class="fas ${iconClass}"></i></button>`
);
}
function renderSkipActionBtn(action, attrs, title) {
return renderRowActionBtn(action, attrs, title, "fa-times");
}
function hubModalEl() {
return (
rootEl?.closest(".modal.wesp-shell-modal") ||
document.getElementById("zootechNotificationCenterModal")
);
}
function planOverlayHost() {
return hubModalEl() || rootEl?.closest(".modal-content") || rootEl;
}
function dismissAllPlanOverlays() {
hubModalEl()
?.querySelectorAll(".zt-k-hub-plan-overlay")
.forEach((el) => el.remove());
}
function appendPlanOverlay(overlay) {
dismissAllPlanOverlays();
overlay.setAttribute("data-daily-plan-overlay", "1");
planOverlayHost()?.appendChild(overlay);
return overlay;
}
function promptSkipDuration(planDate, title = "На какой срок исключить?") {
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return Promise.resolve(null);
return skip.promptSkipDuration({
planDate,
title,
overlayHost: planOverlayHost() || document.body,
beforeAppend: dismissAllPlanOverlays,
onError: notifyError,
});
}
function skipDurationBody(planDate, extra = {}, title) {
const skip = global.WespDailyPlanSkipDuration;
if (!skip) return Promise.resolve(null);
return skip.skipDurationBody(planDate, extra, title);
}
async function openReplaceIngredientModal(recipeId, ingredientId, componentId, label) {
const planDate = planDateValue();
const replaceModal = global.WespDailyPlanReplaceModal;
if (!replaceModal) return;
await replaceModal.open({
componentId,
label,
overlayHost: planOverlayHost() || document.body,
onError: notifyError,
onPick: async (replacementComponentId, replacementName) => {
const body = await skipDurationBody(
planDate,
{ recipeId, ingredientId, replacementComponentId },
"На какой срок заменить?"
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось заменить компонент");
}
await loadPlan();
notifySuccess(`Компонент заменён на «${replacementName}»`);
},
});
}
async function undoReplaceIngredient(recipeId, ingredientId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
ingredient_id: ingredientId,
date: planDate,
});
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось отменить замену");
}
await loadPlan();
notifySuccess("Замена отменена");
} catch (err) {
notifyError(err.message, "Не удалось отменить замену");
}
}
async function undoComponentNormAdjustment(componentId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
component_id: componentId,
date: planDate,
});
const resp = await fetch(`/api/daily-plan/adjustments/components?${params.toString()}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть норму");
}
await loadPlan();
notifySuccess("Норма возвращена к мастеру");
if (typeof global.reloadAfterSkipRestore === "function") {
global.reloadAfterSkipRestore();
}
} catch (err) {
notifyError(err.message, "Не удалось вернуть норму");
}
}
async function skipTrip(recipeId, recipeName) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось исключить рейс");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-trip",
`data-recipe-id="${escapeHtml(recipeId)}"`,
`Рейс «${escapeHtml(recipeName)}» убран из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось исключить рейс");
}
}
async function unskipTrip(recipeId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({ recipe_id: recipeId, date: planDate });
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(() => ({}));
throw new Error(err.message || "Не удалось вернуть рейс");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Рейс снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть рейс");
}
}
async function skipIngredient(recipeId, ingredientId, label) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId, ingredientId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать компонент");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-ingredient",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`,
`Компонент «${escapeHtml(label)}» убран из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось убрать компонент");
}
}
async function unskipIngredient(recipeId, ingredientId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
ingredient_id: ingredientId,
date: planDate,
});
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(() => ({}));
throw new Error(err.message || "Не удалось вернуть компонент");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Компонент снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть компонент");
}
}
async function skipUnloadingGroup(recipeId, groupId, label) {
const planDate = planDateValue();
const body = await skipDurationBody(planDate, { recipeId, unloadingGroupId: groupId });
if (!body) return;
try {
const resp = await fetch("/api/daily-plan/skips/unloading-groups", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать группу");
}
await loadPlan();
showUndoToast(
"daily-plan-unskip-unloading-group",
`data-recipe-id="${escapeHtml(recipeId)}" data-group-id="${escapeHtml(groupId)}"`,
`Группа «${escapeHtml(label)}» убрана из плана.`
);
} catch (err) {
notifyError(err.message, "Не удалось убрать группу");
}
}
async function unskipUnloadingGroup(recipeId, groupId) {
const planDate = planDateValue();
try {
const params = new URLSearchParams({
recipe_id: recipeId,
unloading_group_id: groupId,
date: planDate,
});
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(() => ({}));
throw new Error(err.message || "Не удалось вернуть группу");
}
dismissUndoToast();
await loadPlan();
notifySuccess("Группа снова в плане");
} catch (err) {
notifyError(err.message, "Не удалось вернуть группу");
}
}
function dismissUndoToast() {
if (undoToastTimer) {
clearTimeout(undoToastTimer);
undoToastTimer = null;
}
rootEl?.querySelector("[data-daily-plan-undo]")?.remove();
}
function showUndoToast(unskipAction, dataAttrs, message) {
dismissUndoToast();
const toast = document.createElement("div");
toast.className = "zt-k-hub-plan__undo-toast";
toast.dataset.dailyPlanUndo = "1";
toast.innerHTML =
`<span>${message}</span>` +
`<button type="button" class="btn btn-sm btn-link zt-k-hub-plan__undo-btn" ` +
`data-action="${unskipAction}" ${dataAttrs}>Отменить</button>`;
rootEl?.querySelector(".zt-k-hub-plan")?.appendChild(toast);
undoToastTimer = setTimeout(() => dismissUndoToast(), 8000);
}
async function loadDispensers() {
try {
const resp = await fetch("/api/feed_dispensers?limit=200");
if (!resp.ok) throw new Error("dispensers");
const data = await resp.json();
dispensers = Array.isArray(data) ? data : [];
if (!dispenserEl) return;
if (!dispensers.length) {
dispenserEl.innerHTML = '<option value="">Нет кормораздатчиков</option>';
renderEmpty("Добавьте кормораздатчик в разделе «Кормораздатчики».");
return;
}
const saved = (() => {
try {
return localStorage.getItem(DAILY_PLAN_DISPENSER_KEY) || "";
} catch {
return "";
}
})();
const dispenserItems = dispensers.filter((d) => d.type !== "mill");
const millItems = dispensers.filter((d) => d.type === "mill");
let options =
`<option value="${ALL_DISPENSERS_ID}"${saved === ALL_DISPENSERS_ID ? " selected" : ""}>Все кормораздатчики</option>`;
if (dispenserItems.length) {
options += '<optgroup label="Кормораздатчики">';
options += dispenserItems
.map((d) => {
const id = escapeHtml(d.id);
const name = escapeHtml(d.name || d.id);
const selected = d.id === saved ? " selected" : "";
return `<option value="${id}"${selected}>${name}</option>`;
})
.join("");
options += "</optgroup>";
}
options += `<option value="${ALL_MILLS_ID}"${saved === ALL_MILLS_ID ? " selected" : ""}>Все кормоцеха</option>`;
if (millItems.length) {
options += '<optgroup label="Кормоцеха">';
options += millItems
.map((d) => {
const id = escapeHtml(d.id);
const name = escapeHtml(d.name || d.id);
const selected = d.id === saved ? " selected" : "";
return `<option value="${id}"${selected}>${name}</option>`;
})
.join("");
options += "</optgroup>";
}
dispenserEl.innerHTML = options;
if (!dispenserEl.value) {
dispenserEl.value = saved || ALL_DISPENSERS_ID;
}
loadPlan();
} catch {
if (dispenserEl) {
dispenserEl.innerHTML = '<option value="">Ошибка загрузки</option>';
}
renderEmpty("Не удалось загрузить список кормораздатчиков.");
}
}
function buildPlanUrl(pdf) {
const params = new URLSearchParams();
if (dateEl?.value) params.set("date", dateEl.value);
if (dispenserEl?.value) params.set("dispenser_id", dispenserEl.value);
const base = pdf ? "/api/daily-plan/pdf" : "/api/daily-plan";
return `${base}?${params.toString()}`;
}
async function loadPlan() {
if (!contentEl) return;
if (!dispenserEl?.value) {
renderEmpty("Выберите кормораздатчик.");
return;
}
contentEl.innerHTML =
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>';
try {
const resp = await fetch(buildPlanUrl(false));
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Ошибка загрузки");
}
const plan = await resp.json();
renderPlan(plan);
} catch (err) {
renderEmpty(global.WespUserMessages?.safe?.(err.message, "Не удалось загрузить план.") || "Не удалось загрузить план.");
}
}
function renderEmpty(message) {
if (!contentEl) return;
contentEl.innerHTML =
'<div class="zt-k-hub-plan__empty">' +
'<i class="fas fa-calendar-day"></i>' +
`<p>${escapeHtml(message)}</p></div>`;
}
function renderReplaceBtn(recipeId, ing) {
if (!ing.componentId) return "";
const label = ing.originalName || ing.name || "Компонент";
return renderRowActionBtn(
"daily-plan-replace-ingredient",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ing.id)}" ` +
`data-component-id="${escapeHtml(ing.componentId)}" data-label="${escapeHtml(label)}"`,
"Заменить компонент",
"fa-exchange-alt"
);
}
function renderTotalsColumn(totals, grandTotalKg) {
if (!totals.length) {
return (
'<aside class="zt-k-hub-plan__totals-col">' +
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
'<p class="zt-k-hub-plan__col-empty text-muted">Нет данных</p></aside>'
);
}
let rows = "";
totals.forEach((row) => {
rows += `<tr><td>${escapeHtml(row.name)}</td><td>${escapeHtml(formatKg(row.totalKg))}</td></tr>`;
});
const grand =
grandTotalKg ??
totals.reduce((sum, row) => sum + Number(row.totalKg || 0), 0);
return (
'<aside class="zt-k-hub-plan__totals-col">' +
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
'<div class="zt-k-hub-plan__col-scroll">' +
'<table class="zt-k-hub-plan-table zt-k-hub-plan-table--totals">' +
"<thead><tr><th>Компонент</th><th>Всего, кг</th></tr></thead>" +
`<tbody>${rows}</tbody>` +
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><th>Итого</th><th>${escapeHtml(formatKg(grand))}</th></tr></tfoot>` +
"</table></div></aside>"
);
}
function renderSkippedSection(plan) {
const skippedTrips = plan.skippedTrips || [];
if (!skippedTrips.length) return "";
let rows = "";
skippedTrips.forEach((trip) => {
rows +=
'<li class="zt-k-hub-plan-skipped__item">' +
`<span>${renderSkipDot()}<span class="text-muted">Рейс:</span> ${escapeHtml(trip.recipeName)}</span>` +
'<button type="button" class="btn btn-sm btn-outline-success" ' +
'data-action="daily-plan-unskip-trip" ' +
`data-recipe-id="${escapeHtml(trip.recipeId)}" title="Вернуть в план">` +
'<i class="fas fa-undo"></i> Вернуть</button></li>';
});
return (
'<section class="zt-k-hub-plan-skipped">' +
'<h3 class="zt-k-hub-plan-skipped__title">Исключено из плана</h3>' +
`<ul class="zt-k-hub-plan-skipped__list">${rows}</ul></section>`
);
}
function renderTripsColumn(periods) {
let html = '<div class="zt-k-hub-plan__trips-col"><div class="zt-k-hub-plan__col-scroll">';
periods.forEach((period) => {
const trips = period.trips || [];
if (!trips.length) return;
html +=
`<section class="zt-k-hub-plan__period"><h3 class="zt-k-hub-plan__period-title">${escapeHtml(period.name)}</h3><div class="zt-k-hub-plan__trips">`;
trips.forEach((trip) => {
const recipeId = escapeHtml(trip.recipeId);
const hasAdjustedNorm = (trip.ingredients || []).some((ing) => ing.adjustedToday);
const hasReplacedIngredient = (trip.ingredients || []).some((ing) => ing.replacedToday);
const adjustBadge = hasAdjustedNorm
? ' <span class="recipe-adjust-badge" title="Сегодня изменили норму в плане">' +
'<span class="zt-k-hub-plan-row__adjust-dot" aria-hidden="true"></span>сегодня изменили норму</span>'
: "";
const replaceBadge = hasReplacedIngredient
? ' <span class="recipe-skip-badge" title="Сегодня заменили компонент в плане">' +
'<span class="recipe-skip-badge__dot" aria-hidden="true"></span>заменён компонент</span>'
: "";
const ings = (trip.ingredients || [])
.map((ing) => {
const ingId = escapeHtml(ing.id);
const name = escapeHtml(ing.name);
const skipped = Boolean(ing.skippedToday);
const replaced = Boolean(ing.replacedToday);
const adjusted = Boolean(ing.adjustedToday);
const rowClass = skipped
? "zt-k-hub-plan-row--skipped"
: replaced
? "zt-k-hub-plan-row--replaced"
: adjusted
? "zt-k-hub-plan-row--adjusted"
: "";
const replaceTitle = replacementRowTitle(ing);
const adjustTitle = adjustmentRowTitle(ing);
const skipHint = skipped ? skippedIngredientHint(ing) : "";
const nameCell = skipped
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
: replaced
? `<td class="zt-k-hub-plan-row--replaced" title="${escapeHtml(replaceTitle)}">${renderReplaceDot(replaceTitle)}${name}</td>`
: adjusted
? `<td class="zt-k-hub-plan-row--adjusted" title="${escapeHtml(adjustTitle)}">${renderAdjustDot(adjustTitle)}${name}</td>`
: `<td>${name}</td>`;
const weightCell = skipped
? renderSkippedValueCell(skipHint)
: `<td>${escapeHtml(formatKg(ing.weightPerHead))}</td>`;
const totalCell = skipped
? renderSkippedValueCell(skipHint)
: `<td>${escapeHtml(formatKg(ing.totalKg))}</td>`;
let actionCell = "";
if (ing.id) {
if (skipped) {
actionCell = renderRowActionBtn(
"daily-plan-unskip-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
"Вернуть в план",
"fa-undo"
);
} else {
if (replaced) {
actionCell += renderRowActionBtn(
"daily-plan-undo-replace-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
"Отменить замену",
"fa-undo"
);
} else if (adjusted && ing.componentId) {
actionCell += renderRowActionBtn(
"daily-plan-undo-adjust-norm",
`data-component-id="${escapeHtml(ing.componentId)}"`,
"Вернуть норму",
"fa-undo"
);
} else {
actionCell += renderReplaceBtn(recipeId, ing);
}
actionCell += renderSkipActionBtn(
"daily-plan-skip-ingredient",
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}" data-label="${escapeHtml(ing.originalName || ing.name)}"`,
"Убрать компонент из плана на этот день"
);
}
}
return (
`<tr class="${rowClass}">` +
`${nameCell}${weightCell}${totalCell}` +
`<td class="zt-k-hub-plan-table__actions zt-k-hub-plan-table__actions--multi">${actionCell}</td></tr>`
);
})
.join("");
const groups = (trip.unloadingGroups || [])
.map((g) => {
const groupId = escapeHtml(g.id);
const name = escapeHtml(g.name);
const skipped = Boolean(g.skippedToday);
const nameCell = skipped
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
: `<td>${name}</td>`;
const weightCell = skipped
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
: `<td>${escapeHtml(g.weightKg)}</td>`;
const distCell = skipped
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
: `<td>${escapeHtml(formatDistribution(g))}</td>`;
let actionCell = "";
if (g.id) {
if (skipped) {
actionCell = renderRowActionBtn(
"daily-plan-unskip-unloading-group",
`data-recipe-id="${recipeId}" data-group-id="${groupId}"`,
"Вернуть в план",
"fa-undo"
);
} else {
actionCell = renderSkipActionBtn(
"daily-plan-skip-unloading-group",
`data-recipe-id="${recipeId}" data-group-id="${groupId}" data-label="${name}"`,
"Убрать группу из плана на этот день"
);
}
}
return (
`<tr class="${skipped ? "zt-k-hub-plan-row--skipped" : ""}">` +
`${nameCell}${weightCell}${distCell}` +
`<td class="zt-k-hub-plan-table__actions">${actionCell}</td></tr>`
);
})
.join("");
html +=
'<article class="zt-k-hub-plan-trip zt-card">' +
'<div class="zt-k-hub-plan-trip__head">' +
`<h4 class="zt-k-hub-plan-trip__title">${escapeHtml(trip.recipeName)}${replaceBadge}${adjustBadge}` +
` <span class="text-muted">(${escapeHtml(trip.headsPerTrip)} гол., ${escapeHtml(trip.mixingTimeSec)} с)</span></h4>` +
'<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-trip__skip" ' +
'data-action="daily-plan-skip-trip" ' +
`data-recipe-id="${recipeId}" ` +
`data-recipe-name="${escapeHtml(trip.recipeName)}" ` +
'title="Убрать из плана на этот день">' +
'<i class="fas fa-trash"></i></button></div>' +
'<table class="zt-k-hub-plan-table"><thead><tr><th>Компонент</th><th>кг/гол</th><th>Всего, кг</th><th></th></tr></thead>' +
`<tbody>${ings || '<tr><td colspan="4" class="text-muted">—</td></tr>'}</tbody>` +
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><td>Итого по рейсу</td><td></td><td>${escapeHtml(formatKg(trip.totalWeightKg))}</td><td></td></tr></tfoot></table>`;
if (groups) {
html +=
'<p class="zt-k-hub-plan-trip__sub">Выгрузка</p>' +
'<table class="zt-k-hub-plan-table"><thead><tr><th>Группа</th><th>кг</th><th>Распределение</th><th></th></tr></thead>' +
`<tbody>${groups}</tbody></table>`;
}
html += "</article>";
});
html += "</div></section>";
});
html += "</div></div>";
return html;
}
function renderPlan(plan) {
if (!contentEl) return;
const periods = plan.periods || [];
const skippedTrips = plan.skippedTrips || [];
const hasTrips = periods.some((p) => (p.trips || []).length);
const hasSkipped = skippedTrips.length;
if (!hasTrips && !hasSkipped) {
renderEmpty("Нет периодов или рейсов для выбранного кормораздатчика.");
return;
}
contentEl.innerHTML =
'<div class="zt-k-hub-plan__split">' +
renderTotalsColumn(plan.ingredientTotals || [], plan.ingredientGrandTotalKg) +
renderTripsColumn(periods) +
"</div>" +
renderSkippedSection(plan);
}
function downloadPdf() {
if (!dispenserEl?.value) return;
global.open(buildPlanUrl(true), "_blank", "noopener");
}
function destroy() {
dismissUndoToast();
dismissAllPlanOverlays();
if (rootEl) {
rootEl.removeEventListener("click", onPanelClick);
rootEl.innerHTML = "";
}
rootEl = null;
dateEl = null;
dispenserEl = null;
contentEl = null;
}
return {
mount,
destroy,
dismissAllPlanOverlays,
reload: loadPlan,
getSelectedDispenserId,
getSelectedPlanDate,
};
}
let activePanel = null;
global.WespDailyPlanPanel = {
createDailyPlanPanel,
_setActivePanel(panel) {
activePanel = panel || null;
},
getSelectedDispenserId() {
return activePanel?.getSelectedDispenserId?.() || "";
},
getSelectedPlanDate() {
return activePanel?.getSelectedPlanDate?.() || todayIso();
},
reload() {
return activePanel?.reload?.();
},
};
})(typeof window !== "undefined" ? window : globalThis);
+377
View File
@@ -0,0 +1,377 @@
/**
* Контроль отклонений лента событий (не дубль отчётов).
*/
(function (global) {
const EVENT_LABELS = {
OVERLOAD: "Перегруз",
UNDERLOAD: "Недогруз",
LOADING_TIME: "Долгая загрузка",
LOADING_FAST: "Быстрая загрузка",
MIX_TIME: "Смешивание",
LEFT_IN_MIXER: "Остаток в миксере",
};
const EVENT_ICONS = {
OVERLOAD: "fa-arrow-trend-up",
UNDERLOAD: "fa-arrow-trend-down",
LOADING_TIME: "fa-hourglass-half",
LOADING_FAST: "fa-forward",
MIX_TIME: "fa-clock",
LEFT_IN_MIXER: "fa-triangle-exclamation",
};
let currentView = "summary";
let journalSubView = "reports";
let alertsSeverityFilter = "";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pluralDeviations(count) {
const n = Math.abs(Number(count) || 0);
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return `${n} отклонение`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${n} отклонения`;
return `${n} отклонений`;
}
function getDateParams() {
return global.WespReportsFilters?.getDateParams?.() || {
date_from: document.getElementById("date-from")?.value || "",
date_to: document.getElementById("date-to")?.value || "",
};
}
function getDispenserId() {
return global.WespReportsFilters?.getDispenserId?.() || "";
}
function buildAlertsUrl() {
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
if (alertsSeverityFilter) params.set("severity", alertsSeverityFilter);
return `/api/feed-quality/alerts?${params.toString()}`;
}
function buildSummaryUrl() {
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
return `/api/feed-quality/alerts/summary?${params.toString()}`;
}
function deviationClass(devKg, eventType) {
if (eventType === "MIX_TIME" || eventType === "LOADING_TIME" || eventType === "LOADING_FAST") {
return "deviation-significant";
}
if (devKg == null || Number.isNaN(Number(devKg))) return "";
const v = Number(devKg);
if (v > 0) return "deviation-positive";
if (v < 0) return "deviation-negative";
return "";
}
function formatDeltaPct(item) {
if (
item.eventType === "MIX_TIME" ||
item.eventType === "LOADING_TIME" ||
item.eventType === "LOADING_FAST"
) {
if (item.deviationKg == null) return "—";
const sec = Number(item.deviationKg);
return `${sec > 0 ? "+" : ""}${Math.round(sec)} с`;
}
if (item.deviationPct == null) return "—";
const pct = Number(item.deviationPct);
return `${pct > 0 ? "+" : ""}${pct.toFixed(1)}%`;
}
function formatMixingClock(sec) {
const total = Math.max(0, Math.round(Number(sec) || 0));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function objectLabel(item) {
if (item.componentName) return item.componentName;
if (item.groupName) return `группа «${item.groupName}»`;
if (item.eventType === "MIX_TIME") return "смешивание";
if (item.eventType === "LEFT_IN_MIXER") return "миксер";
return "рейс";
}
function buildContextLine(item) {
const parts = [];
const target = item.targetKg;
const actual = item.actualKg;
if (item.eventType === "MIX_TIME") {
if (actual != null && target != null) {
parts.push(`факт ${formatMixingClock(actual)} при плане ${formatMixingClock(target)}`);
}
} else if (item.eventType === "LEFT_IN_MIXER") {
if (actual != null) parts.push(`осталось ${Number(actual).toFixed(1)} кг`);
} else if (item.groupName) {
if (actual != null && target != null) {
parts.push(`выгружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
} else if (actual != null) {
parts.push(`выгружено ${Number(actual).toFixed(1)} кг`);
}
if (item.durationSec != null && Number(item.durationSec) > 0) {
const sec = Number(item.durationSec);
parts.push(sec >= 60 ? `выгрузка ${(sec / 60).toFixed(1)} мин` : `выгрузка ${sec.toFixed(1)} с`);
}
} else if (item.componentName) {
if (actual != null && target != null) {
parts.push(`загружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
} else if (actual != null) {
parts.push(`загружено ${Number(actual).toFixed(1)} кг`);
}
if (item.durationSec != null && Number(item.durationSec) > 0) {
parts.push(`загрузка ${Number(item.durationSec).toFixed(1)} с`);
}
}
if (item.costDeviationRub != null && item.costDeviationRub > 0) {
parts.push(`+${Number(item.costDeviationRub).toFixed(0)}`);
}
return parts.length ? parts.join(" · ") : (item.detail || "").split(":").pop()?.trim() || "";
}
function sortItems(items) {
return [...items].sort((a, b) => {
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return tb - ta;
});
}
async function refreshSummary() {
const chip = document.getElementById("feedAlertsSummaryChip");
if (!chip) return;
try {
const resp = await fetch(buildSummaryUrl());
if (!resp.ok) return;
const data = await resp.json();
const total = Number(data.total) || 0;
if (total <= 0) {
chip.hidden = true;
chip.textContent = "";
return;
}
chip.hidden = false;
const err = Number(data.error) || 0;
chip.textContent =
err > 0
? `${pluralDeviations(total)} (${err} критич.)`
: pluralDeviations(total) + " за период";
} catch {
chip.hidden = true;
}
}
function renderAlerts(items) {
const listEl = document.getElementById("alertsList");
if (!listEl) return;
if (!items || !items.length) {
listEl.innerHTML =
'<div class="empty-state wesp-content-reveal zt-empty">' +
'<div class="empty-icon"><i class="fas fa-check-circle"></i></div>' +
'<h3 class="zt-empty__title">Отклонений нет</h3>' +
'<p class="zt-empty__hint">За выбранный период отклонений не зафиксировано</p></div>';
global.WespZootechModals?.revealContent(listEl.querySelector(".empty-state"));
return;
}
const sorted = sortItems(items);
const html = sorted
.map((item) => {
const sev = item.severity || "warning";
const typeLabel = EVENT_LABELS[item.eventType] || "Отклонение";
const icon = EVENT_ICONS[item.eventType] || "fa-circle-exclamation";
const devClass = deviationClass(item.deviationKg, item.eventType);
const delta = formatDeltaPct(item);
const context = buildContextLine(item);
const when = item.createdAt
? new Date(item.createdAt).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "";
const trip = item.recipeName || "Рейс";
return (
`<button type="button" class="deviation-alert-card list-item zt-card zt-card--interactive deviation-feed-item deviation-feed-item--${escapeHtml(sev)}" ` +
`data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
'<div class="deviation-alert-card__top">' +
`<span class="deviation-feed-item__dot deviation-feed-item__dot--${escapeHtml(sev)}" aria-hidden="true"></span>` +
'<div class="deviation-alert-card__head">' +
`<h3 class="deviation-alert-card__title">` +
`<i class="fas ${icon}" aria-hidden="true"></i>` +
`${escapeHtml(typeLabel)} · ${escapeHtml(objectLabel(item))}` +
"</h3>" +
`<span class="deviation-type-badge deviation-type-badge--${escapeHtml(sev)}">${escapeHtml(sev === "error" ? "Критично" : "Предупр.")}</span>` +
"</div>" +
`<span class="deviation-feed-item__delta ${escapeHtml(devClass)}">${escapeHtml(delta)}</span>` +
"</div>" +
(context ? `<div class="deviation-alert-card__info"><span class="deviation-alert-card__context">${escapeHtml(context)}</span></div>` : "") +
`<div class="deviation-alert-card__meta">${escapeHtml(trip)}${when ? ` · ${escapeHtml(when)}` : ""}</div>` +
'<span class="deviation-feed-item__chevron" aria-hidden="true"><i class="fas fa-chevron-right"></i></span>' +
"</button>"
);
})
.join("");
listEl.innerHTML = `<div class="deviation-feed">${html}</div>`;
listEl.querySelectorAll("[data-loading-report-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-loading-report-id");
if (id) openReportFromAlert(id);
});
});
global.WespZootechModals?.revealContent(listEl.querySelector(".deviation-feed"));
}
async function loadAlerts() {
const listEl = document.getElementById("alertsList");
if (!listEl) return;
listEl.innerHTML =
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
'<p class="zt-loading-caption">Загрузка отклонений…</p></div>';
try {
const resp = await fetch(buildAlertsUrl());
if (!resp.ok) throw new Error("Не удалось загрузить отклонения");
const data = await resp.json();
renderAlerts(data.items || []);
await refreshSummary();
} catch (err) {
listEl.innerHTML =
`<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
}
}
function openReportFromAlert(loadingReportId) {
switchView("journal");
setJournalSubView("reports");
global.__wespHighlightReportId = loadingReportId;
if (typeof global.filterAndDisplayReports === "function") {
global.filterAndDisplayReports();
} else if (typeof global.loadReports === "function") {
global.loadReports();
}
}
function openJournalAlerts() {
switchView("journal");
setJournalSubView("alerts");
}
function setJournalSubView(sub) {
journalSubView = sub === "alerts" ? "alerts" : "reports";
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-journal-view-tab") === journalSubView);
});
const alertsContainer = document.querySelector(".reports-container--alerts");
const reportsContainer = document.querySelector(".reports-container--reports");
const severityBlock = document.getElementById("alertsSeverityBlock");
if (reportsContainer) reportsContainer.hidden = journalSubView !== "reports";
if (alertsContainer) alertsContainer.hidden = journalSubView !== "alerts";
if (severityBlock) severityBlock.hidden = journalSubView !== "alerts";
if (journalSubView === "alerts") loadAlerts();
else if (typeof global.loadReports === "function") global.loadReports();
}
function updateToolbarVisibility() {
const journalBlock = document.getElementById("journalSubTabsBlock");
if (journalBlock) journalBlock.hidden = currentView !== "journal";
const severityBlock = document.getElementById("alertsSeverityBlock");
if (severityBlock) {
severityBlock.hidden = currentView !== "journal" || journalSubView !== "alerts";
}
}
function switchView(view) {
const allowed = ["summary", "comparison", "journal"];
currentView = allowed.includes(view) ? view : "summary";
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-reports-view-tab") === currentView);
});
const summaryEl = document.querySelector(".reports-container--summary");
const comparisonEl = document.querySelector(".reports-container--comparison");
const reportsContainer = document.querySelector(".reports-container--reports");
const alertsContainer = document.querySelector(".reports-container--alerts");
if (summaryEl) summaryEl.hidden = currentView !== "summary";
if (comparisonEl) comparisonEl.hidden = currentView !== "comparison";
if (reportsContainer) reportsContainer.hidden = currentView !== "journal" || journalSubView !== "reports";
if (alertsContainer) alertsContainer.hidden = currentView !== "journal" || journalSubView !== "alerts";
updateToolbarVisibility();
if (currentView === "summary") {
global.WespAnalyticsSummary?.load?.();
} else if (currentView === "comparison") {
global.WespAnalyticsPlanFact?.load?.();
} else if (currentView === "journal") {
setJournalSubView(journalSubView);
} else {
refreshSummary();
}
}
function setSeverityFilter(value) {
alertsSeverityFilter = value || "";
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-alerts-severity") === alertsSeverityFilter);
});
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
}
function init() {
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
switchView(btn.getAttribute("data-reports-view-tab"));
});
});
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
if (currentView !== "journal") switchView("journal");
setJournalSubView(btn.getAttribute("data-journal-view-tab"));
});
});
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
btn.addEventListener("click", () => {
setSeverityFilter(btn.getAttribute("data-alerts-severity") || "");
});
});
global.WespAnalyticsSummary?.init?.();
switchView("summary");
refreshSummary();
}
function onFiltersApplied() {
refreshSummary();
global.WespAnalyticsSummary?.onFiltersApplied?.();
global.WespAnalyticsPlanFact?.onFiltersApplied?.();
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
}
global.WespFeedAlerts = {
init,
loadAlerts,
refreshSummary,
switchView,
openReportFromAlert,
openJournalAlerts,
onFiltersApplied,
getCurrentView: () => currentView,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,255 @@
/**
* Модалка настроек контроля отклонений (глобальные пороги и уведомления).
*/
const MODAL_ID = "feedQualitySettingsModal";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function sectionHtml(id, title, fieldsHtml, checksHtml) {
return (
`<section class="fq-settings-section" data-fq-section="${escapeHtml(id)}">` +
'<div class="fq-settings-section__head">' +
`<h4 class="fq-settings-section__title">${escapeHtml(title)}</h4>` +
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="enabled" data-fq-section="${escapeHtml(id)}" checked>` +
" Учитывать</label></div>" +
`<div class="fq-settings-grid">${fieldsHtml}</div>` +
`<div class="fq-settings-checks">${checksHtml}</div></section>`
);
}
function fieldHtml(section, key, label, value, step) {
return (
'<div class="fq-settings-field">' +
`<label>${escapeHtml(label)}</label>` +
`<input type="number" class="form-control form-control-sm" data-fq-field="${escapeHtml(key)}" ` +
`data-fq-section="${escapeHtml(section)}" value="${escapeHtml(value)}" step="${escapeHtml(step)}" min="0">` +
"</div>"
);
}
function notifyChecks(section) {
return (
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="notify_warning" data-fq-section="${escapeHtml(section)}" checked>` +
" Уведомление: предупреждение</label>" +
'<label class="fq-settings-check">' +
`<input type="checkbox" data-fq-field="notify_critical" data-fq-section="${escapeHtml(section)}" checked>` +
" Уведомление: критичное</label>"
);
}
function modalTemplate() {
const loading = sectionHtml(
"loading",
"Загрузка компонентов",
fieldHtml("loading", "warning_pct", "Порог предупреждения, %", 10, 0.1) +
fieldHtml("loading", "critical_pct", "Порог критичного, %", 15, 0.1) +
fieldHtml("loading", "warning_min_sec", "Слишком быстро, предупр., с", 3, 1) +
fieldHtml("loading", "critical_min_sec", "Слишком быстро, критично, с", 1, 1) +
fieldHtml("loading", "warning_max_sec", "Слишком долго, предупр., с", 180, 1) +
fieldHtml("loading", "critical_max_sec", "Слишком долго, критично, с", 600, 1),
notifyChecks("loading")
);
const unloading = sectionHtml(
"unloading",
"Выгрузка по группам",
fieldHtml("unloading", "warning_pct", "Порог предупреждения, %", 5, 0.1) +
fieldHtml("unloading", "critical_pct", "Порог критичного, %", 10, 0.1),
notifyChecks("unloading")
);
const mix = sectionHtml(
"mix_time",
"Время смешивания",
fieldHtml("mix_time", "warning_delta_sec", "Предупреждение, с", 30, 1) +
fieldHtml("mix_time", "critical_delta_sec", "Критично, с", 60, 1),
notifyChecks("mix_time")
);
const mixer = sectionHtml(
"left_in_mixer",
"Остаток в миксере",
fieldHtml("left_in_mixer", "warning_min_kg", "Предупреждение, кг", 5, 0.1) +
fieldHtml("left_in_mixer", "warning_min_pct", "Предупреждение, % от партии", 2, 0.1) +
fieldHtml("left_in_mixer", "critical_min_kg", "Критично, кг", 15, 0.1),
notifyChecks("left_in_mixer")
);
return (
`<div class="modal wesp-shell-modal fq-settings-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide modal-dialog--shell-fq">' +
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none fq-settings-modal__content">' +
'<div class="wesp-shell-bsmodal-title-row">' +
'<h2 class="wesp-shell-bsmodal-title">Контроль отклонений</h2>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
"</div>" +
'<div class="wesp-shell-bsmodal-body">' +
`<form id="feedQualitySettingsForm">${loading}${unloading}${mix}${mixer}</form>` +
"</div>" +
'<div class="wesp-shell-bsmodal-actions">' +
'<button type="button" class="wesp-shell-bsmodal-btn" data-bs-dismiss="modal">Отмена</button>' +
'<button type="button" class="wesp-shell-bsmodal-btn wesp-shell-bsmodal-btn-primary" data-action="save-feed-quality-settings">' +
"Сохранить</button>" +
"</div></div></div></div>"
);
}
let modalEl = null;
let notyfAdapter = null;
let currentSettings = null;
let modalStackWired = false;
/** Настройки зоотехника — z-index 3000; shell-модалка Bootstrap по умолчанию ниже. */
const FQ_MODAL_Z = 3100;
const FQ_BACKDROP_Z = 3099;
function isSettingsModalOpen() {
const settings = document.getElementById("settingsModal");
return Boolean(settings && settings.style.display === "block");
}
function raiseModalAboveSettings() {
if (!modalEl) return;
modalEl.style.zIndex = String(FQ_MODAL_Z);
const backdrops = document.querySelectorAll(".modal-backdrop");
const backdrop = backdrops[backdrops.length - 1];
if (backdrop) backdrop.style.zIndex = String(FQ_BACKDROP_Z);
}
function restoreBodyAfterClose() {
const anyBootstrapModalOpen = document.querySelector(".modal.show");
if (!anyBootstrapModalOpen) {
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
document.body.classList.remove("modal-open");
document.body.style.removeProperty("padding-right");
document.body.style.removeProperty("overflow");
}
if (isSettingsModalOpen()) {
document.body.style.overflow = "hidden";
}
}
function wireModalStackHandlers() {
if (!modalEl || modalStackWired) return;
modalStackWired = true;
modalEl.addEventListener("shown.bs.modal", raiseModalAboveSettings);
modalEl.addEventListener("hidden.bs.modal", restoreBodyAfterClose);
}
function ensureModal() {
if (modalEl && modalEl.isConnected) return modalEl;
modalEl = document.getElementById(MODAL_ID);
if (!modalEl) {
document.body.insertAdjacentHTML("beforeend", modalTemplate());
modalEl = document.getElementById(MODAL_ID);
}
globalThis.WespZootechModals?.wireAllShellModals?.();
wireModalStackHandlers();
modalEl.querySelector("[data-action='save-feed-quality-settings']")?.addEventListener("click", saveSettings);
return modalEl;
}
function applySettingsToForm(settings) {
const form = document.getElementById("feedQualitySettingsForm");
if (!form || !settings) return;
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
const section = el.getAttribute("data-fq-section");
const field = el.getAttribute("data-fq-field");
const block = settings[section];
if (!block || field == null) return;
const val = block[field];
if (el.type === "checkbox") {
el.checked = Boolean(val);
} else if (val != null) {
el.value = String(val);
}
});
}
function collectSettingsFromForm() {
const form = document.getElementById("feedQualitySettingsForm");
const out = { loading: {}, unloading: {}, mix_time: {}, left_in_mixer: {} };
if (!form) return out;
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
const section = el.getAttribute("data-fq-section");
const field = el.getAttribute("data-fq-field");
if (!out[section]) out[section] = {};
if (el.type === "checkbox") {
out[section][field] = el.checked;
} else {
out[section][field] = el.value;
}
});
return out;
}
async function loadSettings() {
const resp = await fetch("/api/feed-quality/settings");
if (!resp.ok) throw new Error("Не удалось загрузить настройки");
const data = await resp.json();
currentSettings = data.settings || data;
applySettingsToForm(currentSettings);
}
async function openModal() {
ensureModal();
try {
await loadSettings();
} catch (err) {
notyfAdapter?.error?.(err.message || "Ошибка загрузки");
return;
}
const inst = globalThis.bootstrap?.Modal?.getOrCreateInstance(modalEl);
inst?.show();
requestAnimationFrame(() => {
requestAnimationFrame(raiseModalAboveSettings);
});
}
async function saveSettings() {
const payload = collectSettingsFromForm();
try {
const resp = await fetch("/api/feed-quality/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: payload }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка сохранения");
notyfAdapter?.success?.(
data.reevaluatedReports != null
? `Настройки сохранены (переоценено ${data.reevaluatedReports} рейсов)`
: "Настройки сохранены"
);
globalThis.WespFeedAlerts?.refreshSummary?.();
if (globalThis.WespFeedAlerts?.getCurrentView?.() === "alerts") {
globalThis.WespFeedAlerts.loadAlerts();
}
globalThis.bootstrap?.Modal?.getInstance(modalEl)?.hide();
} catch (err) {
notyfAdapter?.error?.(err.message || "Ошибка сохранения");
}
}
export function initFeedQualitySettingsModal(notyf) {
notyfAdapter = notyf;
ensureModal();
document.addEventListener("click", (event) => {
const el = event.target.closest('[data-action="open-feed-quality-settings"]');
if (!el) return;
event.preventDefault();
openModal();
});
}
if (typeof globalThis !== "undefined") {
globalThis.WespFeedQualitySettings = {
init: initFeedQualitySettingsModal,
open: openModal,
};
}
@@ -0,0 +1,676 @@
(function (global) {
"use strict";
const CREATE_NEW = "__create_new__";
const ACCEPT_EXT = [".xml", ".pdf", ".xlsx", ".xls"];
let parseResult = null;
let components = [];
let selectedFile = null;
const WARN_LABELS = {
omd_from_tdn: "ВРХ через TDN — не lab-grade",
omd_missing: "нет ВРХ — подставим дефолт",
dm_missing: "нет СВ — chemistry on hold",
};
function $(id) {
return document.getElementById(id);
}
function showEl(el) {
if (!el) return;
el.hidden = false;
el.classList.remove("lab-agrostar-reveal");
requestAnimationFrame(() => el.classList.add("lab-agrostar-reveal"));
}
function hideEl(el) {
if (!el) return;
el.hidden = true;
el.classList.remove("lab-agrostar-reveal");
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function importKind() {
return parseResult?.kind || "lab_samples";
}
function isLabSamples() {
return importKind() === "lab_samples";
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error) {
throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
}
return data;
}
async function loadComponents(force) {
if (components.length && !force) return components;
const rows = await api("/api/components?limit=2000");
components = Array.isArray(rows) ? rows : [];
return components;
}
function getSampleByNo(sampleNo) {
return (parseResult?.samples || []).find((s) => s.sampleNo === sampleNo);
}
function buildComponentOptions(matchSource, selectedId) {
const suggested = matchSource?.suggestedComponents || [];
const seen = new Set();
const opts = [
'<option value="">— какой реагент? —</option>',
`<option value="${CREATE_NEW}">+ добавить новый реагент</option>`,
];
for (const m of suggested) {
if (!m.componentId || seen.has(m.componentId)) continue;
seen.add(m.componentId);
const sel = m.componentId === selectedId ? " selected" : "";
opts.push(
`<option value="${escapeHtml(m.componentId)}"${sel}>${escapeHtml(m.name)} (${Math.round(m.score * 100)}%)</option>`,
);
}
for (const c of components) {
if (!c.id || seen.has(c.id)) continue;
const sel = c.id === selectedId ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
}
return opts.join("");
}
function selectComponentForSample(sampleNo, componentId) {
const sel = document.querySelector(
`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`,
);
const sample = getSampleByNo(sampleNo);
if (!sel || !sample) return;
sel.innerHTML = buildComponentOptions(sample, componentId);
sel.value = componentId;
}
function formatUnsupportedNotice(storage) {
if (!storage?.unsupportedCount) return "";
return storage.unsupportedMessage || `${storage.unsupportedCount} показат. AgroStar без полей в WESP`;
}
function notifyCatalogChanged() {
try {
global.dispatchEvent(new CustomEvent("wesp:components-catalog-changed"));
} catch {
/* ignore */
}
}
async function createComponentFromSample(sampleNo, sel) {
const sample = getSampleByNo(sampleNo);
if (!sample || !sel) return;
const preview = sample.preview || {};
const name = (preview.suggestedName || sample.label || "").trim();
const type = preview.suggestedType || "Сочные корма";
if (!name) {
sel.value = "";
setResult("Нет названия для нового реагента", true);
return;
}
sel.disabled = true;
setResult(`Создаём «${name}»…`);
try {
const data = await api("/api/components", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
type,
dry_matter: sample.dryMatterPct || 0,
nutrients: sample.nutrients || {},
}),
});
await loadComponents(true);
selectComponentForSample(sampleNo, data.id);
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
setResult(
`Реагент «${name}» создан — СВ и ${storage.nutrientCount || Object.keys(sample.nutrients || {}).length} показат.` +
(skipNote ? ` ${skipNote}` : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
sel.value = "";
setResult(err.message || "Не удалось создать реагент", true);
} finally {
sel.disabled = false;
}
}
function onComponentSelectChange(e) {
const sel = e.target;
if (!sel.classList?.contains("lab-agrostar-component")) return;
if (sel.value !== CREATE_NEW) return;
const sampleNo = sel.getAttribute("data-sample-no") || "";
createComponentFromSample(sampleNo, sel);
}
function prime(reason) {
global.WespLabHeisenbergPrime?.error?.(reason, "agrostar");
}
function setStatus(text, isError) {
const el = $("labAgrostarStatus");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 mb-0" + (isError ? " lab-agrostar-result--err" : " text-muted");
if (isError && text) prime(text);
}
function setResult(text, isError) {
const el = $("labAgrostarResult");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 " + (isError ? "lab-agrostar-result--err" : "lab-agrostar-result--ok");
if (isError && text) prime(text);
}
function formatWarnings(warnings) {
if (!warnings?.length) return "—";
return warnings
.map((w) => {
const key = String(w).split("=")[0];
const label = WARN_LABELS[key] || w;
return `<span class="lab-agrostar-warn">${escapeHtml(label)}</span>`;
})
.join("");
}
function getSelectedComponentLabel(sampleNo) {
const sel = document.querySelector(`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`);
if (!sel?.value || sel.value === CREATE_NEW) return null;
const opt = sel.options[sel.selectedIndex];
return opt ? opt.text.replace(/\s*\(\d+%\)\s*$/, "").trim() : null;
}
function renderMetaLine(extra) {
const meta = $("labAgrostarMeta");
if (!meta || !parseResult) return;
showEl(meta);
const label = parseResult.sourceLabel || parseResult.labName || "Импорт";
meta.innerHTML =
`<strong>${escapeHtml(label)}</strong>${extra || ""}` +
(parseResult.warnings?.length ? ` · ${escapeHtml(parseResult.warnings.join("; "))}` : "");
}
function setActionButtons() {
const previewBtn = $("labAgrostarPreview");
const applyBtn = $("labAgrostarApply");
const clearBtn = $("labAgrostarClear");
if (previewBtn) previewBtn.disabled = false;
if (clearBtn) clearBtn.disabled = false;
if (applyBtn) {
applyBtn.disabled = !isLabSamples();
applyBtn.title = isLabSamples() ? "" : "Запись в каталог только для лабораторных проб AgroStar";
}
}
function renderLabSamplesTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
if (!wrap || !body || !parseResult) return;
const samples = parseResult.samples || [];
if (!samples.length) {
hideEl(wrap);
return;
}
if (title) title.textContent = "Кому в каталог";
if (head) {
head.innerHTML = `<tr>
<th>Образец</th><th>СВ, %</th><th>Реагент</th><th>Косяки</th>
</tr>`;
}
renderMetaLine(` · образцов: ${samples.length}`);
body.innerHTML = samples
.map((sample) => {
const sid = escapeHtml(sample.sampleNo);
const defaultId = sample.suggestedComponents?.[0]?.componentId || "";
return `<tr data-sample-no="${sid}">
<td>
<div class="fw-semibold">${escapeHtml(sample.label || sample.desc1 || sample.sampleNo)}</div>
<div class="text-muted small">${sid} · ${escapeHtml(sample.datePrinted || "")}</div>
</td>
<td>${sample.dryMatterPct != null ? escapeHtml(sample.dryMatterPct) : "—"}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component" data-sample-no="${sid}">
${buildComponentOptions(sample, defaultId)}
</select>
</td>
<td>${formatWarnings(sample.warnings)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationCompositionTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const lines = parseResult?.feedLines || [];
if (!wrap || !body || !lines.length) return;
if (title) title.textContent = "Состав рациона";
if (head) {
head.innerHTML = `<tr>
<th>Корм</th><th>кг/сут</th><th>руб.</th><th>Реагент WESP</th>
</tr>`;
}
const meta = parseResult.meta || {};
const extra = [
meta.group ? escapeHtml(meta.group) : "",
meta.rationDate ? escapeHtml(meta.rationDate) : "",
meta.totalCostRub ? `Σ ${escapeHtml(meta.totalCostRub)} руб.` : "",
]
.filter(Boolean)
.join(" · ");
renderMetaLine(` · ${lines.length} корм(ов)${extra ? " · " + extra : ""}`);
body.innerHTML = lines
.map((line, idx) => {
const defaultId = line.suggestedComponents?.[0]?.componentId || "";
return `<tr>
<td>${escapeHtml(line.feedName)}</td>
<td>${escapeHtml(line.dailyKg)}</td>
<td>${escapeHtml(line.costRub)}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component lab-agrostar-component--readonly" data-feed-idx="${idx}" disabled>
${buildComponentOptions(line, defaultId)}
</select>
</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationIndicatorsTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const rows = parseResult?.indicators || [];
if (!wrap || !body || !rows.length) return;
if (title) title.textContent = "Показатели рациона";
if (head) {
head.innerHTML = `<tr>
<th>Показатель</th><th>Норма</th><th>Текущий</th><th>Δ</th>
</tr>`;
}
const meta = parseResult.meta || {};
renderMetaLine(
` · ${rows.length} показат.${meta.group ? " · " + escapeHtml(meta.group) : ""}${meta.calcDate ? " · " + escapeHtml(meta.calcDate) : ""}`,
);
body.innerHTML = rows
.map((row) => {
const delta =
row.norm != null && row.current != null ? (row.current - row.norm).toFixed(2) : "—";
return `<tr>
<td>${escapeHtml(row.name)}</td>
<td>${row.norm != null ? escapeHtml(row.norm) : "—"}</td>
<td>${row.current != null ? escapeHtml(row.current) : "—"}</td>
<td>${escapeHtml(delta)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderAfterParse() {
hideSniffPreview();
hideEl($("labAgrostarTableWrap"));
const kind = importKind();
if (kind === "lab_samples") renderLabSamplesTable();
else if (kind === "ration_composition") renderRationCompositionTable();
else if (kind === "ration_indicators") renderRationIndicatorsTable();
}
function renderSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
if (!panel || !body || !parseResult) {
setResult("Нечего нюхать — сначала кинь файл", true);
return;
}
const kind = importKind();
if (kind === "ration_composition") {
renderSniffRationComposition(panel, body);
return;
}
if (kind === "ration_indicators") {
renderSniffRationIndicators(panel, body);
return;
}
renderSniffLabSamples(panel, body);
}
function renderSniffRationComposition(panel, body) {
const lines = parseResult.feedLines || [];
const meta = parseResult.meta || {};
const metaRows = Object.entries(meta)
.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`)
.join("");
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР состав рациона</h3>
<span class="badge text-bg-secondary">${lines.length} корм(ов)</span>
</header>
<p class="small text-muted">Превью · запись рациона в WESP отдельным шагом</p>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-2">
<thead><tr><th>Корм</th><th class="text-end">кг</th><th class="text-end">руб.</th></tr></thead>
<tbody>${lines
.map(
(l) =>
`<tr><td>${escapeHtml(l.feedName)}</td><td class="text-end">${escapeHtml(l.dailyKg)}</td><td class="text-end">${escapeHtml(l.costRub)}</td></tr>`,
)
.join("")}</tbody>
</table>
${metaRows ? `<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>` : ""}
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${lines.length} строк`, false);
}
function renderSniffRationIndicators(panel, body) {
const rows = parseResult.indicators || [];
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР зоопоказатели</h3>
<span class="badge text-bg-secondary">${rows.length} показат.</span>
</header>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Норма</th><th class="text-end">Текущий</th></tr></thead>
<tbody>${rows
.map(
(r) =>
`<tr><td>${escapeHtml(r.name)}</td><td class="text-end">${escapeHtml(r.norm)}</td><td class="text-end fw-semibold">${escapeHtml(r.current)}</td></tr>`,
)
.join("")}</tbody>
</table>
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${rows.length} показат.`, false);
}
function renderSniffLabSamples(panel, body) {
if (!parseResult?.samples?.length) {
setResult("Нечего нюхать — нет проб", true);
return;
}
const labName = parseResult.labName || parseResult.sourceLabel || "AgroStar";
const cards = parseResult.samples.map((sample) => {
const preview = sample.preview;
if (!preview) return "";
const selectedName = getSelectedComponentLabel(sample.sampleNo);
const targetLine = selectedName
? `<p class="lab-agrostar-sniff__target mb-2"><span class="text-muted">Куда запишем:</span> <strong>${escapeHtml(selectedName)}</strong></p>`
: `<p class="lab-agrostar-sniff__target lab-agrostar-sniff__target--warn mb-2">Реагент не выбран — «+ добавить новый реагент»</p>`;
const metaRows = (preview.meta || [])
.map((row) => `<tr><th scope="row">${escapeHtml(row.label)}</th><td>${escapeHtml(row.value)}</td></tr>`)
.join("");
const nutrientRows = (preview.willWrite || [])
.map((row) => {
const sourceCell =
row.sourceValue != null
? `<td class="text-end">${escapeHtml(row.sourceValue)} <span class="text-muted small">${escapeHtml(row.sourceUnit || "")}</span></td>`
: `<td class="text-end text-muted">—</td>`;
return `<tr><td>${escapeHtml(row.label)}</td>${sourceCell}<td class="text-end fw-semibold">${escapeHtml(row.value)}</td><td class="text-muted small">${escapeHtml(row.unit)}</td></tr>`;
})
.join("");
const notes = (preview.notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
const notesBlock = notes
? `<div class="lab-agrostar-sniff__notes mt-3"><div class="small text-muted mb-1">Замечания</div><ul class="small mb-0">${notes}</ul></div>`
: "";
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
const storageBlock = skipNote
? `<p class="small lab-agrostar-sniff__skip mt-3 mb-0">${escapeHtml(skipNote)}</p>`
: `<p class="small text-muted mt-3 mb-0">В каталог: СВ + ${storage.nutrientCount || 0} показат.</p>`;
return `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">${escapeHtml(preview.title || sample.label)}</h3>
<span class="badge text-bg-secondary">${escapeHtml(preview.feedTypeRu || "—")}</span>
</header>
${targetLine}
<div class="row g-3">
<div class="col-md-6"><div class="small text-muted mb-1">Из файла</div>
<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>
</div>
<div class="col-md-6"><div class="small text-muted mb-1">WESP · ${preview.recognizedCount || 0} показат. · % из документа г/кг СВ (×10)</div>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Документ</th><th class="text-end">WESP</th><th></th></tr></thead>
<tbody>${nutrientRows}</tbody>
</table>
</div>
</div>
${notesBlock}${storageBlock}
</article>`;
});
body.innerHTML = cards.join("");
showEl(panel);
setResult(`Понюхали · ${labName} · ${parseResult.samples.length} образц(ов)`, false);
}
function hideSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
hideEl(panel);
if (body) body.innerHTML = "";
}
function collectAssignments() {
if (!parseResult?.samples?.length) return [];
const byNo = new Map(parseResult.samples.map((s) => [s.sampleNo, s]));
const assignments = [];
document.querySelectorAll(".lab-agrostar-component[data-sample-no]").forEach((sel) => {
const sampleNo = sel.getAttribute("data-sample-no") || "";
const sample = byNo.get(sampleNo);
if (!sample) return;
const componentId = sel.value && sel.value !== CREATE_NEW ? sel.value : null;
assignments.push({
sampleNo,
componentId,
dryMatterPct: sample.dryMatterPct,
nutrients: sample.nutrients || {},
warnings: sample.warnings || [],
});
});
return assignments;
}
function fileAllowed(name) {
const lower = (name || "").toLowerCase();
return ACCEPT_EXT.some((ext) => lower.endsWith(ext));
}
async function uploadFile(file) {
if (!file) return;
if (!fileAllowed(file.name)) {
setStatus("Формат не тот — xml, pdf или xlsx", true);
return;
}
selectedFile = file;
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
showEl(fileNameEl);
fileNameEl.textContent = file.name;
}
setStatus("Разбираем поставку…");
setResult("");
hideSniffPreview();
const fd = new FormData();
fd.append("file", file);
try {
parseResult = await api("/api/lab/import/parse", { method: "POST", body: fd });
await loadComponents();
renderAfterParse();
const kind = importKind();
if (kind === "lab_samples") {
setStatus(`На столе: ${parseResult.sampleCount || 0} образц(ов) · ${parseResult.sourceLabel || ""}`);
} else if (kind === "ration_composition") {
setStatus(`Рацион: ${parseResult.feedCount || 0} корм(ов) · ${parseResult.sourceLabel || ""}`);
} else {
setStatus(`Показатели: ${parseResult.indicatorCount || 0} · ${parseResult.sourceLabel || ""}`);
}
} catch (err) {
parseResult = null;
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
setStatus(err.message || "Файл не разобрался", true);
}
}
async function runApply() {
if (!isLabSamples()) {
setResult("Запись в каталог — только для лабораторных проб AgroStar", true);
return;
}
const assignments = collectAssignments();
if (!assignments.length) {
setResult("Нечего готовить — сначала кинь файл", true);
return;
}
const missing = assignments.filter((a) => !a.componentId);
if (missing.length) {
setResult(`Укажи реагент для ${missing.length} образц(ов) — Jesse бы не забыл`, true);
return;
}
setResult("Пишем в каталог…");
try {
const data = await api("/api/lab/import/agrostar-xml/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assignments, dryRun: false }),
});
const lines = (data.results || []).map(
(r) => `${r.sampleNo}: ${r.skipped ? "мимо" : r.componentName || "—"} (${r.nutrientCount} показат.)`,
);
setResult(
`Запомнили · в деле ${data.applied}, мимо ${data.skipped}` +
(lines.length ? "\n" + lines.join("\n") : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
setResult(err.message || "Наука не сработала — импорт сгорел", true);
}
}
function clearAll() {
parseResult = null;
selectedFile = null;
const input = $("labAgrostarFile");
if (input) input.value = "";
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
hideEl(fileNameEl);
fileNameEl.textContent = "";
}
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
hideSniffPreview();
$("labAgrostarPreview").disabled = true;
$("labAgrostarApply").disabled = true;
$("labAgrostarClear").disabled = true;
setStatus("");
setResult("");
}
function bindDropZone() {
const drop = $("labAgrostarDrop");
const input = $("labAgrostarFile");
if (!drop || !input) return;
drop.addEventListener("click", () => input.click());
drop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
input.addEventListener("change", () => {
const file = input.files?.[0];
if (file) uploadFile(file);
});
["dragenter", "dragover"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.add("lab-agrostar-drop--active");
});
});
["dragleave", "drop"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove("lab-agrostar-drop--active");
});
});
drop.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (file) uploadFile(file);
});
}
function init() {
if (!$("labAgrostarDrop")) return;
bindDropZone();
$("labAgrostarTableWrap")?.addEventListener("change", onComponentSelectChange);
$("labAgrostarPreview")?.addEventListener("click", renderSniffPreview);
$("labAgrostarApply")?.addEventListener("click", runApply);
$("labAgrostarClear")?.addEventListener("click", clearAll);
loadComponents().catch(() => {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
@@ -0,0 +1,104 @@
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function openLabAnimalProfilesModal() {
const resp = await fetch("/api/lab/animal-profiles");
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
global.WespZootechNotify?.createAdapter?.()?.error?.(data.message || "Ошибка загрузки профилей");
return;
}
const profiles = data.profiles || [];
const options = profiles
.map(
(p) =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
)
.join("");
const html =
`<div class="lab-profiles-editor">` +
`<p class="small text-muted mb-2">Профили норм стада (зоотех). Выберите для просмотра или создайте новый.</p>` +
`<label class="form-label">Существующие</label>` +
`<select class="form-select mb-2" id="labProfilePick"><option value="">—</option>${options}</select>` +
`<div id="labProfileView" class="small mb-3 text-muted" hidden></div>` +
`<hr>` +
`<label class="form-label">Новый / редактирование</label>` +
`<input class="form-control form-control-sm mb-1" id="labProfileKey" placeholder="Ключ (beef_grow)">` +
`<input class="form-control form-control-sm mb-1" id="labProfileLabel" placeholder="Название">` +
`<select class="form-select form-select-sm mb-1" id="labProfileType">` +
`<option value="BEEF">Мясное</option><option value="DAIRY">Дойное</option></select>` +
`<textarea class="form-control form-control-sm" id="labProfileNorms" rows="4" placeholder='{"dry_matter":{"min":4000,"max":5000}}'></textarea>` +
`</div>`;
const ok = await global.WespDialog?.confirm?.(html, {
title: "Профили норм стада",
html: true,
confirmText: "Сохранить",
cancelText: "Закрыть",
});
if (!ok) return;
const pick = document.getElementById("labProfilePick")?.value;
const key = document.getElementById("labProfileKey")?.value?.trim();
const label = document.getElementById("labProfileLabel")?.value?.trim();
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
let normsData = {};
try {
const raw = document.getElementById("labProfileNorms")?.value?.trim();
if (raw) normsData = JSON.parse(raw);
} catch (_) {
global.WespZootechNotify?.createAdapter?.()?.error?.("Некорректный JSON норм");
return;
}
if (!key || !label) {
global.WespZootechNotify?.createAdapter?.()?.error?.("Укажите ключ и название");
return;
}
const method = pick ? "PUT" : "POST";
const url = pick
? `/api/lab/animal-profiles/${encodeURIComponent(pick)}`
: "/api/lab/animal-profiles";
try {
const saveResp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profileKey: key, label, rationType, normsData }),
});
const saveData = await saveResp.json();
if (!saveResp.ok) throw new Error(saveData.message || "Ошибка сохранения");
global.WespZootechNotify?.createAdapter?.()?.success?.("Профиль сохранён");
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось сохранить профиль" });
}
}
document.addEventListener("change", (ev) => {
if (ev.target?.id !== "labProfilePick") return;
const id = ev.target.value;
const view = document.getElementById("labProfileView");
if (!id || !view) return;
fetch(`/api/lab/animal-profiles/${encodeURIComponent(id)}`)
.then((r) => r.json())
.then((p) => {
document.getElementById("labProfileKey").value = p.profileKey || "";
document.getElementById("labProfileLabel").value = p.label || "";
document.getElementById("labProfileType").value = p.rationType || "BEEF";
document.getElementById("labProfileNorms").value = JSON.stringify(p.normsData || {}, null, 2);
view.hidden = false;
view.textContent = `${p.profileKey} · ${p.rationType}`;
})
.catch(() => {});
});
global.openLabAnimalProfilesModal = openLabAnimalProfilesModal;
})(window);
@@ -0,0 +1,330 @@
(function (global) {
"use strict";
/** Показатели для зоотех-расчёта рациона (weighted average). */
const NUTRIENT_ROWS = [
{
label: "Осн. корм",
unit: "г/кг СВ",
keys: ["Осн.Корм"],
hint: "СВ этого сырья, учитываемое как объёмный (основной) корм в норме dm_main. 0 — концентрат/добавка.",
},
{ label: "Сыр. протеин", unit: "г/кг", keys: ["Сыр. Протеин"] },
{ label: "уСП", unit: "г/кг", keys: ["уСП"] },
{ label: "RNB", unit: "г/кг", keys: ["RNB", "БРА", " БРА "] },
{ label: "ЧЭЛ — КРС", unit: "МДж/кг", keys: ["ЧЭЛ- КРС", " ЧЭЛ- КРС"] },
{ label: "ОЭ — КРС", unit: "МДж/кг", keys: ["ОЭ-КРС", " ОЭ-КРС"] },
{ label: "Сырая клетчатка", unit: "г/кг", keys: ["Сырая клетч", "Сырая клетчатка"] },
{ label: "Структур. клетч.", unit: "г/кг", keys: ["Структур клетч", "Структур. клетч", "Структур. клетчатка"] },
{ label: "Сырой жир", unit: "г/кг", keys: ["Сырой жир"] },
];
/** Лаб. ввод для native derive (см. LAB_FORMULAS.md). СВ — только component.dry_matter (%). */
const LAB_INPUT_ROWS = [
{ label: "Сырая зола", unit: "г/кг", keys: ["Сырая зола"] },
{
label: "ВРХ орг. вещ.",
unit: "%",
keys: ["ВРХ Орг Вещ"],
hint: "Обязательно для грубых и сочных кормов. Без значения подставится дефолт WESP (65% / 72%).",
},
{
label: "уСП в ОР",
unit: "г/кг",
keys: ["уСП в ОР", "уСП в ОВ"],
hint: "Лабораторное уСП: при значении > 1 заменяет расчёт по формуле Lebzien.",
},
{ label: "КРС протеин", unit: "%", keys: ["КРС Протеин"] },
{ label: "КРС сырой жир", unit: "%", keys: ["КРС Сырой жир"] },
{ label: "КРС сырая клетч.", unit: "%", keys: ["КРС Сырая клетч"] },
{ label: "КРС БЭВ", unit: "%", keys: ["КРС БЭВ"] },
{ label: "НДК", unit: "г/кг", keys: ["НДК", "НДК Общ"] },
{ label: "NFC", unit: "г/кг", keys: ["NFC", "БЕР"] },
];
/** Минералы, аминокислоты и прочий лаб. ввод из AgroStar / импорта. */
const EXTRA_LAB_ROWS = [
{ label: "КДК (ADF)", unit: "г/кг", keys: ["КДК", "КДК общ"] },
{ label: "Ca", unit: "г/кг", keys: ["Ca"] },
{ label: "P", unit: "г/кг", keys: ["P"] },
{ label: "Mg", unit: "г/кг", keys: ["Mg"] },
{ label: "K", unit: "г/кг", keys: ["K"] },
{ label: "S", unit: "г/кг", keys: ["S"] },
{ label: "Cl", unit: "г/кг", keys: ["CL", "Cl"] },
{ label: "Сахар", unit: "г/кг", keys: ["Сахар"] },
{ label: "Крахмал", unit: "г/кг", keys: ["Крахмал"] },
{ label: "Нераств. протеин", unit: "%", keys: ["% нераствор протеин"] },
{ label: "Лизин", unit: "г/кг", keys: ["Лизин"] },
{ label: "Метионин", unit: "г/кг", keys: ["Метионин"] },
{ label: "Лейцин", unit: "г/кг", keys: ["Лейцин"] },
{ label: "Изолейцин", unit: "г/кг", keys: ["Изолейцин"] },
{ label: "Валин", unit: "г/кг", keys: ["Валин"] },
];
const ALL_FORM_ROWS = [...NUTRIENT_ROWS, ...LAB_INPUT_ROWS, ...EXTRA_LAB_ROWS];
const CARD_INFO_COLUMNS = [
{
label: "Сухое вещество",
value(component) {
const dm = component.dryMatter ?? component.dry_matter;
return dm == null || dm === "" ? "—" : `${dm}%`;
},
},
{
label: "№ справочника",
value(component) {
const n = component.externalNo ?? component.external_no;
return n == null || n === "" ? "—" : String(n);
},
},
{
label: "Цена",
value(component) {
const price = component.price;
return price == null || price === "" ? "—" : `${price} руб/кг`;
},
},
{
label: "Осн. корм",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["Осн.Корм"]);
return val == null ? "—" : `${val} г/кг`;
},
},
{
label: "Сыр. протеин",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["Сыр. Протеин"]);
return val == null ? "—" : `${val} г/кг`;
},
},
{
label: "ОЭ — КРС",
value(_component, nutrients) {
const val = readNutrientValue(nutrients, ["ОЭ-КРС", " ОЭ-КРС"]);
return val == null ? "—" : `${val} МДж/кг`;
},
},
{
label: "СВ (расч.)",
value(component) {
const dm = component.dryMatter ?? component.dry_matter;
if (dm == null || dm === "") return "—";
const n = Number(dm);
if (Number.isNaN(n)) return "—";
return `${(n * 10).toFixed(1)} г/кг`;
},
},
{
label: "Показателей",
value(_component, nutrients) {
return String(Object.keys(nutrients || {}).length);
},
},
];
function normalizeKey(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function parseNutrients(raw) {
if (!raw) return {};
if (typeof raw === "object") return { ...raw };
try {
return JSON.parse(raw) || {};
} catch (_) {
return {};
}
}
function readNutrientValue(nutrients, keys) {
const map = nutrients || {};
for (const search of keys) {
const target = normalizeKey(search);
for (const [k, v] of Object.entries(map)) {
if (normalizeKey(k) !== target) continue;
const n = Number(v);
if (!Number.isNaN(n)) return n;
}
}
return null;
}
function canonicalKey(keys) {
return keys[0];
}
function buildNutrientsPayload(root) {
const payload = {};
if (!root) return payload;
root.querySelectorAll("[data-nutrient-key]").forEach((input) => {
const key = input.getAttribute("data-nutrient-key");
const val = input.value.trim();
if (key && val !== "") payload[key] = Number(val);
});
return payload;
}
function rowFieldsHtml(rows, nutrients) {
return rows
.map((row) => {
const key = canonicalKey(row.keys);
const val = readNutrientValue(nutrients, row.keys);
const hint = row.hint ? ` title="${row.hint}"` : "";
return (
`<div class="lab-nutrient-field"${hint}>` +
`<label class="form-label small mb-0 lab-nutrient-field__label">${row.label}` +
`<span class="text-muted"> (${row.unit})</span></label>` +
`<input type="number" step="0.01" class="form-control form-control-sm" ` +
`data-nutrient-key="${key}" value="${val == null ? "" : val}" placeholder="—">` +
`</div>`
);
})
.join("");
}
const _OMD_FEED_TYPES = new Set(["грубые корма", "сочные корма", "объемные корма", "объёмные корма"]);
function feedGroupNeedsOmd(component) {
if (!component || !component.type) return false;
return _OMD_FEED_TYPES.has(normalizeKey(component.type));
}
function omdWarningHtml(component, nutrients) {
if (!feedGroupNeedsOmd(component)) return "";
if (readNutrientValue(nutrients, ["ВРХ Орг Вещ", "КРС Орг Вещ"]) != null) return "";
return (
`<div class="alert alert-warning py-2 px-3 small mb-2" role="status">` +
`Для грубых и сочных кормов укажите <strong>ВРХ орг. вещ.</strong> — ` +
`без анализа расчёт энергии и уСП будет ненадёжным.` +
`</div>`
);
}
function nutrientsFormHtml(nutrients, component) {
const calcRows = rowFieldsHtml(NUTRIENT_ROWS, nutrients);
const labRows = rowFieldsHtml(LAB_INPUT_ROWS, nutrients);
const extraRows = rowFieldsHtml(EXTRA_LAB_ROWS, nutrients);
const warn = omdWarningHtml(component, nutrients);
return (
warn +
`<div class="lab-nutrients-block" data-lab-nutrients-block>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-flask text-primary me-1"></i>` +
`<span class="lab-nutrients-block__title">Показатели для расчёта рациона</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--calc">${calcRows}</div>` +
`</div>` +
`<div class="lab-nutrients-block lab-nutrients-block--lab-input mt-3" data-lab-nutrients-lab>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-vial text-secondary me-1"></i>` +
`<span class="lab-nutrients-block__title">Лабораторный ввод (derive)</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--lab">${labRows}</div>` +
`</div>` +
`<div class="lab-nutrients-block lab-nutrients-block--extra mt-3" data-lab-nutrients-extra>` +
`<div class="lab-nutrients-block__head">` +
`<i class="fas fa-atom text-secondary me-1"></i>` +
`<span class="lab-nutrients-block__title">Минералы и аминокислоты</span>` +
`</div>` +
`<div class="lab-nutrients-row lab-nutrients-row--extra">${extraRows}</div>` +
`</div>`
);
}
function mountCreateForm() {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function mountEditForm() {
const mount = document.getElementById("labNutrientsEditMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function resetCreateForm() {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml({});
mount.dataset.mounted = "1";
}
function fillCreateFromComponent(component) {
const mount = document.getElementById("labNutrientsCreateMount");
if (!mount || !component) return;
const nutrients = parseNutrients(component.nutrients);
mount.innerHTML = nutrientsFormHtml(nutrients, component);
mount.dataset.mounted = "1";
}
function fillEditNutrients(component) {
const mount = document.getElementById("labNutrientsEditMount");
if (!mount) return;
mount.innerHTML = nutrientsFormHtml(parseNutrients(component.nutrients), component);
mount.dataset.mounted = "1";
}
function formatCardItems(component) {
const nutrients = parseNutrients(component.nutrients);
return CARD_INFO_COLUMNS.map((col) => {
const value = col.value(component, nutrients);
return {
label: col.label,
value,
empty: value === "—",
};
});
}
function renderCardInfoHtml(component) {
if (!global.WESP_CAN_LAB) return "";
return formatCardItems(component)
.map(
(item) =>
`<div class="info-item${item.empty ? " info-item--empty" : ""}">` +
`<span class="info-label">${item.label}</span>` +
`<span class="info-value">${item.value}</span>` +
`</div>`
)
.join("");
}
function collectFromCreateForm() {
return buildNutrientsPayload(document.getElementById("labNutrientsCreateMount"));
}
function collectFromEditForm() {
return buildNutrientsPayload(document.getElementById("labNutrientsEditMount"));
}
global.WespLabComponentsNutrients = {
NUTRIENT_ROWS,
LAB_INPUT_ROWS,
EXTRA_LAB_ROWS,
normalizeKey,
readNutrientValue,
parseNutrients,
nutrientsFormHtml,
mountCreateForm,
mountEditForm,
resetCreateForm,
fillCreateFromComponent,
fillEditNutrients,
renderCardInfoHtml,
collectFromCreateForm,
collectFromEditForm,
init() {
if (!global.WESP_CAN_LAB) return;
mountCreateForm();
mountEditForm();
},
};
})(window);
+601
View File
@@ -0,0 +1,601 @@
(function (global) {
"use strict";
const DEFAULT_OPTIMIZE_KEYS = [
"dry_matter",
"usp",
"nel",
"crude_protein",
"rnb",
"nfc_pct_dm_uk",
];
let catalog = [];
let groups = [];
let profiles = [];
let currentStep = 1;
const selections = {};
function formulateError(message) {
global.WespLabHeisenbergPrime?.error?.(message, "formulate");
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
}
function diffClass(diff) {
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
const d = Number(diff);
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
return "lab-sandbox-diff--bad";
}
function totalSteps() {
return groups.length + 1;
}
function groupById(id) {
return groups.find((g) => g.id === id);
}
function selectedForGroup(gid) {
return Array.from(document.querySelectorAll(`.lab-formulate-gcb[data-group="${gid}"]:checked`)).map(
(el) => el.value
);
}
function allSelections() {
const out = {};
groups.forEach((g) => {
out[g.id] = selectedForGroup(g.id);
});
return out;
}
function totalPicked() {
return Object.values(allSelections()).reduce((n, arr) => n + arr.length, 0);
}
function componentsForGroup(gid, filterText) {
const q = (filterText || "").trim().toLowerCase();
return catalog.filter((c) => {
if (!c.eligible || c.feedGroup !== gid) return false;
if (q && !String(c.name || "").toLowerCase().includes(q)) return false;
return true;
});
}
function renderGroupTable(gid, filterText) {
const body = document.querySelector(`#labFormulatePane-${gid} tbody`);
if (!body) return;
const picked = new Set(selectedForGroup(gid));
const rows = componentsForGroup(gid, filterText);
if (!rows.length) {
body.innerHTML =
'<tr><td colspan="4" class="lab-sandbox-empty">В этой фракции пусто — нечего готовить</td></tr>';
return;
}
body.innerHTML = rows
.map((c) => {
const checked = picked.has(c.id) ? " checked" : "";
return (
`<tr>` +
`<td><input type="checkbox" class="form-check-input lab-formulate-gcb" data-group="${escapeHtml(gid)}" value="${escapeHtml(c.id)}"${checked}></td>` +
`<td>${escapeHtml(c.name)}</td>` +
`<td>${formatNum(c.mainFeedDmGPerKg)}</td>` +
`<td>${formatNum(c.dryMatterPct)}%</td>` +
`</tr>`
);
})
.join("");
}
function buildWizard() {
const stepsEl = document.getElementById("labFormulateSteps");
const panesEl = document.getElementById("labFormulatePanes");
if (!stepsEl || !panesEl) return;
stepsEl.innerHTML =
groups
.map(
(g, i) =>
`<button type="button" class="lab-formulate-step${i === 0 ? " lab-formulate-step--active" : ""}" data-step="${i + 1}">${i + 1}. ${escapeHtml(g.shortLabel)}</button>`
)
.join("") +
`<button type="button" class="lab-formulate-step" data-step="${groups.length + 1}">${groups.length + 1}. Кристалл</button>`;
panesEl.innerHTML = groups
.map((g) => {
const req = g.required
? `<span class="lab-formulate-badge lab-formulate-badge--ok">без этого никак</span>`
: `<span class="lab-formulate-badge">не мешай</span>`;
return (
`<div id="labFormulatePane-${g.id}" class="lab-formulate-pane${g.step === 1 ? "" : " d-none"}" data-group="${g.id}">` +
`<div class="d-flex justify-content-between align-items-center mb-2">` +
`<div class="lab-sandbox-panel__title mb-0">${escapeHtml(g.label)}</div>${req}</div>` +
`<p class="small text-muted mb-2">${escapeHtml(g.hint)}</p>` +
`<p class="small mb-2 lab-formulate-group-count" data-group="${g.id}">В партии: 0</p>` +
`<div class="lab-sandbox-field">` +
`<input type="search" class="form-control form-control-sm lab-formulate-filter" data-group="${g.id}" placeholder="Найти реагент">` +
`</div>` +
`<div class="table-responsive lab-formulate-pool-wrap">` +
`<table class="table table-sm lab-sandbox-table mb-0">` +
`<thead><tr><th></th><th>Реагент</th><th>Осн.</th><th>СВ%</th></tr></thead>` +
`<tbody></tbody></table></div>` +
`<div class="lab-sandbox-actions mt-2">` +
(g.step > 1
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-back" data-step="${g.step}">← Откат</button>`
: "") +
(!g.required
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-skip" data-step="${g.step}">Не мешай</button>`
: "") +
`<button type="button" class="btn btn-sm btn-primary lab-formulate-next" data-step="${g.step}">Вперёд →</button>` +
`</div></div>`
);
})
.join("");
groups.forEach((g) => renderGroupTable(g.id, ""));
}
function updateCounters() {
const sel = allSelections();
groups.forEach((g) => {
const el = document.querySelector(`.lab-formulate-group-count[data-group="${g.id}"]`);
if (el) {
const n = (sel[g.id] || []).length;
el.textContent = g.required ? `В партии: ${n} (мин. ${g.minPick})` : `В партии: ${n}`;
}
});
const total = totalPicked();
const summaryEl = document.getElementById("labFormulateSummary");
const prefilter = document.getElementById("labFormulatePrefilterNote");
const roughOk = (sel.rough || []).length >= 1;
if (summaryEl) {
summaryEl.textContent = roughOk && total >= 3
? `Партия из ${total} реагентов — можно готовить`
: `Нужна база (≥1) и ≥3 реагента всего — сейчас ${total}. Нам нужно готовить.`;
}
if (prefilter) prefilter.classList.toggle("d-none", total <= 18);
const runBtn = document.getElementById("labFormulateRun");
if (runBtn) runBtn.disabled = !(roughOk && total >= 3 && currentStep === totalSteps());
renderReview();
}
function renderReview() {
const el = document.getElementById("labFormulateReview");
if (!el) return;
const sel = allSelections();
let html = "";
groups.forEach((g) => {
const ids = sel[g.id] || [];
html += `<li><strong>${escapeHtml(g.shortLabel)}</strong> (${ids.length})</li>`;
ids.forEach((id) => {
const name = catalog.find((c) => c.id === id)?.name || id;
html += `<li class="ms-3">${escapeHtml(name)}</li>`;
});
});
el.innerHTML = html || "<li>—</li>";
}
function setStep(step) {
currentStep = step;
const calcStep = totalSteps();
groups.forEach((g) => {
document.getElementById(`labFormulatePane-${g.id}`)?.classList.toggle("d-none", g.step !== step);
});
document.getElementById("labFormulateReviewPane")?.classList.toggle("d-none", step !== calcStep);
document.querySelector(".lab-formulate-step-panel--calc")?.classList.toggle("d-none", step !== calcStep);
document.querySelectorAll(".lab-formulate-step").forEach((btn) => {
btn.classList.toggle("lab-formulate-step--active", Number(btn.dataset.step) === step);
});
updateCounters();
}
function canGoToStep(step) {
if (step <= 1) return true;
const sel = allSelections();
for (const g of groups) {
if (g.step >= step) break;
if (g.required && (sel[g.id] || []).length < (g.minPick || 1)) return false;
}
if (step === totalSteps()) {
return (sel.rough || []).length >= 1 && totalPicked() >= 3;
}
return true;
}
function readNumInput(id) {
const el = document.getElementById(id);
if (!el) return null;
const raw = String(el.value ?? "").trim();
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function readNormsMethod() {
return document.getElementById("labFormulateNormsMethod")?.value || "wesp";
}
function readNormsParams() {
const method = readNormsMethod();
if (method === "wesp") return {};
const params = {
milkFatPct: readNumInput("labFormulateFat") ?? 4,
lactationNo: readNumInput("labFormulateLactation") ?? 2,
};
if (method === "racion_piter") {
params.koncOeSv = readNumInput("labFormulateKonc") ?? 10.3;
}
return params;
}
function syncNormsMethodUi() {
const method = readNormsMethod();
const racionBox = document.getElementById("labFormulateRacionFields");
const piterOnly = document.querySelectorAll(".lab-formulate-piter-only");
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
}
function readFormulateParams() {
return {
profileId: document.getElementById("labFormulateProfile")?.value || "",
massKg: readNumInput("labFormulateMass"),
milkYieldKg: readNumInput("labFormulateMilk"),
totalKgPerHead: readNumInput("labFormulateTotalKg") ?? 7.3,
costWeight: readNumInput("labFormulateCostWeight") ?? 100,
optimizeKeys: Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value),
groupSelections: allSelections(),
normsMethod: readNormsMethod(),
normsParams: readNormsParams(),
};
}
function resolveMassMilk(profile) {
return {
mass: readNumInput("labFormulateMass") ?? profile?.massKg ?? null,
milk: readNumInput("labFormulateMilk") ?? profile?.milkYieldKg ?? null,
};
}
async function normsForOptimizeDisplay(profile) {
const method = readNormsMethod();
let norms =
method === "wesp"
? { ...(profile?.norms || profile?.normsData?.indicators || {}) }
: {};
const { mass, milk } = resolveMassMilk(profile);
if (method !== "wesp" && (mass == null || milk == null)) return norms;
if (mass == null && milk == null) return norms;
try {
const q = new URLSearchParams({ method });
const profileId = document.getElementById("labFormulateProfile")?.value;
if (profileId) q.set("profile_id", profileId);
if (mass != null) q.set("mass_kg", String(mass));
if (milk != null) q.set("milk_yield_kg", String(milk));
const np = readNormsParams();
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
const resolved = preview.resolvedIndicators || {};
const dynamic = preview.dynamicNorms || {};
const merged = { ...resolved };
Object.keys(dynamic).forEach((key) => {
const d = dynamic[key];
if (d?.min != null) merged[key] = { ...(merged[key] || {}), min: d.min };
});
if (method === "wesp") {
Object.assign(norms, merged);
} else {
norms = merged;
}
} catch (_err) {
/* preview optional */
}
return norms;
}
async function refreshOptimizeKeysForInputs() {
const profileId = document.getElementById("labFormulateProfile")?.value;
const profile = profiles.find((p) => String(p.id) === String(profileId));
if (!profile) return;
const norms = await normsForOptimizeDisplay(profile);
renderOptimizeKeys({ ...profile, norms });
}
let optimizeKeysRefreshTimer = null;
function scheduleOptimizeKeysRefresh() {
if (optimizeKeysRefreshTimer) clearTimeout(optimizeKeysRefreshTimer);
optimizeKeysRefreshTimer = setTimeout(() => {
optimizeKeysRefreshTimer = null;
refreshOptimizeKeysForInputs().catch(() => {});
}, 200);
}
function profileOptions(selectedId) {
const opts = ['<option value="">— стандарт чистоты —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
const key = p.profileKey ? `[${p.profileKey}] ` : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)}</option>`
);
});
return opts.join("");
}
function fillProfileFields(profileId) {
const profile = profiles.find((p) => String(p.id) === String(profileId));
if (!profile) return;
const massEl = document.getElementById("labFormulateMass");
const milkEl = document.getElementById("labFormulateMilk");
if (massEl && profile.massKg != null) massEl.value = profile.massKg;
if (milkEl && profile.milkYieldKg != null) milkEl.value = profile.milkYieldKg;
refreshOptimizeKeysForInputs().catch(() => renderOptimizeKeys(profile));
}
function renderOptimizeKeys(profile) {
const box = document.getElementById("labFormulateOptimizeKeys");
if (!box) return;
const norms = profile?.norms || profile?.normsData?.indicators || {};
const keys = Object.keys(norms).filter((k) => norms[k]?.min != null || norms[k]?.max != null);
const useKeys = keys.length ? keys : DEFAULT_OPTIMIZE_KEYS;
const prevChecked = new Set(
Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value)
);
const hasPrev = prevChecked.size > 0;
box.innerHTML = useKeys
.map((key) => {
const checked =
(hasPrev ? prevChecked.has(key) : DEFAULT_OPTIMIZE_KEYS.includes(key)) ? " checked" : "";
const b = norms[key] || {};
const bounds =
b.min != null && b.max != null
? `${formatNum(b.min)}${formatNum(b.max)}`
: b.max != null
? `${formatNum(b.max)}`
: b.min != null
? `${formatNum(b.min)}`
: "";
return (
`<label class="lab-formulate-opt-key">` +
`<input type="checkbox" class="form-check-input lab-formulate-opt-cb" value="${escapeHtml(key)}"${checked}>` +
`<span>${escapeHtml(key)}</span>` +
`<small class="text-muted">${escapeHtml(bounds)}</small>` +
`</label>`
);
})
.join("");
}
function renderResult(data) {
const el = document.getElementById("labFormulateResult");
if (!el) return;
if (!data) {
el.innerHTML = '<div class="lab-sandbox-empty">Скажи моё имя и жми кнопку</div>';
return;
}
const stats = data.searchStats || {};
const optimizeSet = new Set(data.optimizeKeys || []);
let html =
`<div class="lab-formulate-result-meta">` +
`<span>Реагентов ${data.candidatePoolSize}</span>` +
`<span>Троек ${stats.tripletsEvaluated || 0}</span>` +
`<span>${stats.durationMs || 0} мс — время деньги</span></div>` +
`<table class="table table-sm lab-sandbox-table mb-3"><thead><tr><th>Реагент</th><th>кг/сут</th><th>%</th></tr></thead><tbody>` +
(data.lines || [])
.map(
(line) =>
`<tr><td>${escapeHtml(line.name)}</td><td>${formatNum(line.dailyKg)}</td><td>${formatNum(line.sharePct)}</td></tr>`
)
.join("") +
`</tbody></table>`;
const rows = (data.indicators || []).filter(
(ind) => ind.min != null || ind.max != null || ind.content != null
);
html +=
`<table class="table table-sm lab-sandbox-table"><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
rows
.map((ind) => {
const target = optimizeSet.has(ind.key) ? " lab-formulate-row--target" : "";
const dc = ind.min != null || ind.max != null ? diffClass(ind.diff) : "lab-sandbox-diff--na";
const norm =
ind.min != null && ind.max != null
? `${formatNum(ind.min)}${formatNum(ind.max)}`
: ind.max != null
? `${formatNum(ind.max)}`
: ind.min != null
? `${formatNum(ind.min)}`
: "—";
return (
`<tr class="${target}"><td>${escapeHtml(ind.label || ind.key)}</td>` +
`<td>${formatNum(ind.content)}</td><td>${norm}</td>` +
`<td class="${dc}">${formatNum(ind.diff)}</td></tr>`
);
})
.join("") +
`</tbody></table>`;
el.innerHTML = html;
}
async function loadCatalogs() {
const [compData, profData] = await Promise.all([
api("/api/lab/formulate/components"),
api("/api/lab/animal-profiles?ration_type=DAIRY"),
]);
catalog = compData.components || [];
groups = (compData.groups || []).slice().sort((a, b) => a.step - b.step);
profiles = profData.profiles || [];
buildWizard();
const profileSel = document.getElementById("labFormulateProfile");
if (profileSel) {
const labDefault = profiles.find((p) => p.profileKey === "lab_math_dairy_01") || profiles[0];
profileSel.innerHTML = profileOptions(labDefault?.id);
if (labDefault) fillProfileFields(labDefault.id);
}
setStep(1);
}
async function labFormulateRun() {
const params = readFormulateParams();
const profile = profiles.find((p) => String(p.id) === String(params.profileId));
if (!params.profileId) throw new Error("Выбери стандарт чистоты — я предупреждал");
if (params.normsMethod !== "wesp") {
const { mass, milk } = resolveMassMilk(profile);
if (mass == null || milk == null) {
throw new Error("Для Москва/Петербург нужны масса и удой — заполни поля или выбери профиль с удоем");
}
params.massKg = mass;
params.milkYieldKg = milk;
}
if ((params.groupSelections.rough || []).length < 1) {
throw new Error("Без базы не готовим. Минимум один грубый корм");
}
if (totalPicked() < 3) throw new Error("Минимум три реагента. Нам нужно готовить");
const data = await api("/api/lab/formulate", {
method: "POST",
headers: { "Content-Type": "application/json" },
cache: "no-store",
body: JSON.stringify({
profileId: params.profileId,
groupSelections: params.groupSelections,
massKg: params.massKg,
milkYieldKg: params.milkYieldKg,
totalKgPerHead: params.totalKgPerHead,
costWeight: params.costWeight,
optimizeKeys: params.optimizeKeys,
normsMethod: params.normsMethod,
normsParams: params.normsParams,
}),
});
renderResult(data);
return data;
}
function selectLabForGroup(gid) {
catalog
.filter((c) => c.eligible && c.feedGroup === gid && String(c.name || "").startsWith("LAB тест —"))
.forEach((c) => {
const cb = document.querySelector(
`.lab-formulate-gcb[data-group="${CSS.escape(gid)}"][value="${CSS.escape(c.id)}"]`
);
if (cb) cb.checked = true;
});
updateCounters();
}
function bindEvents() {
document.getElementById("labFormulateRun")?.addEventListener("click", async () => {
try {
await labFormulateRun();
} catch (err) {
formulateError(String(err.message || err));
} finally {
updateCounters();
}
});
document.getElementById("labFormulateProfile")?.addEventListener("change", (e) => {
fillProfileFields(e.target.value);
});
for (const id of [
"labFormulateMass",
"labFormulateMilk",
"labFormulateTotalKg",
"labFormulateCostWeight",
"labFormulateFat",
"labFormulateLactation",
"labFormulateKonc",
]) {
document.getElementById(id)?.addEventListener("input", scheduleOptimizeKeysRefresh);
}
document.getElementById("labFormulateNormsMethod")?.addEventListener("change", () => {
syncNormsMethodUi();
scheduleOptimizeKeysRefresh();
});
document.getElementById("labFormulatePanes")?.addEventListener("input", (e) => {
if (e.target.classList.contains("lab-formulate-filter")) {
renderGroupTable(e.target.dataset.group, e.target.value);
}
});
document.getElementById("labFormulatePanes")?.addEventListener("change", (e) => {
if (e.target.classList.contains("lab-formulate-gcb")) updateCounters();
});
document.getElementById("labFormulatePanes")?.addEventListener("click", (e) => {
const next = e.target.closest(".lab-formulate-next");
const back = e.target.closest(".lab-formulate-back");
const skip = e.target.closest(".lab-formulate-skip");
if (next) {
const step = Number(next.dataset.step);
const g = groups.find((x) => x.step === step);
if (g?.required && selectedForGroup(g.id).length < (g.minPick || 1)) {
formulateError(`В «${g.label}» нужно минимум ${g.minPick}. Всё под контролем — выбери`);
return;
}
setStep(step + 1);
}
if (back) setStep(Number(back.dataset.step) - 1);
if (skip) setStep(Number(skip.dataset.step) + 1);
});
document.getElementById("labFormulateSteps")?.addEventListener("click", (e) => {
const btn = e.target.closest(".lab-formulate-step");
if (!btn) return;
const step = Number(btn.dataset.step);
if (!canGoToStep(step)) return;
setStep(step);
});
document.addEventListener("keydown", (e) => {
if (e.altKey && e.key === "l") {
selectLabForGroup("rough");
selectLabForGroup("succulent");
selectLabForGroup("concentrate");
}
});
}
function init() {
bindEvents();
syncNormsMethodUi();
loadCatalogs().catch((err) => {
const msg = err.message || String(err);
formulateError(msg);
const panes = document.getElementById("labFormulatePanes");
if (panes) panes.innerHTML = `<p class="text-danger">${escapeHtml(msg)}</p>`;
});
}
global.labFormulateRun = labFormulateRun;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
@@ -0,0 +1,93 @@
(function (global) {
"use strict";
const MAX_MS = 5000;
const MIN_MS = 2400;
const DEFAULT_MS = 4000;
const SOURCE_LABELS = {
agrostar: "Поставка AgroStar",
sandbox: "Песочница рациона",
formulate: "Автоготовка",
lab: "Лаборатория",
};
let overlay = null;
let hideTimer = null;
function ensureOverlay() {
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.id = "labHeisenbergPrime";
overlay.className = "lab-heisenberg-prime-overlay";
overlay.hidden = true;
overlay.innerHTML =
'<div class="lab-heisenberg-prime__vignette" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__eyes" aria-hidden="true">' +
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--left"></span>' +
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--right"></span>' +
"</div>" +
'<div class="lab-heisenberg-prime__glow" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__scanlines" aria-hidden="true"></div>' +
'<div class="lab-heisenberg-prime__banner" role="alert" aria-live="assertive">' +
'<div class="lab-heisenberg-prime__title">В нашем деле - или ты, или тебя.</div>' +
"</div>";
document.body.appendChild(overlay);
return overlay;
}
function sourceLabel(source) {
const key = String(source || "lab").toLowerCase();
return SOURCE_LABELS[key] || SOURCE_LABELS.lab;
}
function dismiss() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
document.body.classList.remove("lab-heisenberg-prime-active");
if (overlay) overlay.hidden = true;
}
/**
* @param {string} reason почему сработал prime (показываем пользователю)
* @param {{ source?: string, durationMs?: number }} [options]
*/
function trigger(reason, options) {
if (!document.body.classList.contains("lab-sandbox-page")) return;
const opts = options || {};
const text = String(reason || "Что-то пошло не по плану").trim();
const duration = Math.min(MAX_MS, Math.max(MIN_MS, Number(opts.durationMs) || DEFAULT_MS));
global.WespZootechNotify?.dismissAll?.();
dismiss();
const el = ensureOverlay();
const reasonEl = el.querySelector(".lab-heisenberg-prime__reason");
const sourceEl = el.querySelector(".lab-heisenberg-prime__source");
if (reasonEl) reasonEl.textContent = text;
el.hidden = false;
requestAnimationFrame(() => {
document.body.classList.add("lab-heisenberg-prime-active");
});
hideTimer = setTimeout(dismiss, duration);
}
/** Ошибка на /lab: только prime (без toast/alert — они ломают сцену). */
function error(reason, source) {
trigger(reason, { source: source || "lab" });
}
global.WespLabHeisenbergPrime = {
trigger,
dismiss,
error,
MAX_MS,
SOURCE_LABELS,
};
})(window);
@@ -0,0 +1,272 @@
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function createLabMasterPanel() {
let root = null;
let recipeId = null;
let state = null;
let components = [];
let profiles = [];
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || "Ошибка API");
return data;
}
async function ensureCatalogs(rationType) {
const [compResp, profData] = await Promise.all([
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
]);
if (Array.isArray(compResp)) components = compResp;
profiles = profData.profiles || [];
}
async function loadRecipes() {
const data = await api("/api/lab/recipes");
return data.recipes || [];
}
async function loadRation(id) {
return api(`/api/lab/rations/${encodeURIComponent(id)}`);
}
function componentOptionsHtml(selectedId) {
const opts = ['<option value="">— компонент —</option>'];
components.forEach((c) => {
const sel = String(c.id) === String(selectedId) ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
});
return opts.join("");
}
function profileOptionsHtml(selectedId) {
const opts = ['<option value="">— профиль норм —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
);
});
return opts.join("");
}
function render() {
if (!root) return;
const indicators = (state?.rationResults?.indicators || []).slice(0, 8);
const lines = state?.lines || [];
root.innerHTML =
`<div class="zt-k-hub-lab">` +
`<div class="zt-k-hub-lab__toolbar">` +
`<label class="zt-k-hub-lab__field">Рецепт <select data-lab-recipe-select class="form-select form-select-sm"></select></label>` +
`<label class="zt-k-hub-lab__field">Профиль <select data-lab-profile-select class="form-select form-select-sm">${profileOptionsHtml(state?.animalProfileId)}</select></label>` +
`<label class="zt-k-hub-lab__field">Тип <select data-lab-ration-type class="form-select form-select-sm">` +
`<option value="BEEF" ${state?.rationType === "BEEF" ? "selected" : ""}>Мясное</option>` +
`<option value="DAIRY" ${state?.rationType === "DAIRY" ? "selected" : ""}>Дойное</option>` +
`</select></label>` +
`<button type="button" class="btn btn-sm btn-primary" data-action="lab-recalculate">Пересчёт</button>` +
`<button type="button" class="btn btn-sm btn-outline-primary" data-action="lab-save">Сохранить</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-add-line">+ Строка</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-ration" title="Печать рациона">Печать</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-compound" title="Печать комбикорма">Комб.</button>` +
`</div>` +
`<div class="zt-k-hub-lab__subtitle">Зоотехнический мастер · ${escapeHtml(state?.recipeName || "")}</div>` +
`<table class="table table-sm zt-k-hub-lab__table"><thead><tr>` +
`<th>Компонент WESP</th><th>кг/день</th><th>В рац.</th><th>Комб.</th><th></th></tr></thead><tbody>` +
(lines.length
? lines
.map(
(l, i) =>
`<tr data-line-idx="${i}">` +
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptionsHtml(l.componentId)}</select></td>` +
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${l.dailyKg ?? ""}"></td>` +
`<td class="text-center"><input type="checkbox" data-field="inRation" ${l.inRation ? "checked" : ""}></td>` +
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${l.inCompound ? "checked" : ""}></td>` +
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="lab-remove-line" data-line-idx="${i}" title="Удалить">&times;</button></td>` +
`</tr>`
)
.join("")
: `<tr><td colspan="5" class="text-muted text-center">Нет строк — добавьте или создайте из рецепта</td></tr>`) +
`</tbody></table>` +
`<div class="zt-k-hub-lab__indicators">` +
indicators
.map(
(ind) =>
`<span class="badge bg-light text-dark me-1 mb-1">${escapeHtml(ind.label)}: ${escapeHtml(ind.content)} ${escapeHtml(ind.unit)}</span>`
)
.join("") +
`</div></div>`;
const sel = root.querySelector("[data-lab-recipe-select]");
if (sel && sel.options.length === 0) {
loadRecipes().then((recipes) => {
sel.innerHTML =
'<option value="">—</option>' +
recipes
.map(
(r) =>
`<option value="${escapeHtml(r.id)}" ${r.id === recipeId ? "selected" : ""}>${escapeHtml(r.name)}</option>`
)
.join("");
});
}
}
async function reload() {
if (!recipeId) return;
try {
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
if (!state.exists) {
const ok = await global.WespDialog?.confirm?.(
"Создать мастер из текущего рецепта?",
{ title: "Мастер рациона" }
);
if (ok) {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, {
method: "POST",
});
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
} else {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, {
method: "POST",
});
state = await loadRation(recipeId);
await ensureCatalogs(state.rationType);
}
}
render();
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
}
function collectLines() {
if (!root || !state) return [];
return (state.lines || []).map((line, i) => {
const row = root.querySelector(`tr[data-line-idx="${i}"]`);
if (!row) return line;
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
const comp = components.find((c) => String(c.id) === String(componentId));
return {
...line,
componentId,
ingredientName: comp?.name || line.ingredientName,
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
inRation: row.querySelector('[data-field="inRation"]')?.checked,
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
};
});
}
function collectPayload() {
const rationType = root.querySelector("[data-lab-ration-type]")?.value || state?.rationType;
return {
lines: collectLines(),
rationType,
animalProfileId: root.querySelector("[data-lab-profile-select]")?.value || null,
};
}
return {
mount(el) {
root = el;
root.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn || !recipeId) return;
const action = btn.getAttribute("data-action");
try {
if (action === "lab-add-line") {
state.lines = [...(state?.lines || []), {
componentId: "",
ingredientName: "",
dailyKg: 0,
inRation: true,
inCompound: false,
}];
render();
return;
}
if (action === "lab-remove-line") {
const idx = parseInt(btn.getAttribute("data-line-idx"), 10);
if (!Number.isNaN(idx)) {
state.lines = (state.lines || []).filter((_, i) => i !== idx);
render();
}
return;
}
if (action === "lab-recalculate") {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, {
method: "POST",
});
await reload();
}
if (action === "lab-save") {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await reload();
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер сохранён");
}
if (action === "lab-print-ration" || action === "lab-print-compound") {
const mode = action === "lab-print-compound" ? "compound" : "ration";
global.open?.(
`/api/lab/rations/${encodeURIComponent(recipeId)}/print?mode=${mode}`,
"_blank",
"noopener"
);
}
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
});
root.addEventListener("change", async (ev) => {
if (ev.target.matches("[data-lab-recipe-select]")) {
recipeId = ev.target.value;
reload();
return;
}
if (ev.target.matches("[data-lab-ration-type]")) {
const rt = ev.target.value;
state = { ...state, rationType: rt };
profiles = [];
await ensureCatalogs(rt);
render();
return;
}
if (ev.target.matches("[data-lab-profile-select]")) {
const profile = profiles.find((p) => String(p.id) === String(ev.target.value));
if (profile?.rationType) {
state = { ...state, rationType: profile.rationType, animalProfileId: profile.id };
const typeSel = root.querySelector("[data-lab-ration-type]");
if (typeSel) typeSel.value = profile.rationType;
}
}
});
},
destroy() {
root = null;
state = null;
},
reload,
setRecipeId(id) {
recipeId = id;
reload();
},
};
}
global.WespLabMasterPanel = { createLabMasterPanel };
})(window);
+777
View File
@@ -0,0 +1,777 @@
(function (global) {
"use strict";
const GFE_DYNAMIC_KEYS = ["usp", "nel"];
let catalog = [];
let profiles = [];
let selectedId = null;
let draftNorms = {};
let dynamicNorms = {};
let normCoverage = null;
let normSourceMap = {};
let resolvedNormsPreview = {};
let seedCatalog = [];
let seedCatalogRation = null;
let normsCoverageTimer = null;
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function notify() {
return global.WespZootechNotify?.createAdapter?.();
}
function readMass() {
const raw = document.getElementById("labProfileMass")?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readMilk() {
const raw = document.getElementById("labProfileMilk")?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readNumInput(id) {
const raw = document.getElementById(id)?.value?.trim();
if (raw === "") return null;
const n = Number(raw);
return Number.isNaN(n) ? null : n;
}
function readNormsMethod() {
return document.getElementById("labProfileNormsMethod")?.value || "wesp";
}
function readNormsParams() {
const method = readNormsMethod();
if (method === "wesp") return {};
const params = {
milkFatPct: readNumInput("labProfileFat") ?? 4,
lactationNo: readNumInput("labProfileLactation") ?? 2,
};
if (method === "racion_piter") {
params.koncOeSv = readNumInput("labProfileKonc") ?? 10.3;
}
return params;
}
function syncNormsMethodUi() {
const method = readNormsMethod();
const racionBox = document.getElementById("labProfileRacionFields");
const piterOnly = document.querySelectorAll(".lab-profile-piter-only");
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
syncSourceFilterOptions(method);
renderNormsSummary();
scheduleNormsCoverageRefresh();
}
function syncSourceFilterOptions(method) {
const sel = document.getElementById("labProfilesSourceFilter");
if (!sel) return;
sel.querySelectorAll("option[data-racion-only]").forEach((opt) => {
opt.hidden = method === "wesp";
opt.disabled = method === "wesp";
});
if (method === "wesp" && ["racion", "racion_only", "derived", "new_racion"].includes(sel.value)) {
sel.value = "all";
}
}
function buildNormSourceMap(coverage, storedNorms) {
const map = {};
if (coverage) {
(coverage.racion || []).forEach((k) => {
map[k] = "racion";
});
(coverage.derived || []).forEach((k) => {
map[k] = "derived";
});
(coverage.fallback || []).forEach((k) => {
if (!map[k]) map[k] = "fallback";
});
(coverage.missing || []).forEach((k) => {
if (!map[k]) map[k] = "missing";
});
}
Object.keys(storedNorms || {}).forEach((k) => {
if (!map[k]) map[k] = "stored";
});
return map;
}
function normSourceLabel(src) {
const labels = {
racion: "RACION",
derived: "расч.",
fallback: "WESP",
stored: "БД",
missing: "—",
gfe: "GfE",
};
return labels[src] || "—";
}
function normSourceBadge(src) {
if (!src || src === "missing") return '<span class="norm-source-badge norm-source-badge--empty">—</span>';
const cls = `norm-source-badge norm-source-badge--${src}`;
return `<span class="${cls}" title="${escapeHtml(normSourceLabel(src))}">${escapeHtml(normSourceLabel(src))}</span>`;
}
function isInDb(key) {
const b = draftNorms[key] || {};
return b.min != null || b.max != null;
}
function matchesSourceFilter(key) {
const filter = document.getElementById("labProfilesSourceFilter")?.value || "all";
if (filter === "all") return true;
const src = normSourceMap[key];
if (filter === "in_db") return isInDb(key);
if (filter === "seed") return src === "fallback" || src === "stored";
if (filter === "racion") return src === "racion" || src === "derived";
if (filter === "racion_only") return src === "racion";
if (filter === "derived") return src === "derived";
if (filter === "new_racion") return src === "racion" || src === "derived";
return true;
}
function countNormSources() {
const counts = { inDb: 0, racion: 0, derived: 0, fallback: 0, stored: 0, missing: 0 };
const seen = new Set();
catalog.forEach((c) => seen.add(c.key));
Object.keys(draftNorms).forEach((k) => seen.add(k));
if (normCoverage) {
(normCoverage.racion || []).forEach((k) => seen.add(k));
(normCoverage.derived || []).forEach((k) => seen.add(k));
(normCoverage.fallback || []).forEach((k) => seen.add(k));
(normCoverage.missing || []).forEach((k) => seen.add(k));
}
seen.forEach((key) => {
if (isInDb(key)) counts.inDb += 1;
const src = normSourceMap[key];
if (src === "racion") counts.racion += 1;
else if (src === "derived") counts.derived += 1;
else if (src === "fallback") counts.fallback += 1;
else if (src === "stored") counts.stored += 1;
else if (src === "missing") counts.missing += 1;
});
return counts;
}
function renderNormsSummary() {
const panel = document.getElementById("labProfilesNormsSummary");
const text = document.getElementById("labProfilesNormsSummaryText");
const form = document.getElementById("labProfilesForm");
if (!panel || !text || !form || form.hidden) {
if (panel) panel.hidden = true;
return;
}
const method = readNormsMethod();
const counts = countNormSources();
if (method === "wesp") {
panel.hidden = false;
text.textContent = `В БД ${counts.inDb} показ. · справочник/ручные ${counts.stored + counts.fallback}`;
return;
}
if (!normCoverage) {
panel.hidden = false;
text.textContent = "Укажи массу и удой — разберём источники RACION";
return;
}
panel.hidden = false;
const racTotal = counts.racion + counts.derived;
const withMin = normCoverage.withMin != null ? normCoverage.withMin : racTotal + counts.fallback;
text.textContent =
`В БД ${counts.inDb} · RACION ${racTotal} (NPitV ${counts.racion} + расч. ${counts.derived}) · ` +
`WESP fallback ${counts.fallback} · с min ${withMin}/${normCoverage.total ?? "—"}`;
}
function applyNormsCoveragePayload(payload) {
normCoverage = payload.coverage || null;
resolvedNormsPreview = payload.resolvedIndicators || {};
if (payload.dynamicNorms) {
dynamicNorms = { ...dynamicNorms, ...payload.dynamicNorms };
}
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
renderNormsSummary();
renderNormsTable();
}
async function refreshNormsCoverage() {
const method = readNormsMethod();
const mass = readMass();
const milk = readMilk();
if (method === "wesp") {
normCoverage = null;
resolvedNormsPreview = {};
normSourceMap = buildNormSourceMap(null, draftNorms);
GFE_DYNAMIC_KEYS.forEach((key) => {
if (dynamicNorms[key]?.min != null && !isInDb(key)) normSourceMap[key] = "gfe";
});
renderNormsSummary();
renderNormsTable();
return;
}
if (mass == null || milk == null) {
normCoverage = null;
normSourceMap = buildNormSourceMap(null, draftNorms);
renderNormsSummary();
renderNormsTable();
return;
}
const q = new URLSearchParams({ method, mass_kg: String(mass), milk_yield_kg: String(milk) });
if (selectedId) q.set("profile_id", selectedId);
const np = readNormsParams();
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
try {
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
applyNormsCoveragePayload(preview);
} catch (err) {
notify()?.error?.(err.message);
}
}
function scheduleNormsCoverageRefresh() {
if (normsCoverageTimer) clearTimeout(normsCoverageTimer);
normsCoverageTimer = setTimeout(() => {
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
}, 350);
}
function fillNormsParamsFromProfile(p) {
const method = p.normsMethod || "wesp";
const sel = document.getElementById("labProfileNormsMethod");
if (sel) sel.value = method;
const np = p.normsParams || {};
const fat = document.getElementById("labProfileFat");
const lact = document.getElementById("labProfileLactation");
const konc = document.getElementById("labProfileKonc");
if (fat) fat.value = np.milkFatPct != null ? np.milkFatPct : 4;
if (lact) lact.value = np.lactationNo != null ? np.lactationNo : 2;
if (konc) konc.value = np.koncOeSv != null ? np.koncOeSv : 10.3;
syncNormsMethodUi();
}
/** GfE 2001 — parity app/lab/calc/gfe_norms.py */
function gfeUspMinG(massKg, milkKg) {
if (!massKg || massKg <= 0) return null;
return 0.09 * massKg ** 0.75 * 6.25 + Math.max(milkKg || 0, 0) * 85;
}
function gfeNelMinMj(massKg, milkKg) {
if (!massKg || massKg <= 0) return null;
return 0.293 * massKg ** 0.75 + Math.max(milkKg || 0, 0) * 3.3;
}
function computeDynamicNormsLocal() {
const mass = readMass();
const milk = readMilk();
const out = {};
const usp = gfeUspMinG(mass, milk);
if (usp != null) {
out.usp = { min: usp, formula: "0.09×масса^0.75×6.25 + удой×85" };
}
const nel = gfeNelMinMj(mass, milk);
if (nel != null) {
out.nel = { min: nel, formula: "0.293×масса^0.75 + удой×3.3" };
}
return out;
}
function formatGfe(v) {
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(2);
}
function refreshDynamicNorms() {
dynamicNorms = computeDynamicNormsLocal();
renderGfePanel();
renderNormsTable();
}
function renderGfePanel() {
const panel = document.getElementById("labProfilesGfePanel");
const vals = document.getElementById("labProfilesGfeValues");
const form = document.getElementById("labProfilesForm");
if (!panel || !form || form.hidden) {
if (panel) panel.hidden = true;
return;
}
const mass = readMass();
if (!mass || mass <= 0) {
panel.hidden = true;
return;
}
panel.hidden = false;
const parts = [];
if (dynamicNorms.usp?.min != null) {
parts.push(`min уСП ≈ ${formatGfe(dynamicNorms.usp.min)} г`);
}
if (dynamicNorms.nel?.min != null) {
parts.push(`min ЧЭЛ ≈ ${formatGfe(dynamicNorms.nel.min)} МДж`);
}
if (vals) vals.textContent = parts.join(" · ") || "—";
const enabled = parts.length > 0;
["labProfilesGfeApplyUsp", "labProfilesGfeApplyNel", "labProfilesGfeApplyAll"].forEach((id) => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function applyGfeMin(keys) {
collectNormsFromTable();
let applied = 0;
keys.forEach((key) => {
const v = dynamicNorms[key]?.min;
if (v == null) return;
const cur = draftNorms[key] || {};
if (cur.min != null) return;
draftNorms[key] = { ...cur, min: v };
applied += 1;
});
renderNormsTable();
if (applied) {
notify()?.success?.("Подставлено по GfE — нажми «Запомни это»");
} else {
notify()?.error?.("Нижние границы уже заданы или нет массы");
}
}
function syncDeleteButton() {
const btn = document.getElementById("labProfilesDelete");
if (btn) btn.hidden = !selectedId;
}
function readRationType() {
return document.getElementById("labProfileType")?.value || "DAIRY";
}
function syncSeedPanelVisibility() {
const panel = document.getElementById("labProfilesSeedPanel");
const form = document.getElementById("labProfilesForm");
if (panel) panel.hidden = !form || form.hidden;
}
function filteredSeedCatalog() {
const q = (document.getElementById("labProfilesSeedSearch")?.value || "").trim().toLowerCase();
if (!q) return seedCatalog;
return seedCatalog.filter((e) => {
const hay = `${e.externalNo} ${e.label} ${e.massKg ?? ""}`.toLowerCase();
return hay.includes(q);
});
}
function renderSeedSelect() {
const sel = document.getElementById("labProfilesSeedSelect");
const applyBtn = document.getElementById("labProfilesSeedApply");
if (!sel) return;
const prev = sel.value;
const rows = filteredSeedCatalog();
const opts = ['<option value="">— выбери строку —</option>'].concat(
rows.map((e) => {
const mass = e.massKg != null ? ` · ${e.massKg} кг` : "";
const cnt = e.indicatorCount != null ? ` · ${e.indicatorCount} показ.` : "";
return `<option value="${escapeHtml(String(e.externalNo))}">№${escapeHtml(String(e.externalNo))}${escapeHtml(e.label)}${escapeHtml(mass)}${escapeHtml(cnt)}</option>`;
})
);
sel.innerHTML = opts.join("");
if (prev && rows.some((e) => String(e.externalNo) === prev)) {
sel.value = prev;
}
if (applyBtn) applyBtn.disabled = !sel.value;
}
async function loadSeedCatalog(rationType) {
const ration = rationType || readRationType();
if (seedCatalogRation === ration && seedCatalog.length) {
renderSeedSelect();
return;
}
const data = await api(`/api/lab/seed-norms-catalog?ration_type=${encodeURIComponent(ration)}`);
seedCatalog = data.entries || [];
seedCatalogRation = ration;
renderSeedSelect();
}
async function applySeedNorms() {
const sel = document.getElementById("labProfilesSeedSelect");
const externalNo = sel?.value;
if (!externalNo) {
notify()?.error?.("Выбери строку справочника");
return;
}
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
if (filled > 0) {
const ok = global.confirm("Заменить текущие границы нормами из справочника?");
if (!ok) return;
}
const ration = readRationType();
const entry = await api(
`/api/lab/seed-norms-catalog/${encodeURIComponent(externalNo)}?ration_type=${encodeURIComponent(ration)}`
);
draftNorms = { ...(entry.indicators || {}) };
if (entry.massKg != null) {
document.getElementById("labProfileMass").value = entry.massKg;
}
document.getElementById("labProfileExternal").value = entry.externalNo;
const labelEl = document.getElementById("labProfileLabel");
if (labelEl && !labelEl.value.trim()) {
labelEl.value = entry.label || "";
}
refreshDynamicNorms();
notify()?.success?.(`Загружено ${Object.keys(draftNorms).length} показателей — нажми «Запомни это»`);
}
function filteredProfiles() {
const type = document.getElementById("labProfilesType")?.value || "";
const q = (document.getElementById("labProfilesSearch")?.value || "").trim().toLowerCase();
return profiles.filter((p) => {
if (type && p.rationType !== type) return false;
if (!q) return true;
const hay = `${p.profileKey} ${p.label}`.toLowerCase();
return hay.includes(q);
});
}
function renderList() {
const el = document.getElementById("labProfilesList");
if (!el) return;
const rows = filteredProfiles();
if (!rows.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Стандартов нет — создай новый</div>';
return;
}
el.innerHTML = rows
.map(
(p) =>
`<button type="button" class="${p.id === selectedId ? "is-active" : ""}" data-id="${escapeHtml(p.id)}">` +
`${escapeHtml(p.label)}` +
`<span class="profile-key">${escapeHtml(p.profileKey)} · ${escapeHtml(p.rationType)}</span>` +
`</button>`
)
.join("");
}
function indicatorRows() {
const onlyCalc = document.getElementById("labProfilesOnlyCalc")?.checked;
const onlyFilled = document.getElementById("labProfilesOnlyFilled")?.checked;
const q = (document.getElementById("labProfilesNormSearch")?.value || "").trim().toLowerCase();
const extraKeys = Object.keys(draftNorms).filter((k) => !catalog.some((c) => c.key === k));
const rows = [
...catalog.map((c) => ({ ...c })),
...extraKeys.map((k) => ({ key: k, label: k, unit: "", inCalc: false })),
];
return rows.filter((row) => {
if (onlyCalc && !row.inCalc) return false;
const b = draftNorms[row.key] || {};
const has = b.min != null || b.max != null;
if (onlyFilled && !has) return false;
if (!matchesSourceFilter(row.key)) return false;
if (q) {
const hay = `${row.key} ${row.label}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function renderNormsTable() {
const tbody = document.getElementById("labProfilesNormsBody");
const countEl = document.getElementById("labProfilesNormCount");
if (!tbody) return;
const rows = indicatorRows();
tbody.innerHTML = rows
.map((row) => {
const b = draftNorms[row.key] || {};
const min = b.min != null ? b.min : "";
const max = b.max != null ? b.max : "";
const badge = row.inCalc ? '<span class="norm-calc-badge ms-1">в деле</span>' : "";
const unit = row.unit ? ` <span class="text-muted">${escapeHtml(row.unit)}</span>` : "";
const dyn = dynamicNorms[row.key];
const gfeHint =
dyn?.min != null && min === ""
? `<small class="text-muted d-block">GfE ≈ ${formatGfe(dyn.min)}${dyn.formula ? ` (${escapeHtml(dyn.formula)})` : ""}</small>`
: "";
const gfeBadge = GFE_DYNAMIC_KEYS.includes(row.key)
? '<span class="norm-calc-badge ms-1" title="min по GfE 2001, если нижняя пуста">GfE</span>'
: "";
let src = normSourceMap[row.key];
if (!src && isInDb(row.key)) src = "stored";
const dbBadge = isInDb(row.key) ? '<span class="norm-source-badge norm-source-badge--db" title="Сохранено в БД">БД</span>' : "";
const srcBadge = normSourceBadge(src);
const resolved = resolvedNormsPreview[row.key];
const resolvedHint =
resolved?.min != null && min !== "" && Number(min) !== Number(resolved.min)
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)}</small>`
: resolved?.min != null && min === ""
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)} (не в БД)</small>`
: "";
return (
`<tr data-key="${escapeHtml(row.key)}">` +
`<td>${escapeHtml(row.label)}${badge}${gfeBadge}${unit}${gfeHint}${resolvedHint}</td>` +
`<td class="lab-profiles-source-cell">${dbBadge}${srcBadge}</td>` +
`<td><input type="number" class="form-control form-control-sm" data-bound="min" step="any" value="${min}"` +
`${dyn?.min != null && min === "" ? ` placeholder="${formatGfe(dyn.min)}"` : ""}></td>` +
`<td><input type="number" class="form-control form-control-sm" data-bound="max" step="any" value="${max}"></td>` +
`</tr>`
);
})
.join("");
if (countEl) {
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
const counts = countNormSources();
const method = readNormsMethod();
const srcPart =
method === "wesp"
? ` · в БД ${counts.inDb}`
: ` · БД ${counts.inDb} · RACION ${counts.racion + counts.derived} · WESP ${counts.fallback}`;
countEl.textContent = `В таблице ${rows.length} · с границами ${filled}${srcPart}`;
}
renderNormsSummary();
}
function collectNormsFromTable() {
const out = { ...draftNorms };
document.querySelectorAll("#labProfilesNormsBody tr[data-key]").forEach((tr) => {
const key = tr.getAttribute("data-key");
if (!key) return;
const minIn = tr.querySelector('[data-bound="min"]');
const maxIn = tr.querySelector('[data-bound="max"]');
const minRaw = minIn?.value?.trim();
const maxRaw = maxIn?.value?.trim();
const min = minRaw === "" ? null : Number(minRaw);
const max = maxRaw === "" ? null : Number(maxRaw);
if (min == null && max == null) {
delete out[key];
} else {
out[key] = {
min: Number.isNaN(min) ? null : min,
max: Number.isNaN(max) ? null : max,
};
}
});
draftNorms = out;
}
async function selectProfile(id) {
selectedId = id;
syncDeleteButton();
renderList();
const empty = document.getElementById("labProfilesEmpty");
const form = document.getElementById("labProfilesForm");
if (!id) {
if (empty) empty.hidden = false;
if (form) form.hidden = true;
syncSeedPanelVisibility();
return;
}
const p = await api(`/api/lab/animal-profiles/${encodeURIComponent(id)}`);
if (empty) empty.hidden = true;
if (form) form.hidden = false;
document.getElementById("labProfileKey").value = p.profileKey || "";
document.getElementById("labProfileLabel").value = p.label || "";
document.getElementById("labProfileType").value = p.rationType || "BEEF";
document.getElementById("labProfileMass").value = p.massKg != null ? p.massKg : "";
document.getElementById("labProfileMilk").value = p.milkYieldKg != null ? p.milkYieldKg : "";
document.getElementById("labProfileExternal").value = p.externalNo != null ? p.externalNo : "";
fillNormsParamsFromProfile(p);
draftNorms = { ...(p.norms || p.normsData?.indicators || {}) };
normCoverage = p.coverage || p.normsData?.coverage || null;
resolvedNormsPreview = p.resolvedNorms || p.normsData?.resolvedIndicators || {};
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
refreshDynamicNorms();
syncSourceFilterOptions(readNormsMethod());
renderNormsSummary();
scheduleNormsCoverageRefresh();
syncSeedPanelVisibility();
loadSeedCatalog(p.rationType || "DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
}
function newProfile() {
selectedId = null;
syncDeleteButton();
renderList();
document.getElementById("labProfilesEmpty").hidden = true;
document.getElementById("labProfilesForm").hidden = false;
document.getElementById("labProfileKey").value = "";
document.getElementById("labProfileLabel").value = "";
document.getElementById("labProfileType").value = "DAIRY";
document.getElementById("labProfileMass").value = "";
document.getElementById("labProfileMilk").value = "";
document.getElementById("labProfileExternal").value = "";
fillNormsParamsFromProfile({ normsMethod: "wesp", normsParams: {} });
draftNorms = {};
normCoverage = null;
resolvedNormsPreview = {};
normSourceMap = {};
refreshDynamicNorms();
syncSourceFilterOptions("wesp");
renderNormsSummary();
syncSeedPanelVisibility();
loadSeedCatalog("DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
}
async function saveProfile() {
collectNormsFromTable();
const key = document.getElementById("labProfileKey")?.value?.trim();
const label = document.getElementById("labProfileLabel")?.value?.trim();
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
const massRaw = document.getElementById("labProfileMass")?.value?.trim();
const milkRaw = document.getElementById("labProfileMilk")?.value?.trim();
const extRaw = document.getElementById("labProfileExternal")?.value?.trim();
if (!key || !label) {
notify()?.error?.("Укажи код и название — я предупреждал");
return;
}
const payload = {
profileKey: key,
label,
rationType,
indicators: draftNorms,
normsMethod: readNormsMethod(),
normsParams: readNormsParams(),
};
if (massRaw !== "") payload.massKg = Number(massRaw);
payload.milkYieldKg = milkRaw === "" ? null : Number(milkRaw);
if (extRaw !== "") payload.externalNo = Number(extRaw);
const method = selectedId ? "PUT" : "POST";
const url = selectedId
? `/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`
: "/api/lab/animal-profiles";
await api(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
notify()?.success?.("Стандарт сохранён. Ты чертовски прав");
const typeFilter = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
);
profiles = data.profiles || [];
if (!selectedId && profiles.length) {
const hit = profiles.find((p) => p.profileKey === key);
if (hit) selectedId = hit.id;
}
renderList();
if (selectedId) await selectProfile(selectedId);
}
async function deleteProfile() {
if (!selectedId) return;
const label = document.getElementById("labProfileLabel")?.value?.trim() || selectedId;
if (!global.confirm(`Удалить «${label}»? Никаких полумер.`)) return;
await api(`/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`, { method: "DELETE" });
notify()?.success?.("Стандарт снесён. Say my name.");
selectedId = null;
syncDeleteButton();
const typeFilter = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
);
profiles = data.profiles || [];
document.getElementById("labProfilesEmpty").hidden = false;
document.getElementById("labProfilesForm").hidden = true;
renderList();
}
async function init() {
const [cat, prof] = await Promise.all([
api("/api/lab/norm-indicators"),
api("/api/lab/animal-profiles"),
]);
catalog = cat.indicators || [];
profiles = prof.profiles || [];
renderList();
}
function bind() {
document.getElementById("labProfilesList")?.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-id]");
if (!btn) return;
selectProfile(btn.getAttribute("data-id")).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesType")?.addEventListener("change", async () => {
const type = document.getElementById("labProfilesType")?.value || "";
const data = await api(
`/api/lab/animal-profiles${type ? `?ration_type=${encodeURIComponent(type)}` : ""}`
);
profiles = data.profiles || [];
renderList();
});
document.getElementById("labProfilesSearch")?.addEventListener("input", renderList);
document.getElementById("labProfilesOnlyFilled")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesOnlyCalc")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesSourceFilter")?.addEventListener("change", renderNormsTable);
document.getElementById("labProfilesNormSearch")?.addEventListener("input", renderNormsTable);
document.getElementById("labProfilesNormsRefresh")?.addEventListener("click", () => {
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesNew")?.addEventListener("click", newProfile);
document.getElementById("labProfilesSave")?.addEventListener("click", () => {
saveProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesDelete")?.addEventListener("click", () => {
deleteProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesNormsBody")?.addEventListener("change", (ev) => {
if (ev.target.matches("input[data-bound]")) collectNormsFromTable();
});
document.getElementById("labProfileMass")?.addEventListener("input", () => {
refreshDynamicNorms();
scheduleNormsCoverageRefresh();
});
document.getElementById("labProfileMilk")?.addEventListener("input", () => {
refreshDynamicNorms();
scheduleNormsCoverageRefresh();
});
document.getElementById("labProfileNormsMethod")?.addEventListener("change", syncNormsMethodUi);
["labProfileFat", "labProfileLactation", "labProfileKonc"].forEach((id) => {
document.getElementById(id)?.addEventListener("input", scheduleNormsCoverageRefresh);
});
document.getElementById("labProfileType")?.addEventListener("change", () => {
seedCatalogRation = null;
loadSeedCatalog(readRationType()).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesSeedSearch")?.addEventListener("input", renderSeedSelect);
document.getElementById("labProfilesSeedSelect")?.addEventListener("change", () => {
const applyBtn = document.getElementById("labProfilesSeedApply");
const sel = document.getElementById("labProfilesSeedSelect");
if (applyBtn && sel) applyBtn.disabled = !sel.value;
});
document.getElementById("labProfilesSeedApply")?.addEventListener("click", () => {
applySeedNorms().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
document.getElementById("labProfilesGfeApplyUsp")?.addEventListener("click", () => applyGfeMin(["usp"]));
document.getElementById("labProfilesGfeApplyNel")?.addEventListener("click", () => applyGfeMin(["nel"]));
document.getElementById("labProfilesGfeApplyAll")?.addEventListener("click", () =>
applyGfeMin(GFE_DYNAMIC_KEYS)
);
}
document.addEventListener("DOMContentLoaded", () => {
bind();
init().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
});
})(window);
@@ -0,0 +1,299 @@
(function (global) {
"use strict";
let diffByComponent = new Map();
let hooked = false;
function getActiveRecipeId() {
const fromEdit = document.getElementById("recipeEdit")?.dataset?.recipeId;
if (fromEdit) return fromEdit;
return document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || null;
}
function ensureToolbarButtons() {
const toolbar = document.querySelector("#recipeEdit .recipe-section-toolbar");
if (!toolbar) return;
if (!toolbar.querySelector('[data-action="sync-to-master"]')) {
const syncBtn = document.createElement("button");
syncBtn.type = "button";
syncBtn.className = "btn btn-outline-secondary";
syncBtn.setAttribute("data-action", "sync-to-master");
syncBtn.innerHTML = '<i class="fas fa-arrow-up me-1"></i>В мастер';
toolbar.insertBefore(syncBtn, toolbar.firstChild);
}
if (!toolbar.querySelector('[data-action="apply-from-master"]')) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-outline-primary";
btn.setAttribute("data-action", "apply-from-master");
btn.hidden = true;
btn.innerHTML = '<i class="fas fa-arrow-down me-1"></i>Из мастера';
const addBtn = toolbar.querySelector('[data-action="add-ingredient"]');
if (addBtn) toolbar.insertBefore(btn, addBtn);
else toolbar.appendChild(btn);
}
}
function ensureKHubButton() {
const actions = document.querySelector("#recipeEdit .recipe-edit-header-actions .d-flex");
if (!actions || actions.querySelector('[data-action="open-lab-k-hub"]')) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-outline-secondary recipe-edit-header-icon-btn";
btn.setAttribute("data-action", "open-lab-k-hub");
btn.title = "Открыть зоотех-мастер в K-hub";
btn.setAttribute("aria-label", "K-hub");
btn.innerHTML = '<i class="fas fa-flask" aria-hidden="true"></i>';
actions.insertBefore(btn, actions.firstChild);
}
function ensureRationTypeField() {
const grid = document.querySelector("#recipeEdit .recipe-form-grid");
if (!grid || document.getElementById("rationType")) return;
const wrap = document.createElement("div");
wrap.className = "form-group";
wrap.innerHTML =
'<label for="rationType">Тип стада</label>' +
'<select class="form-control" id="rationType">' +
'<option value="">—</option><option value="DAIRY">Дойное</option><option value="BEEF">Мясное</option>' +
"</select>";
const tripWrap = document.getElementById("tripPercent")?.closest(".form-group");
if (tripWrap?.parentElement === grid) {
tripWrap.insertAdjacentElement("afterend", wrap);
} else {
grid.appendChild(wrap);
}
}
function ensureDiffStyles() {
if (document.getElementById("wesp-lab-diff-styles")) return;
const style = document.createElement("style");
style.id = "wesp-lab-diff-styles";
style.textContent =
".lab-diff-badge{margin-left:6px;font-size:.7rem;vertical-align:middle;}" +
".lab-diff-badge--warn{background:#fff3cd;color:#664d03;}" +
".lab-diff-badge--miss{background:#f8d7da;color:#842029;}" +
".lab-recipe-quality{margin-top:4px;font-size:.78rem;color:var(--bs-secondary-color,#6c757d);}" +
".lab-recipe-quality__label{font-weight:600;margin-right:4px;}";
document.head.appendChild(style);
}
function diffBadgeHtml(reasons) {
if (!reasons?.length) return "";
const title = reasons.join(", ");
const cls = reasons.includes("missing_in_master") || reasons.includes("missing_in_execution")
? "lab-diff-badge--miss"
: "lab-diff-badge--warn";
return `<span class="badge lab-diff-badge ${cls}" title="${title}">мастер</span>`;
}
function applyDiffBadges() {
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach((row) => {
const select = row.querySelector(".ingredient-select");
const cell = row.querySelector('.ingredient-detail-cell[data-label="Ингредиент"] .recipe-row-name-with-skip');
if (!select || !cell) return;
cell.querySelectorAll(".lab-diff-badge").forEach((el) => el.remove());
const compId = select.value;
if (!compId) return;
const reasons = diffByComponent.get(String(compId));
if (!reasons?.length) return;
cell.insertAdjacentHTML("beforeend", diffBadgeHtml(reasons));
});
}
function ensureQualityBlock() {
const headerText = document.querySelector("#recipeEdit .recipe-edit-header-text");
if (!headerText || document.getElementById("labRecipeQuality")) return;
const block = document.createElement("div");
block.id = "labRecipeQuality";
block.className = "lab-recipe-quality";
block.hidden = true;
block.innerHTML =
'<span class="lab-recipe-quality__label">Зоотех:</span>' +
'<span class="lab-recipe-quality__items" data-lab-quality-items></span>';
headerText.appendChild(block);
}
async function refreshQuality(recipeId) {
ensureQualityBlock();
const block = document.getElementById("labRecipeQuality");
const items = block?.querySelector("[data-lab-quality-items]");
if (!block || !items || !recipeId) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
if (!resp.ok) throw new Error("no ration");
const data = await resp.json();
const indicators = (data.rationResults?.indicators || []).slice(0, 4);
if (!data.exists || !indicators.length) {
block.hidden = true;
return;
}
items.textContent = indicators
.map((ind) => `${ind.label}: ${ind.content ?? "—"} ${ind.unit || ""}`.trim())
.join(" · ");
block.hidden = false;
} catch (_) {
block.hidden = true;
}
}
async function refreshDiff(recipeId) {
const btn = document.querySelector('[data-action="apply-from-master"]');
if (!recipeId) return;
await refreshQuality(recipeId);
if (!btn) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/diff`);
const data = await resp.json();
diffByComponent = new Map();
(data.lines || []).forEach((line) => {
if (line.componentId) diffByComponent.set(String(line.componentId), line.reasons || []);
});
btn.hidden = !data.hasChanges;
applyDiffBadges();
} catch (_) {
btn.hidden = true;
diffByComponent = new Map();
}
}
function patchRecipeLoad() {
if (hooked) return;
const orig = global.loadRecipeDetails;
if (typeof orig !== "function") return;
global.loadRecipeDetails = async function (recipeId, options) {
const recipeEdit = document.getElementById("recipeEdit");
if (recipeEdit && recipeId) recipeEdit.dataset.recipeId = String(recipeId);
await orig(recipeId, options);
await refreshDiff(recipeId);
global.WespLabRecipesPlanOverlay?.decorateRows?.();
};
hooked = true;
}
function patchRecipeSave() {
const orig = global.doSaveRecipe;
if (typeof orig !== "function" || orig.__labPatched) return;
global.doSaveRecipe = async function (closeAfterSave) {
const rationEl = document.getElementById("rationType");
const prev = global.__wespLabRationTypeForSave;
if (rationEl) {
global.__wespLabRationTypeForSave = rationEl.value || null;
}
try {
return await orig(closeAfterSave);
} finally {
global.__wespLabRationTypeForSave = prev;
}
};
const origFetch = global.fetch.bind(global);
global.fetch = async function (input, init) {
const url = typeof input === "string" ? input : input?.url;
const method = (init?.method || "GET").toUpperCase();
if (
method === "PUT" &&
url &&
/\/api\/recipes\/[^/]+$/.test(url) &&
init?.body &&
global.__wespLabRationTypeForSave !== undefined
) {
try {
const body = JSON.parse(init.body);
body.ration_type = global.__wespLabRationTypeForSave || null;
init = { ...init, body: JSON.stringify(body) };
} catch (_) {
/* ignore */
}
}
return origFetch(input, init);
};
global.doSaveRecipe.__labPatched = true;
}
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn) return;
const action = btn.getAttribute("data-action");
const recipeId = getActiveRecipeId();
if (action === "open-lab-k-hub") {
global.WespZootechNotificationCenter?.openDailyPlan?.(recipeId || undefined);
return;
}
if (action === "sync-to-master") {
if (!recipeId) return;
const ok = await global.WespDialog?.confirm?.(
"Сохранить текущий рецепт в зоотех-мастер?",
{ title: "В мастер" }
);
if (!ok) return;
try {
const resp = await fetch(
`/api/lab/rations/${encodeURIComponent(recipeId)}/sync-from-execution`,
{ method: "POST" }
);
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка");
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер обновлён из рецепта");
await refreshDiff(recipeId);
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
}
return;
}
if (action !== "apply-from-master") return;
if (!recipeId) return;
const ok = await global.WespDialog?.confirm?.("Перенести ингредиенты из зоотех-мастера?", {
title: "Из мастера",
});
if (!ok) return;
try {
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, {
method: "POST",
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "Ошибка");
if (typeof global.reloadRecipeEditor === "function") {
await global.reloadRecipeEditor(recipeId);
} else if (typeof global.loadRecipeDetails === "function") {
await global.loadRecipeDetails(recipeId);
}
await refreshDiff(recipeId);
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер применён к рецепту");
} catch (e) {
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось применить мастер" });
}
});
document.addEventListener("change", (ev) => {
if (!ev.target?.matches?.(".ingredient-select")) return;
applyDiffBadges();
});
global.WespLabRecipesOverlay = {
init() {
ensureDiffStyles();
ensureToolbarButtons();
ensureKHubButton();
ensureRationTypeField();
patchRecipeLoad();
patchRecipeSave();
},
refreshDiff,
refreshQuality,
applyDiffBadges,
};
function boot() {
global.WespLabRecipesOverlay.init();
if (typeof global.loadRecipeDetails === "function") patchRecipeLoad();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(window);
@@ -0,0 +1,252 @@
(function (global) {
"use strict";
let observer = null;
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function planDate() {
return (
global.WespDailyPlanPanel?.getSelectedPlanDate?.() ||
global.WespDailyPlanSkipDuration?.todayIso?.() ||
new Date().toISOString().slice(0, 10)
);
}
function getRecipeId() {
return (
document.getElementById("recipeEdit")?.dataset?.recipeId ||
document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId ||
null
);
}
function notifySuccess(message) {
global.WespZootechNotify?.createAdapter?.()?.success?.(message);
}
function notifyError(message, fallback) {
global.WespZootechNotify?.createAdapter?.()?.error?.(message, { fallback: fallback || message });
}
async function reloadEditor() {
const recipeId = getRecipeId();
if (!recipeId) return;
if (typeof global.reloadRecipeEditor === "function") {
await global.reloadRecipeEditor(recipeId);
} else if (typeof global.loadRecipeDetailsWithPlanOverlay === "function") {
await global.loadRecipeDetailsWithPlanOverlay();
} else if (typeof global.loadRecipeDetails === "function") {
await global.loadRecipeDetails(recipeId);
}
}
function planBtnHtml(action, title, icon, attrs = "") {
return (
`<button type="button" class="btn btn-outline-secondary btn-sm recipe-plan-action-btn" ` +
`data-action="${action}" title="${escapeHtml(title)}" ${attrs}>` +
`<i class="fas ${icon}"></i></button>`
);
}
function decorateRow(row) {
const actions = row.querySelector(".recipe-table-row-actions");
if (!actions || actions.querySelector(".recipe-plan-actions")) return;
const ingredientId = row.dataset.id;
if (!ingredientId) return;
const skipped = row.classList.contains("ingredient-row--skipped");
const replaced = row.classList.contains("ingredient-row--replaced");
const select = row.querySelector(".ingredient-select");
const componentId = select?.value || "";
const label = select?.selectedOptions?.[0]?.textContent?.trim() || "Компонент";
const recipeId = getRecipeId();
if (!recipeId) return;
const wrap = document.createElement("div");
wrap.className = "btn-group recipe-plan-actions me-1";
wrap.setAttribute("role", "group");
if (skipped) {
wrap.innerHTML = planBtnHtml(
"recipe-plan-unskip-ingredient",
"Вернуть в план",
"fa-undo",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
);
} else if (replaced) {
wrap.innerHTML = planBtnHtml(
"recipe-plan-undo-replace-ingredient",
"Отменить замену",
"fa-undo",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
);
} else {
wrap.innerHTML =
planBtnHtml(
"recipe-plan-skip-ingredient",
"Убрать из плана",
"fa-ban",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" data-label="${escapeHtml(label)}"`
) +
(componentId
? planBtnHtml(
"recipe-plan-replace-ingredient",
"Заменить в плане",
"fa-exchange-alt",
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" ` +
`data-component-id="${escapeHtml(componentId)}" data-label="${escapeHtml(label)}"`
)
: "");
}
const deleteBtn = actions.querySelector('[data-action="remove-ingredient"]');
if (deleteBtn) actions.insertBefore(wrap, deleteBtn);
else actions.appendChild(wrap);
}
function decorateRows() {
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach(decorateRow);
}
function ensureStyles() {
if (document.getElementById("wesp-lab-plan-overlay-styles")) return;
const style = document.createElement("style");
style.id = "wesp-lab-plan-overlay-styles";
style.textContent =
".recipe-plan-action-btn{padding:2px 6px;}" +
".recipe-plan-actions .btn+.btn{margin-left:2px;}";
document.head.appendChild(style);
}
async function skipIngredient(recipeId, ingredientId, label) {
const skip = global.WespDailyPlanSkipDuration;
if (!skip?.skipDurationBody) return;
const body = await skip.skipDurationBody(planDate(), { recipeId, ingredientId }, "На какой срок убрать компонент?");
if (!body) return;
const resp = await fetch("/api/daily-plan/skips/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось убрать компонент");
}
await reloadEditor();
notifySuccess(`«${label}» убран из плана`);
}
async function unskipIngredient(recipeId, ingredientId) {
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось вернуть компонент");
}
await reloadEditor();
notifySuccess("Компонент снова в плане");
}
async function openReplace(recipeId, ingredientId, componentId, label) {
const replaceModal = global.WespDailyPlanReplaceModal;
if (!replaceModal?.open) {
notifyError("Модалка замены недоступна");
return;
}
await replaceModal.open({
currentName: label,
onPick: async (replacementComponentId, replacementName) => {
const skip = global.WespDailyPlanSkipDuration;
const body = await skip?.skipDurationBody?.(
planDate(),
{ recipeId, ingredientId, replacementComponentId },
"На какой срок заменить компонент?"
);
if (!body) return;
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось заменить компонент");
}
await reloadEditor();
notifySuccess(`Компонент заменён на «${replacementName}»`);
},
});
}
async function undoReplace(recipeId, ingredientId) {
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось отменить замену");
}
await reloadEditor();
notifySuccess("Замена отменена");
}
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest("[data-action]");
if (!btn) return;
const action = btn.dataset.action;
if (!action?.startsWith("recipe-plan-")) return;
if (!document.getElementById("recipeEdit")?.contains(btn)) return;
ev.preventDefault();
const recipeId = btn.dataset.recipeId;
const ingredientId = btn.dataset.ingredientId;
try {
if (action === "recipe-plan-skip-ingredient") {
await skipIngredient(recipeId, ingredientId, btn.dataset.label || "Компонент");
} else if (action === "recipe-plan-unskip-ingredient") {
await unskipIngredient(recipeId, ingredientId);
} else if (action === "recipe-plan-replace-ingredient") {
await openReplace(recipeId, ingredientId, btn.dataset.componentId, btn.dataset.label || "Компонент");
} else if (action === "recipe-plan-undo-replace-ingredient") {
await undoReplace(recipeId, ingredientId);
}
} catch (e) {
notifyError(e.message, "Ошибка плана");
}
});
function watchIngredients() {
const tbody = document.getElementById("ingredientsTableBody");
if (!tbody || observer) return;
observer = new MutationObserver(() => decorateRows());
observer.observe(tbody, { childList: true, subtree: true });
}
global.WespLabRecipesPlanOverlay = {
init() {
ensureStyles();
watchIngredients();
decorateRows();
},
decorateRows,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => global.WespLabRecipesPlanOverlay.init());
} else {
global.WespLabRecipesPlanOverlay.init();
}
})(window);
+525
View File
@@ -0,0 +1,525 @@
(function (global) {
"use strict";
const INDICATOR_LABELS = {
dry_matter: "Сухое вещество",
dm_main: "СВ — основной корм",
oe: "ОЭ — КРС",
nel: "ЧЭЛ — КРС",
nel_per_kg_dm: "ЧЭЛ/кг СВ",
crude_protein: "Сырой протеин",
rup_per_kg_dm: "Нер. СП / кг СВ",
insoluble_protein: "Нерастворимый протеин",
usp: "уСП",
usp_pct_dm: "% уСП/кг СВ",
ndf: "Сырая клетчатка",
structural_fiber: "Структур. клетчатка",
crude_fat: "Сырой жир",
rnb: "RNB",
calcium: "Ca",
phosphorus: "P",
magnesium: "Mg",
sodium: "Na",
dcab: "DCAB",
sugar_starch: "Сахар и крахмал",
insoluble_starch: "Нераств. крахмал",
sugar: "Сахар",
starch: "Крахмал",
carotene: "Каротин",
};
let state = null;
let recipeId = null;
let components = [];
let profiles = [];
let normCatalog = [];
async function loadNormCatalog() {
if (normCatalog.length) return normCatalog;
try {
const data = await api("/api/lab/norm-indicators");
normCatalog = data.indicators || [];
} catch (_e) {
normCatalog = [];
}
return normCatalog;
}
function indicatorMeta(key) {
const fromCat = normCatalog.find((item) => item.key === key);
if (fromCat) return fromCat;
return { key, label: INDICATOR_LABELS[key] || key, unit: "" };
}
function mergeIndicatorsForDisplay(calcRows, norms) {
const byKey = new Map();
for (const ind of calcRows || []) {
if (ind?.key) byKey.set(ind.key, { ...ind });
}
for (const key of Object.keys(norms || {})) {
const bounds = norms[key] || {};
if (bounds.min == null && bounds.max == null) continue;
const meta = indicatorMeta(key);
const existing = byKey.get(key);
if (existing) {
existing.min = bounds.min ?? existing.min;
existing.max = bounds.max ?? existing.max;
existing.label = existing.label || meta.label;
existing.unit = existing.unit || meta.unit || "";
} else {
byKey.set(key, {
key,
label: meta.label,
unit: meta.unit || "",
content: null,
min: bounds.min,
max: bounds.max,
diff: null,
});
}
}
return [...byKey.values()];
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
return data;
}
function labError(message) {
global.WespLabHeisenbergPrime?.error?.(message, "sandbox");
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
}
function formatKgInput(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
return String(Math.round(n * 100) / 100);
}
function diffClass(diff) {
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
const d = Number(diff);
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
return "lab-sandbox-diff--bad";
}
async function loadCatalogs(rationType) {
const [compResp, profResp] = await Promise.all([
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
]);
if (Array.isArray(compResp)) components = compResp;
profiles = profResp.profiles || [];
}
async function loadRecipes() {
try {
const data = await api("/api/lab/recipes");
return data.recipes || [];
} catch (_e) {
return [];
}
}
function componentOptions(selectedId) {
const opts = ['<option value="">— реагент —</option>'];
components.forEach((c) => {
const sel = String(c.id) === String(selectedId) ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
});
return opts.join("");
}
function profileOptions(selectedId) {
const opts = ['<option value="">— стандарт чистоты —</option>'];
profiles.forEach((p) => {
const sel = String(p.id) === String(selectedId) ? " selected" : "";
const key = p.profileKey ? `[${p.profileKey}] ` : "";
opts.push(
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
);
});
return opts.join("");
}
function hasNormBounds(ind) {
return ind != null && (ind.min != null || ind.max != null);
}
function formatNormBounds(bounds) {
const b = bounds || {};
const min = b.min != null ? formatNum(b.min) : null;
const max = b.max != null ? formatNum(b.max) : null;
if (min != null && max != null) return `${min}${max}`;
if (max != null) return `${max}`;
if (min != null) return `${min}`;
return "—";
}
function formatIndicatorNorm(ind) {
if (!hasNormBounds(ind)) {
return '<span class="lab-sandbox-norm--empty" title="Для этого стандарта нет границы">вне стандарта</span>';
}
return formatNormBounds(ind);
}
function formatIndicatorDiff(ind) {
if (!hasNormBounds(ind)) return "—";
return formatNum(ind.diff);
}
function renderNormsPanel() {
const el = document.getElementById("labSandboxNorms");
if (!el) return;
const norms = state?.norms || {};
const keys = Object.keys(norms);
if (!keys.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Выбери стандарт чистоты</div>';
return;
}
let note = "";
el.innerHTML =
note +
`<dl class="lab-sandbox-norms">` +
keys
.map((key) => {
const b = norms[key] || {};
const label = INDICATOR_LABELS[key] || key;
return `<dt>${escapeHtml(label)}</dt><dd>${formatNormBounds(b)}</dd>`;
})
.join("") +
`</dl>`;
}
function renderResultsPanel() {
const el = document.getElementById("labSandboxResults");
if (!el) return;
const calcRows = state?.rationResults?.indicators || [];
const totals = state?.rationResults?.totals || [];
const norms = state?.norms || {};
const indicators = mergeIndicatorsForDisplay(calcRows, norms);
if (!indicators.length && !totals.length) {
el.innerHTML = '<div class="lab-sandbox-empty">Нажми «Примени науку»</div>';
return;
}
let html = "";
if (!Object.keys(norms).length && calcRows.length) {
html +=
'<p class="small text-warning mb-2">Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».</p>';
}
if (totals.length) {
html +=
`<p class="small text-muted mb-1">Синий — значит чистый</p><ul class="small mb-2">` +
totals.map((t) => `<li>${escapeHtml(t.label)}: ${formatNum(t.value)}</li>`).join("") +
`</ul>`;
}
if (indicators.length) {
const withNorm = indicators.filter((ind) => hasNormBounds(ind)).length;
const rows = indicators.filter((ind) => hasNormBounds(ind) || ind.content != null);
const sorted = rows.slice().sort((a, b) => {
const an = hasNormBounds(a) ? 0 : 1;
const bn = hasNormBounds(b) ? 0 : 1;
if (an !== bn) return an - bn;
return String(a.label).localeCompare(String(b.label), "ru");
});
html +=
`<p class="small text-muted mb-1">Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.</p>` +
`<table><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
sorted
.map((ind) => {
const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na";
return (
`<tr>` +
`<td>${escapeHtml(ind.label)}</td>` +
`<td>${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}</td>` +
`<td>${formatIndicatorNorm(ind)}</td>` +
`<td class="${dc}">${formatIndicatorDiff(ind)}</td>` +
`</tr>`
);
})
.join("") +
`</tbody></table>`;
}
if (state?.calculatedAt) {
html += `<p class="small text-muted mt-2 mb-0">Готово: ${escapeHtml(state.calculatedAt)}</p>`;
}
el.innerHTML = html;
}
function renderLines() {
const tbody = document.getElementById("labSandboxLinesBody");
if (!tbody) return;
const lines = state?.lines || [];
if (!lines.length) {
tbody.innerHTML =
`<tr><td colspan="6" class="lab-sandbox-empty">Партия пуста — добавь реагент или возьми из WESP</td></tr>`;
return;
}
tbody.innerHTML = lines
.map(
(line, i) =>
`<tr data-line-idx="${i}">` +
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptions(line.componentId)}</select></td>` +
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${formatKgInput(line.dailyKg)}"></td>` +
`<td class="text-center"><input type="checkbox" data-field="inRation" ${line.inRation ? "checked" : ""}></td>` +
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${line.inCompound ? "checked" : ""}></td>` +
`<td class="small text-muted">${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}</td>` +
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="remove-line" data-idx="${i}">&times;</button></td>` +
`</tr>`
)
.join("");
}
function renderSetup() {
const recipeSel = document.getElementById("labSandboxRecipe");
const profileSel = document.getElementById("labSandboxProfile");
const typeSel = document.getElementById("labSandboxRationType");
if (profileSel) profileSel.innerHTML = profileOptions(state?.animalProfileId);
if (typeSel && state?.rationType) typeSel.value = state.rationType;
if (profileSel && state?.animalProfileId) profileSel.value = state.animalProfileId;
if (recipeSel && recipeId) recipeSel.value = recipeId;
const meta = document.getElementById("labSandboxMeta");
if (meta && state) {
meta.textContent = `${state.recipeName || ""} · ${state.headsPerTrip || 1} гол. · кг/сут на стадо · ${state.exists ? "формула есть" : "формула пуста — готовим?"}`;
}
}
function collectLines() {
const tbody = document.getElementById("labSandboxLinesBody");
if (!tbody || !state) return state.lines || [];
return (state.lines || []).map((line, i) => {
const row = tbody.querySelector(`tr[data-line-idx="${i}"]`);
if (!row) return line;
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
const comp = components.find((c) => String(c.id) === String(componentId));
return {
...line,
id: line.id,
componentId,
ingredientName: comp?.name || line.ingredientName,
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
inRation: row.querySelector('[data-field="inRation"]')?.checked,
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
};
});
}
function collectPayload() {
return {
lines: collectLines(),
rationType: document.getElementById("labSandboxRationType")?.value || state?.rationType,
animalProfileId: document.getElementById("labSandboxProfile")?.value || null,
};
}
async function reloadRation() {
if (!recipeId) return;
await loadNormCatalog();
state = await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
if (state) {
state.normsProfileKey = state.normsProfileKey || null;
state.legacyNormsRemapped = Boolean(state.legacyNormsRemapped);
}
await loadCatalogs(state.rationType);
if (state.legacyNormsRemapped && state.normsProfileKey) {
const fp = profiles.find((p) => p.profileKey === state.normsProfileKey);
if (fp) state.animalProfileId = fp.id;
}
renderSetup();
renderLines();
renderNormsPanel();
renderResultsPanel();
}
async function ensureMaster() {
if (!recipeId) return;
if (state?.exists) return;
const ok = await global.WespDialog?.confirm?.("Взять состав из WESP и начать готовку?", {
title: "Нам нужно готовить",
});
if (ok) {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
} else {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { method: "POST" });
}
await reloadRation();
}
async function initRecipeSelect() {
const sel = document.getElementById("labSandboxRecipe");
if (!sel) return;
const recipes = await loadRecipes();
sel.innerHTML =
'<option value="">— партия —</option>' +
recipes.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
const params = new URLSearchParams(global.location.search);
const q = params.get("recipe");
if (q) {
sel.value = q;
recipeId = q;
await reloadRation();
await ensureMaster();
}
}
function bindEvents() {
document.getElementById("labSandboxRecipe")?.addEventListener("change", async (ev) => {
recipeId = ev.target.value || null;
if (!recipeId) {
state = null;
renderLines();
renderResultsPanel();
return;
}
await reloadRation();
await ensureMaster();
});
document.getElementById("labSandboxProfile")?.addEventListener("change", async (ev) => {
const pid = ev.target.value;
if (!pid) {
if (state) state.norms = {};
renderNormsPanel();
return;
}
try {
const profile = await api(`/api/lab/animal-profiles/${encodeURIComponent(pid)}`);
if (state) {
state.animalProfileId = pid;
state.norms =
profile.resolvedNorms ||
profile.normsData?.resolvedIndicators ||
profile.normsData?.indicators ||
{};
state.normsProfileKey = profile.normsProfileKey || profile.profileKey;
state.legacyNormsRemapped = Boolean(profile.legacyNormsRemapped);
if (profile.rationType) state.rationType = profile.rationType;
}
const typeSel = document.getElementById("labSandboxRationType");
if (typeSel && profile.rationType) typeSel.value = profile.rationType;
renderNormsPanel();
} catch (e) {
labError(e.message);
}
});
document.getElementById("labSandboxRationType")?.addEventListener("change", async (ev) => {
await loadCatalogs(ev.target.value);
renderSetup();
});
document.getElementById("labSandboxLinesBody")?.addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-action='remove-line']");
if (!btn || !state) return;
const idx = parseInt(btn.getAttribute("data-idx"), 10);
if (!Number.isNaN(idx)) {
state.lines = (state.lines || []).filter((_, i) => i !== idx);
renderLines();
}
});
document.querySelector("[data-action='add-line']")?.addEventListener("click", () => {
if (!state) state = { lines: [] };
state.lines = [
...(state.lines || []),
{ componentId: "", ingredientName: "", dailyKg: 0, inRation: true, inCompound: false },
];
renderLines();
});
document.querySelector("[data-action='save']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — без этого не готовим");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await reloadRation();
global.WespZootechNotify?.createAdapter?.()?.success?.("Запомни это — формула сохранена");
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='recalculate']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — науку некуда применять");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collectPayload()),
});
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { method: "POST" });
await reloadRation();
global.WespZootechNotify?.createAdapter?.()?.success?.("Применена наука — 99,1% чистоты");
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='seed']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — нечего брать с поля");
return;
}
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
await reloadRation();
} catch (e) {
labError(e.message);
}
});
document.querySelector("[data-action='apply']")?.addEventListener("click", async () => {
if (!recipeId) {
labError("Сначала выбери партию — некуда отправлять");
return;
}
const ok = await global.WespDialog?.confirm?.("Отправить кг/сут из лаборатории в WESP?");
if (!ok) return;
try {
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { method: "POST" });
global.WespZootechNotify?.createAdapter?.()?.success?.("Отправлено в WESP. Ты чертовски прав");
} catch (e) {
labError(e.message);
}
});
}
function init() {
bindEvents();
loadNormCatalog();
initRecipeSelect();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);
+284
View File
@@ -0,0 +1,284 @@
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=6";
import {
markLoginTransition,
storePreloadedApiPayload,
PRELOAD_FEED_DISPENSERS_KEY,
} from "/static/js/wesp-nav-preload.js";
const LS_LOGIN = "wesp_saved_login";
const LS_REMEMBER = "wesp_remember_login";
const THEME_STORAGE_KEY = "theme";
const POST_LOGIN_REDIRECT_DELAY_MS = 300;
/** Длительность анимации логотипа после успешного входа (на странице login). */
const LOGIN_EXIT_ANIM_MS = 3500;
const RECIPES_WARM_URLS = [
"/recipes",
"/static/css/bootstrap.min.css",
"/static/css/wesp-zootech-shell.css",
"/static/css/wesp-zootech-layout.css",
"/static/css/wesp-zootech-components.css",
"/static/css/wesp-recipes-skeleton.css",
"/static/css/wesp-logo-loader.css",
"/static/css/notyf.min.css",
"/static/css/font-awesome.min.css",
"/static/js/wesp-theme-boot.js",
"/static/js/wesp-logo-loader.js?v=6",
"/static/js/wesp-page-enter.js",
"/static/js/wesp-nav-logo-shimmer.js",
"/static/js/wesp-zootech-nav.js",
"/static/js/notyf.min.js",
"/static/js/bootstrap.bundle.min.js",
"/static/js/wesp-dialog.js",
"/static/js/pages/recipes-page.js",
"/static/js/pages/recipes-auth-settings.js",
"/static/js/pages/recipes-data-controller.js",
"/static/js/pages/recipes-operations-controller.js",
"/static/js/pages/recipes-editor-controller.js",
"/static/js/modules/app-state.js",
];
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function preloadUrl(url) {
return fetch(url, { credentials: "same-origin" }).catch(() => null);
}
async function preloadRecipesAfterLogin() {
markLoginTransition();
const apiTask = preloadUrl("/api/feed_dispensers").then(async (response) => {
if (!response?.ok) return;
const payload = await response.json();
if (Array.isArray(payload)) {
storePreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY, payload);
}
});
await Promise.allSettled([
apiTask,
...RECIPES_WARM_URLS.map((url) => preloadUrl(url)),
]);
}
async function finishLoginTransition(target) {
const preloadPromise = preloadRecipesAfterLogin();
await playLoginExitAnimation();
await Promise.all([preloadPromise, delay(POST_LOGIN_REDIRECT_DELAY_MS)]);
}
function themeForLogoLoader() {
try {
const saved = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
} catch {
/* ignore */
}
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "light" || attr === "organic" || attr === "dark") return attr;
return "dark";
}
function isDarkTheme() {
return themeForLogoLoader() === "dark";
}
function safeNextPath(raw) {
if (raw == null || typeof raw !== "string") return null;
const s = raw.trim();
if (!s || s.charAt(0) !== "/") return null;
if (s.indexOf("//") === 0) return null;
if (s.indexOf("://") !== -1) return null;
if (s.indexOf("\0") !== -1 || s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) return null;
const pathOnly = s.split("?", 1)[0];
if (pathOnly.indexOf("@") !== -1) return null;
return s;
}
function postLoginRedirectUrl() {
try {
const params = new URLSearchParams(window.location.search);
const n = safeNextPath(params.get("next"));
if (n) return n;
} catch (e) {
/* ignore */
}
return "/recipes";
}
function showError(message) {
const errorDiv = document.getElementById("errorMessage");
if (!errorDiv) return;
errorDiv.textContent = message;
errorDiv.style.display = "block";
window.setTimeout(() => {
errorDiv.style.display = "none";
}, 5000);
}
function setLoading(loading) {
const button = document.getElementById("authButton");
const buttonText = document.getElementById("buttonText");
const spinner = document.getElementById("loadingSpinner");
const loginInput = document.getElementById("loginInput");
const passwordInput = document.getElementById("passwordInput");
const rememberInput = document.getElementById("rememberInput");
if (button) button.disabled = loading;
if (loginInput) loginInput.disabled = loading;
if (passwordInput) passwordInput.disabled = loading;
if (rememberInput) rememberInput.disabled = loading;
if (buttonText && spinner) {
buttonText.style.display = loading ? "none" : "block";
spinner.style.display = loading ? "block" : "none";
}
}
function applyRememberToStorage(login, remember) {
if (remember) {
try {
localStorage.setItem(LS_LOGIN, login);
localStorage.setItem(LS_REMEMBER, "1");
} catch (e) {
/* ignore */
}
} else {
try {
localStorage.removeItem(LS_LOGIN);
localStorage.removeItem(LS_REMEMBER);
} catch (e) {
/* ignore */
}
}
}
function loadRememberFromStorage() {
try {
if (localStorage.getItem(LS_REMEMBER) === "1") {
const saved = localStorage.getItem(LS_LOGIN);
if (saved) {
const loginInput = document.getElementById("loginInput");
const rememberInput = document.getElementById("rememberInput");
if (loginInput) loginInput.value = saved;
if (rememberInput) rememberInput.checked = true;
}
}
} catch (e) {
/* ignore */
}
}
async function checkSession() {
try {
const r = await fetch("/api/auth/check", { credentials: "same-origin" });
const data = await r.json();
if (data.authenticated && data.remember_login) {
const target = postLoginRedirectUrl();
await finishLoginTransition(target);
window.location.href = target;
}
} catch (e) {
/* stay on login */
}
}
function playLoginExitAnimation() {
return new Promise((resolve) => {
const page = document.querySelector(".z-login-page");
if (page) page.classList.add("z-login-page--hidden");
const backdrop = document.createElement("div");
backdrop.className = "z-login-backdrop";
document.body.appendChild(backdrop);
const wrap = document.createElement("div");
wrap.className = "z-login-loader";
const root = document.createElement("div");
wrap.appendChild(root);
document.body.appendChild(wrap);
mountWespLogoLoader(root, {
loop: false,
durationMs: LOGIN_EXIT_ANIM_MS,
totalDurationMs: LOGIN_EXIT_ANIM_MS,
embedded: true,
transparentBackground: true,
knockoutBackground: true,
theme: themeForLogoLoader(),
onComplete: () => resolve(),
}).catch(() => resolve());
});
}
async function authenticate(event) {
event.preventDefault();
const login = document.getElementById("loginInput")?.value?.trim() || "";
const password = document.getElementById("passwordInput")?.value || "";
const remember = !!document.getElementById("rememberInput")?.checked;
if (!login) {
showError("Введите логин");
return;
}
if (!password) {
showError("Введите пароль");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ login, password, remember }),
});
const data = await response.json();
if (data.status === "success") {
applyRememberToStorage(login, remember);
const target = postLoginRedirectUrl();
await finishLoginTransition(target);
window.location.href = target;
return;
}
showError(data.message || "Неверный логин или пароль");
const passwordInput = document.getElementById("passwordInput");
if (passwordInput) {
passwordInput.value = "";
passwordInput.focus();
}
setLoading(false);
} catch (error) {
showError("Ошибка соединения. Попробуйте снова.");
console.error("Authentication error:", error);
setLoading(false);
}
}
function initLoginPage() {
loadRememberFromStorage();
checkSession();
document.getElementById("loginInput")?.focus();
document.getElementById("loginForm")?.addEventListener("submit", authenticate);
document.getElementById("loginInput")?.addEventListener("keypress", (e) => {
if (e.key === "Enter") document.getElementById("passwordInput")?.focus();
});
document.getElementById("passwordInput")?.addEventListener("keypress", (e) => {
if (e.key === "Enter") authenticate(e);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initLoginPage);
} else {
initLoginPage();
}
@@ -0,0 +1,404 @@
import { initThemePicker, initHighContrastToggle, initThemeToggle, applyTheme, getTheme } from "/static/js/wesp-theme.js";
import {
DEFAULT_PASSWORD_MIN_LEN,
validateCredentialChange,
} from "/static/js/wesp-user-credentials.js";
export function createRecipesAuthSettingsController({ notyf }) {
let settingsModalClickHandler = null;
let settingsEscapeHandler = null;
let syncDispenserNames = [];
let passwordMinLen = DEFAULT_PASSWORD_MIN_LEN;
function escapeHtmlSyncSettings(value) {
if (value == null) return "";
const div = document.createElement("div");
div.textContent = String(value);
return div.innerHTML;
}
function closeSettings() {
const modal = document.getElementById("settingsModal");
if (!modal) return;
modal.style.display = "none";
document.body.style.overflow = "";
const form = document.getElementById("settingsForm");
if (form) form.reset();
const credForm = document.getElementById("credentialsForm");
if (credForm) credForm.style.display = "none";
const userCard = document.querySelector(".settings-user-card--clickable");
if (userCard) userCard.classList.remove("open");
const syncForm = document.getElementById("syncSettingsForm");
if (syncForm) syncForm.style.display = "none";
const syncCard = document.querySelector(".settings-sync-card--clickable");
if (syncCard) syncCard.classList.remove("open");
if (settingsModalClickHandler) {
modal.removeEventListener("click", settingsModalClickHandler);
settingsModalClickHandler = null;
}
if (settingsEscapeHandler) {
document.removeEventListener("keydown", settingsEscapeHandler);
settingsEscapeHandler = null;
}
}
async function openSettings(event) {
if (event) event.preventDefault();
try {
const response = await fetch("/api/auth/get_current_credentials");
const data = await response.json();
if (data.status === "success") {
const oldLogin = document.getElementById("oldLogin");
const oldPassword = document.getElementById("oldPassword");
if (oldLogin) oldLogin.value = data.login || "";
if (oldPassword) oldPassword.value = "";
}
} catch (error) {
console.error("Ошибка загрузки текущих учетных данных:", error);
}
const modal = document.getElementById("settingsModal");
if (!modal) return;
modal.style.display = "block";
document.body.style.overflow = "hidden";
initThemePicker();
initHighContrastToggle();
if (settingsModalClickHandler) {
modal.removeEventListener("click", settingsModalClickHandler);
}
if (settingsEscapeHandler) {
document.removeEventListener("keydown", settingsEscapeHandler);
}
settingsModalClickHandler = (e) => {
if (e.target === modal) closeSettings();
};
settingsEscapeHandler = (e) => {
if (e.key === "Escape" && modal.style.display === "block") closeSettings();
};
modal.addEventListener("click", settingsModalClickHandler);
document.addEventListener("keydown", settingsEscapeHandler);
}
async function checkAuth() {
try {
const response = await fetch("/api/auth/check");
const data = await response.json();
if (data.status === "success" && data.authenticated) {
const userInfo = document.getElementById("userInfo");
const userLogin = document.getElementById("userLogin");
const mobileUserInfo = document.getElementById("mobileUserInfo");
const canOpenAdmin = Boolean(data.is_superuser);
window.WESP_CAN_LAB = Boolean(data.can_lab);
window.WespLabAccess?.apply?.(window.WESP_CAN_LAB);
const rules = data.user_rules || {};
passwordMinLen = Number(rules.password_min_len) || DEFAULT_PASSWORD_MIN_LEN;
if (userLogin) userLogin.textContent = data.user_login;
if (userInfo) {
userInfo.style.cursor = canOpenAdmin ? "pointer" : "";
userInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
userInfo.onclick = canOpenAdmin
? () => {
window.location.href = "/admin";
}
: null;
}
if (mobileUserInfo) {
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${data.user_login}`;
mobileUserInfo.style.cursor = canOpenAdmin ? "pointer" : "";
mobileUserInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
mobileUserInfo.onclick = canOpenAdmin
? () => {
window.location.href = "/admin";
}
: null;
}
} else {
window.location.href = "/login";
}
return data;
} catch (error) {
console.error("Ошибка проверки авторизации:", error);
window.location.href = "/login";
return null;
}
}
async function logout() {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await response.json();
if (data.status === "success") {
window.location.href = "/";
} else {
notyf.error("Ошибка при выходе из системы");
}
} catch (error) {
console.error("Ошибка при выходе:", error);
notyf.error("Ошибка при выходе из системы");
}
}
function setupMobileMenu() {
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return;
hamburgerMenu.addEventListener("click", () => {
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
document.body.style.overflow = "hidden";
const userInfo = document.getElementById("userInfo");
if (userInfo && mobileUserInfo) {
mobileUserInfo.textContent = userInfo.textContent || "";
}
});
const closeMobileMenu = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
document.body.style.overflow = "";
};
closeMenu.addEventListener("click", closeMobileMenu);
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) closeMobileMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) {
closeMobileMenu();
}
});
const mobileMenuItems = document.querySelectorAll(".mobile-menu-item");
mobileMenuItems.forEach((item) => {
item.addEventListener("click", () => {
setTimeout(closeMobileMenu, 300);
});
});
}
async function loadSyncClientsSettings() {
const listEl = document.getElementById("syncClientsListSettings");
const emptyEl = document.getElementById("syncClientsEmptySettings");
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.style.display = "none";
try {
const [clientsRes, namesRes] = await Promise.all([
fetch("/api/sync/clients"),
fetch("/api/feed_dispensers/names"),
]);
const clients = clientsRes.ok ? await clientsRes.json() : [];
syncDispenserNames = namesRes.ok ? await namesRes.json() : [];
if (!clients || clients.length === 0) {
if (emptyEl) {
emptyEl.style.display = "block";
emptyEl.textContent = "Нет подключенных клиентов";
}
return;
}
clients.forEach((client) => {
const displayLabel = client.display_name || client.client_name || client.node_id;
const item = document.createElement("div");
item.className = "settings-sync-client-item";
item.dataset.nodeId = client.node_id;
item.dataset.displayName = displayLabel || "";
item.innerHTML =
`<span class="settings-sync-client-name">${escapeHtmlSyncSettings(displayLabel)}</span>` +
'<div class="settings-sync-client-actions">' +
'<button type="button" class="btn btn-sm btn-edit btn-outline-primary" data-action="sync-edit-client"><i class="fas fa-edit"></i> Изменить</button> ' +
'<button type="button" class="btn btn-sm btn-delete btn-outline-danger" data-action="sync-delete-client"><i class="fas fa-trash"></i> Удалить</button>' +
"</div>";
listEl.appendChild(item);
});
} catch (error) {
if (emptyEl) {
emptyEl.style.display = "block";
emptyEl.textContent = "Не удалось загрузить список клиентов";
}
}
}
function editSyncClientSettings(btn) {
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
const nodeId = item ? item.dataset.nodeId : null;
const currentName = item ? item.dataset.displayName || "" : "";
if (!item || !nodeId) return;
let selectHtml = '<option value="">— не задано —</option>';
syncDispenserNames.forEach((name) => {
const escaped = escapeHtmlSyncSettings(name);
selectHtml += `<option value="${escaped}">${escaped}</option>`;
});
item.innerHTML =
'<div class="settings-sync-edit-row w-100">' +
'<label class="mb-0 me-2">Имя клиента:</label>' +
`<select class="form-control form-control-sm d-inline-block" id="syncEditSelect_${nodeId}">${selectHtml}</select> ` +
`<button type="button" class="btn btn-sm btn-primary" data-action="sync-save-client" data-node-id="${String(nodeId).replace(/"/g, "&quot;")}"><i class="fas fa-save"></i> Сохранить</button> ` +
'<button type="button" class="btn btn-sm btn-secondary" data-action="sync-cancel-edit">Отмена</button>' +
"</div>";
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
if (sel && currentName) sel.value = currentName;
}
function saveSyncClientDisplayName(nodeId) {
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
const displayName = sel ? (sel.value || "").trim() : "";
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: displayName || null }),
})
.then((r) => r.json())
.then((data) => {
if (data.error) {
notyf.error(data.error);
return;
}
notyf.success(displayName ? "Имя сохранено" : "Имя сброшено");
loadSyncClientsSettings();
})
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
}
async function deleteSyncClientSettings(btn) {
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
const nodeId = item ? item.dataset.nodeId : null;
if (!nodeId) return;
const ok = globalThis.WespKioskDialog
? await globalThis.WespKioskDialog.confirm("Удалить этого клиента из синхронизации?")
: globalThis.confirm("Удалить этого клиента из синхронизации?");
if (!ok) return;
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" })
.then((r) => r.json())
.then((data) => {
if (data.error) {
notyf.error(data.error);
return;
}
notyf.success(data.message || "Клиент удалён");
loadSyncClientsSettings();
})
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
}
function toggleCredentialsForm() {
const credForm = document.getElementById("credentialsForm");
const userCard = document.querySelector(".settings-user-card--clickable");
if (!credForm) return;
const isOpen = credForm.style.display !== "none";
credForm.style.display = isOpen ? "none" : "block";
if (userCard) userCard.classList.toggle("open", !isOpen);
}
function toggleSyncForm() {
const syncForm = document.getElementById("syncSettingsForm");
const syncCard = document.querySelector(".settings-sync-card--clickable");
if (!syncForm) return;
const isOpen = syncForm.style.display !== "none";
syncForm.style.display = isOpen ? "none" : "block";
if (syncCard) syncCard.classList.toggle("open", !isOpen);
if (!isOpen) loadSyncClientsSettings();
}
async function changeCredentials() {
const oldLogin = (document.getElementById("oldLogin")?.value || "").trim();
const oldPassword = document.getElementById("oldPassword")?.value || "";
const newLogin = (document.getElementById("newLogin")?.value || "").trim();
const newPassword = document.getElementById("newPassword")?.value || "";
const confirmPassword = document.getElementById("confirmPassword")?.value || "";
const wantsCredChange = !!(newLogin || newPassword || confirmPassword.trim());
if (!wantsCredChange) {
notyf.success("Настройки сохранены");
closeSettings();
return;
}
const validationError = validateCredentialChange({
oldLogin,
oldPassword,
newLogin,
newPassword,
confirmPassword,
passwordMinLen,
});
if (validationError) {
notyf.error(validationError);
return;
}
try {
const response = await fetch("/api/auth/change_credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_login: oldLogin,
old_password: oldPassword,
new_login: newLogin,
new_password: newPassword,
confirm_password: confirmPassword,
}),
});
const data = await response.json();
if (data.status === "success") {
notyf.success(data.message);
closeSettings();
const userLogin = document.getElementById("userLogin");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (userLogin) userLogin.textContent = newLogin;
if (mobileUserInfo) {
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${newLogin}`;
}
} else {
notyf.error(data.message);
}
} catch (error) {
console.error("Ошибка при изменении учетных данных:", error);
notyf.error("Ошибка при изменении учетных данных");
}
}
applyTheme(getTheme());
return {
checkAuth,
logout,
setupMobileMenu,
openSettings,
closeSettings,
toggleSyncForm,
loadSyncClientsSettings,
editSyncClientSettings,
saveSyncClientDisplayName,
deleteSyncClientSettings,
toggleCredentialsForm,
changeCredentials,
initThemePicker,
initHighContrastToggle,
initThemeToggle,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { LOGIN_TRANSITION_KEY, clearRecipesBootOverlay } from "/static/js/wesp-nav-preload.js";
function hasLoginTransition() {
try {
const raw = sessionStorage.getItem(LOGIN_TRANSITION_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
return Boolean(parsed?.storedAt);
} catch {
return false;
}
}
/** После входа — без полноэкранного логотипа, только page-enter на /recipes. */
export function initRecipesBootOverlay() {
if (!hasLoginTransition()) return;
clearRecipesBootOverlay();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
export function createRecipesEditorController({
notyf,
getCurrentDispenserType,
setCurrentDispenserType,
getComponentsCache,
ensureComponentsLoaded,
setSelectedDispenser,
setSelectedPeriod,
setSelectedRecipe,
resetDeletedEntities,
setUnloadingLinkBroken,
initializeUnloadingLinkButton,
updateTripPercentFieldState,
setDryMatterMode,
loadIngredients,
loadUnloadingGroups,
showRecipeEdit,
}) {
function resetCreationForm() {
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 = "";
updateTripPercentFieldState();
resetDeletedEntities();
const tripPercent = document.getElementById("tripPercent");
if (tripPercent) tripPercent.value = 100;
const totalWeightIngredients = document.getElementById("totalWeightIngredients");
if (totalWeightIngredients) totalWeightIngredients.textContent = "0";
const totalTripWeight = document.getElementById("totalTripWeight");
if (totalTripWeight) totalTripWeight.textContent = "0";
const totalDryMatterPerHead = document.getElementById("totalDryMatterPerHead");
if (totalDryMatterPerHead) totalDryMatterPerHead.textContent = "0";
const totalWeightPerHead = document.getElementById("totalWeightPerHead");
if (totalWeightPerHead) totalWeightPerHead.textContent = "0";
}
async function loadComponentsForMill() {
try {
await ensureComponentsLoaded();
const select = document.getElementById("targetComponentSelect");
if (!select) return;
const components = Array.isArray(getComponentsCache()) ? getComponentsCache() : [];
const currentValue = select.value;
select.innerHTML = `
<option value="">Выберите компонент...</option>
${components.map((component) => `<option value="${component.id}">${component.name}</option>`).join("")}
`;
if (currentValue) {
select.value = currentValue;
}
} catch (error) {
console.error("Ошибка при загрузке компонентов для кормоцеха:", error);
}
}
function toggleUnloadingBlocks() {
const isMill = getCurrentDispenserType() === "mill";
const unloadingGroupsCard = document.getElementById("unloadingGroupsCard");
const componentSelectionCard = document.getElementById("componentSelectionCard");
if (unloadingGroupsCard) unloadingGroupsCard.style.display = "block";
if (componentSelectionCard) {
componentSelectionCard.style.display = isMill ? "block" : "none";
}
if (isMill) {
loadComponentsForMill();
}
}
async function loadRecipeDetails(recipeId, options = {}) {
try {
const response = await fetch(`/api/recipes/${recipeId}`);
if (!response.ok) throw new Error("Ошибка при загрузке данных рецепта");
let recipe = await response.json();
if (!options.skipPlanOverlayFlags) {
const planDate =
options.planDate ||
globalThis.WespDailyPlanPanel?.getSelectedPlanDate?.() ||
new Date().toISOString().slice(0, 10);
try {
const overlayResp = await fetch(
`/api/recipes/${recipeId}?date=${encodeURIComponent(planDate)}`
);
if (overlayResp.ok) {
const overlay = await overlayResp.json();
const ingById = new Map(
(overlay.ingredients || []).map((ing) => [String(ing.id), ing])
);
recipe = {
...recipe,
ingredients: (recipe.ingredients || []).map((ing) => {
const overlayIng = ingById.get(String(ing.id));
if (!overlayIng) return ing;
return {
...ing,
skippedToday: Boolean(overlayIng.skippedToday),
adjustedToday: Boolean(overlayIng.adjustedToday),
replacedToday: Boolean(overlayIng.replacedToday),
};
}),
};
const groups = recipe.unloading_groups || recipe.unloadingGroups || [];
const grpById = new Map(
(overlay.unloadingGroups || overlay.unloading_groups || []).map((group) => [
String(group.id),
group,
])
);
const mergedGroups = groups.map((group) => {
const overlayGroup = grpById.get(String(group.id));
if (!overlayGroup) return group;
return { ...group, skippedToday: Boolean(overlayGroup.skippedToday) };
});
recipe.unloading_groups = mergedGroups;
recipe.unloadingGroups = mergedGroups;
}
} catch (overlayError) {
console.warn("Не удалось загрузить флаги плана для редактора рецепта:", overlayError);
}
}
resetDeletedEntities();
const recipeNameInput = document.getElementById("recipeName");
if (recipeNameInput) recipeNameInput.value = recipe.name || "";
const headsCountInput = document.getElementById("headsCount");
if (headsCountInput) {
const heads =
recipe.heads_count ??
recipe.headsPerTrip ??
recipe.heads_per_trip ??
"";
headsCountInput.value = heads === "" || heads == null ? "" : String(heads);
}
const mixingTimeInput = document.getElementById("mixingTime");
if (mixingTimeInput) {
const mt = recipe.mixing_time ?? recipe.mixingTime;
mixingTimeInput.value = mt === "" || mt == null ? "" : String(mt);
}
const tripPercentInput = document.getElementById("tripPercent");
if (tripPercentInput) {
const tp = recipe.trip_percent ?? recipe.tripPercent;
tripPercentInput.value = tp === "" || tp == null ? 100 : String(tp);
}
const ingredientsTableBody = document.getElementById("ingredientsTableBody");
if (ingredientsTableBody) ingredientsTableBody.innerHTML = "";
const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody");
if (unloadingGroupsTableBody) unloadingGroupsTableBody.innerHTML = "";
setUnloadingLinkBroken(recipe.unloading_link_broken || false);
initializeUnloadingLinkButton();
updateTripPercentFieldState();
setDryMatterMode(!!recipe.dry_matter_locked);
toggleUnloadingBlocks();
updateTripPercentFieldState();
await loadIngredients(recipe.ingredients);
const unloadingGroups = recipe.unloading_groups || recipe.unloadingGroups || [];
await loadUnloadingGroups(unloadingGroups);
if (getCurrentDispenserType() === "mill") {
const targetComponentSelect = document.getElementById("targetComponentSelect");
if (targetComponentSelect) {
targetComponentSelect.value = "";
if (recipe.target_component_id) {
targetComponentSelect.value = recipe.target_component_id;
}
}
}
} catch (error) {
console.error("Ошибка при загрузке данных рецепта:", error);
notyf.error("Ошибка при загрузке данных рецепта");
}
}
async function createNewRecipeForMill(dispenserId) {
try {
setSelectedDispenser(dispenserId);
setSelectedPeriod(null);
setSelectedRecipe(null);
setCurrentDispenserType("mill");
resetCreationForm();
toggleUnloadingBlocks();
showRecipeEdit(true);
setDryMatterMode(false);
} catch (error) {
console.error("Ошибка в createNewRecipeForMill:", error);
notyf.error("Ошибка при создании рецепта");
}
}
async function createNewRecipe(periodId) {
try {
setSelectedPeriod(periodId);
setSelectedRecipe(null);
resetCreationForm();
toggleUnloadingBlocks();
showRecipeEdit(true);
setDryMatterMode(false);
} catch (error) {
console.error("Ошибка в createNewRecipe:", error);
notyf.error("Ошибка при создании рейса");
}
}
return {
toggleUnloadingBlocks,
loadComponentsForMill,
loadRecipeDetails,
createNewRecipeForMill,
createNewRecipe,
};
}
@@ -0,0 +1,336 @@
export function createRecipesOperationsController({
notyf,
getSelectedRecipe,
setSelectedRecipe,
getSelectedPeriod,
setSelectedPeriod,
getSelectedDispenser,
getCurrentDispenserType,
showRecipeEdit,
loadRecipes,
loadPeriods,
ensureComponentsLoaded,
setUnloadingLinkBroken,
initializeUnloadingLinkButton,
updateTripPercentFieldState,
setDryMatterMode,
loadIngredients,
loadUnloadingGroups,
recalculateWeights,
updateTotalValues,
}) {
let copiedRecipeData = null;
let moveRecipeInFlight = false;
async function deleteRecipe(recipeId) {
const periodId = getSelectedPeriod();
const dispenserId = getSelectedDispenser();
const isMill =
typeof getCurrentDispenserType === "function" && getCurrentDispenserType() === "mill";
/** В контексте периода кормораздатчика удаляем только связь с периодом — иначе DELETE /api/recipes глобально убирает рецепт со всех оборудований. */
const unlinkFromPeriodOnly = Boolean(periodId && dispenserId && !isMill);
const msg = unlinkFromPeriodOnly
? "Удалить этот рейс из выбранного периода? (Рецепт останется в базе, если используется ещё где-то.)"
: "Вы уверены, что хотите удалить этот рецепт? Он будет удалён для всех привязок.";
const ok = globalThis.WespKioskDialog
? await globalThis.WespKioskDialog.confirm(msg)
: globalThis.confirm(msg);
if (!ok) return;
try {
const url = unlinkFromPeriodOnly
? `/api/feed_dispensers/${encodeURIComponent(dispenserId)}/periods/${encodeURIComponent(periodId)}/recipes/${encodeURIComponent(recipeId)}`
: `/api/recipes/${encodeURIComponent(recipeId)}`;
const response = await fetch(url, { method: "DELETE" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Ошибка при удалении рейса");
}
const result = await response.json().catch(() => ({}));
notyf.success(result.message || (unlinkFromPeriodOnly ? "Рейс удалён из периода" : "Рейс удален"));
if (String(getSelectedRecipe() ?? "") === String(recipeId)) {
setSelectedRecipe(null);
showRecipeEdit(false);
}
if (getSelectedPeriod()) {
await loadRecipes(getSelectedPeriod());
}
if (getSelectedDispenser()) {
await loadPeriods(getSelectedDispenser());
}
} catch (error) {
console.error("Ошибка при удалении рейса:", error);
notyf.error(error.message, { fallback: "Ошибка при удалении рейса" });
}
}
async function moveRecipe(recipeId, fromIndex, toIndex) {
const selectedPeriod = getSelectedPeriod();
if (!selectedPeriod || fromIndex === toIndex) return;
if (
typeof document !== "undefined" &&
document.body.classList.contains("recipe-period-transfer-active")
) {
notyf.error(
"Сначала завершите перенос в другой период (кнопка «Вставить здесь») или отмените его (Escape, отпустите вне списка)."
);
return;
}
if (moveRecipeInFlight) return;
if (!applyOptimisticRecipeReorder(fromIndex, toIndex)) {
return;
}
moveRecipeInFlight = true;
try {
const response = await fetch(`/api/recipes/${encodeURIComponent(recipeId)}/move`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_index: fromIndex, to_index: toIndex, period_id: selectedPeriod }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
await loadRecipes(selectedPeriod, { skipRecipesSkeleton: true });
throw new Error(errorData.message || "Ошибка при перемещении рецепта");
}
const listEl = document.getElementById("recipesList");
if (listEl) {
reindexRecipeListItems(listEl);
}
} catch (error) {
console.error("Ошибка при перемещении рейса:", error);
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
} finally {
moveRecipeInFlight = false;
}
}
function reindexRecipeListItems(listEl) {
const items = listEl.querySelectorAll(":scope > .list-item[data-recipe-id]");
items.forEach((item, index) => {
item.dataset.index = String(index);
item.querySelectorAll("[data-index]").forEach((el) => {
el.dataset.index = String(index);
});
const upBtn = item.querySelector('[data-action="move-recipe-up"]');
const downBtn = item.querySelector('[data-action="move-recipe-down"]');
if (upBtn) upBtn.disabled = index === 0;
if (downBtn) downBtn.disabled = index === items.length - 1;
});
}
function applyOptimisticRecipeReorder(fromIndex, toIndex) {
const listEl = document.getElementById("recipesList");
if (!listEl) return false;
const items = [...listEl.querySelectorAll(":scope > .list-item[data-recipe-id]")];
if (
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= items.length ||
toIndex >= items.length ||
fromIndex === toIndex
) {
return false;
}
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
items.forEach((item) => listEl.appendChild(item));
reindexRecipeListItems(listEl);
return true;
}
async function moveRecipeUp(recipeId, currentIndex) {
if (currentIndex === 0) {
notyf.error("Рейс уже находится в начале списка");
return;
}
try {
await moveRecipe(recipeId, currentIndex, currentIndex - 1);
} catch (error) {
console.error("Ошибка при перемещении рейса:", error);
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
}
}
async function moveRecipeDown(recipeId, currentIndex) {
const totalRecipes = document.querySelectorAll("#recipesList .list-item").length;
if (currentIndex >= totalRecipes - 1) {
notyf.error("Рейс уже находится в конце списка");
return;
}
try {
await moveRecipe(recipeId, currentIndex, currentIndex + 1);
} catch (error) {
console.error("Ошибка при перемещении рейса:", error);
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
}
}
async function pasteRecipe(periodId) {
if (!copiedRecipeData) {
notyf.error("Буфер пуст. Сначала скопируйте рейс.");
return;
}
try {
setSelectedPeriod(periodId);
setSelectedRecipe(null);
await ensureComponentsLoaded();
const recipeName = document.getElementById("recipeName");
if (recipeName) {
recipeName.value = `${copiedRecipeData.name} (копия)`;
}
const headsCount = document.getElementById("headsCount");
if (headsCount) {
const h =
copiedRecipeData.heads_count ??
copiedRecipeData.headsPerTrip ??
copiedRecipeData.heads_per_trip ??
"";
headsCount.value = h === "" || h == null ? "" : String(h);
}
const mixingTime = document.getElementById("mixingTime");
if (mixingTime) {
const mt = copiedRecipeData.mixing_time ?? copiedRecipeData.mixingTime;
mixingTime.value = mt === "" || mt == null ? "" : String(mt);
}
const tripPercent = document.getElementById("tripPercent");
if (tripPercent) {
const tp = copiedRecipeData.trip_percent ?? copiedRecipeData.tripPercent;
tripPercent.value = tp === "" || tp == null ? 100 : String(tp);
}
const ingredientsTableBody = document.getElementById("ingredientsTableBody");
if (ingredientsTableBody) {
ingredientsTableBody.innerHTML = "";
}
const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody");
if (unloadingGroupsTableBody) {
unloadingGroupsTableBody.innerHTML = "";
}
setUnloadingLinkBroken(Boolean(copiedRecipeData.unloading_link_broken));
initializeUnloadingLinkButton();
updateTripPercentFieldState();
setDryMatterMode(false);
if (copiedRecipeData.ingredients && copiedRecipeData.ingredients.length > 0) {
await loadIngredients(copiedRecipeData.ingredients);
}
const copiedGroups =
copiedRecipeData.unloading_groups?.length > 0
? copiedRecipeData.unloading_groups
: copiedRecipeData.unloadingGroups;
if (copiedGroups && copiedGroups.length > 0) {
await loadUnloadingGroups(copiedGroups);
}
showRecipeEdit(true);
setTimeout(() => {
Promise.resolve(recalculateWeights()).catch((err) => console.error("Ошибка при расчете:", err));
}, 100);
setTimeout(() => {
Promise.resolve(updateTotalValues()).catch((err) => console.error("Ошибка при расчете:", err));
}, 100);
notyf.success("Рейс вставлен из буфера");
} catch (error) {
console.error("Ошибка при вставке рейса:", error);
notyf.error("Ошибка при вставке рейса");
}
}
async function copyRecipe(recipeId) {
if (!recipeId) {
notyf.error("Выберите рейс для копирования");
return;
}
try {
const response = await fetch(`/api/recipes/${recipeId}`);
if (!response.ok) {
throw new Error("Ошибка при загрузке рейса");
}
const recipe = await response.json();
copiedRecipeData = {
name: recipe.name,
heads_count:
recipe.heads_count ??
recipe.headsPerTrip ??
recipe.heads_per_trip ??
0,
mixing_time: recipe.mixing_time ?? recipe.mixingTime ?? 0,
trip_percent: recipe.trip_percent ?? recipe.tripPercent ?? 100,
ingredients: (recipe.ingredients || []).map((ing) => ({
component_id: ing.component_id,
weight_per_head: ing.weight_per_head,
amount: ing.amount,
dry_matter: ing.dry_matter,
order: ing.order,
})),
unloading_link_broken: Boolean(
recipe.unloading_link_broken ?? recipe.unloadingLinkBroken ?? false
),
unloading_groups: (recipe.unloading_groups || recipe.unloadingGroups || []).map((group) => ({
name: group.name,
distribution_type: group.distribution_type ?? group.distributionType ?? "percent",
value: group.value,
weight: group.weight,
order: group.order,
})),
};
updatePasteButtons();
notyf.success("Рейс скопирован");
} catch (error) {
console.error("Ошибка при копировании рейса:", error);
notyf.error("Ошибка при копировании рейса");
}
}
function updatePasteButtons() {
const pasteButtons = document.querySelectorAll(".paste-recipe-btn");
const hasData = copiedRecipeData !== null;
pasteButtons.forEach((button) => {
button.disabled = !hasData;
if (hasData) {
button.title = `Вставить рейс "${copiedRecipeData.name}"`;
} else {
button.title = "Вставить рейс из буфера";
}
});
}
return {
deleteRecipe,
moveRecipe,
moveRecipeUp,
moveRecipeDown,
pasteRecipe,
copyRecipe,
updatePasteButtons,
};
}
+135
View File
@@ -0,0 +1,135 @@
import { RecipeEditor } from "/static/js/modules/recipes/recipe-editor.js";
import { SyncPanel } from "/static/js/modules/recipes/sync-panel.js";
import { DispenserSelector } from "/static/js/modules/recipes/dispenser-selector.js";
import { initRecipeMobileSheetGestures } from "/static/js/modules/recipes/recipe-mobile-sheet-gestures.js";
let initialized = false;
export function initRecipesPage(handlers) {
if (initialized) return;
initialized = true;
const recipeEditorModule = new RecipeEditor({
addIngredient: handlers.addIngredient,
toggleUnloadingLink: handlers.toggleUnloadingLink,
addUnloadingGroup: handlers.addUnloadingGroup,
showRecipeEdit: handlers.showRecipeEdit,
printAllRecipesInPeriod: handlers.printAllRecipesInPeriod,
printRecipe: handlers.printRecipe,
saveRecipe: handlers.saveRecipe,
moveGroupUp: handlers.moveGroupUp,
moveGroupDown: handlers.moveGroupDown,
removeUnloadingGroup: handlers.removeUnloadingGroup,
moveIngredientUp: handlers.moveIngredientUp,
moveIngredientDown: handlers.moveIngredientDown,
removeIngredient: handlers.removeIngredient,
openIngredientMobileSheet: handlers.openIngredientMobileSheet,
closeIngredientMobileSheet: handlers.closeIngredientMobileSheet,
removeIngredientFromSheet: handlers.removeIngredientFromSheet,
openUnloadingGroupMobileSheet: handlers.openUnloadingGroupMobileSheet,
closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet,
handleGroupTypeChange: handlers.handleGroupTypeChange,
validateGroupValue: handlers.validateGroupValue,
handleIngredientChange: handlers.handleIngredientChange,
handleDryMatterPercentChange: handlers.handleDryMatterPercentChange,
recalculateWeights: handlers.recalculateWeights,
recalculateFromTotalWeight: handlers.recalculateFromTotalWeight,
});
const syncPanelModule = new SyncPanel({
openSettings: handlers.openSettings,
closeSettings: handlers.closeSettings,
toggleCredentialsForm: handlers.toggleCredentialsForm,
toggleSyncForm: handlers.toggleSyncForm,
changeCredentials: handlers.changeCredentials,
editSyncClientSettings: handlers.editSyncClientSettings,
deleteSyncClientSettings: handlers.deleteSyncClientSettings,
saveSyncClientDisplayName: handlers.saveSyncClientDisplayName,
loadSyncClientsSettings: handlers.loadSyncClientsSettings,
});
const dispenserSelectorModule = new DispenserSelector({
selectDispenser: handlers.selectDispenser,
selectRecipe: handlers.selectRecipe,
selectPeriod: handlers.selectPeriod,
copyRecipe: handlers.copyRecipe,
deleteRecipe: handlers.deleteRecipe,
unskipRecipeToday: handlers.unskipRecipeToday,
unskipIngredientPartsToday: handlers.unskipIngredientPartsToday,
unskipGroupPartsToday: handlers.unskipGroupPartsToday,
pasteRecipe: handlers.pasteRecipe,
createNewRecipe: handlers.createNewRecipe,
moveRecipeUp: handlers.moveRecipeUp,
moveRecipeDown: handlers.moveRecipeDown,
});
initRecipeMobileSheetGestures({
closeIngredientMobileSheet: handlers.closeIngredientMobileSheet,
closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet,
closeRecipeHelpModal: handlers.closeRecipeHelpModal,
});
const createRecipeBtn = document.getElementById("createRecipeBtn");
if (createRecipeBtn) {
createRecipeBtn.addEventListener("click", (event) => {
event.stopPropagation();
const selectedDispenser = handlers.getSelectedDispenser();
if (selectedDispenser) {
handlers.createNewRecipeForMill(selectedDispenser);
}
});
}
document.addEventListener("click", async (event) => {
const actionEl = event.target.closest("[data-action]");
if (!actionEl) return;
const action = actionEl.dataset.action;
if (await syncPanelModule.handleAction(action, event, actionEl)) return;
if (await dispenserSelectorModule.handleAction(action, event, actionEl)) return;
if (await recipeEditorModule.handleAction(action, event, actionEl)) return;
if (action === "open-recipe-help") {
event.preventDefault();
handlers.openRecipeHelp?.(actionEl.id);
return;
}
if (action === "close-recipe-help") {
event.preventDefault();
handlers.closeRecipeHelpModal?.();
return;
}
if (action === "mobile-dashboard-back") {
event.preventDefault();
handlers.mobileDashboardBack?.();
return;
}
if (action === "logout") {
event.preventDefault();
handlers.logout();
}
});
document.addEventListener("change", (event) => {
const actionEl = event.target.closest("[data-action]");
if (!actionEl) return;
const action = actionEl.dataset.action;
recipeEditorModule.handleChange(action, actionEl);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
const ingSheet = document.getElementById("ingredientMobileSheet");
if (ingSheet && !ingSheet.hidden) {
handlers.closeIngredientMobileSheet?.();
return;
}
const grpSheet = document.getElementById("unloadingGroupMobileSheet");
if (grpSheet && !grpSheet.hidden) {
handlers.closeUnloadingGroupMobileSheet?.();
}
});
}
@@ -0,0 +1,315 @@
/**
* Компактный Date Range Picker для /reports.
* Сохраняет #date-from / #date-to для совместимости с остальным кодом.
*/
(function (global) {
const MONTHS = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
let draftFrom = null;
let draftTo = null;
let viewMonth = null;
let activePreset = "day";
let panelEl = null;
let triggerEl = null;
function pad(n) {
return String(n).padStart(2, "0");
}
function toIso(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function parseIso(s) {
if (!s) return null;
const p = String(s).split("-");
if (p.length !== 3) return null;
const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
d.setHours(0, 0, 0, 0);
return d;
}
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function today() {
return startOfDay(new Date());
}
function sameDay(a, b) {
return a && b && toIso(a) === toIso(b);
}
function formatLabel(from, to) {
if (!from || !to) return "Выберите даты";
const t = today();
const fmt = (d) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`;
if (sameDay(from, to) && sameDay(from, t)) return "Сегодня";
if (sameDay(from, to)) return fmt(from);
return `${fmt(from)}${fmt(to)}`;
}
function syncHiddenInputs(from, to) {
const fromEl = document.getElementById("date-from");
const toEl = document.getElementById("date-to");
if (fromEl) fromEl.value = from ? toIso(from) : "";
if (toEl) toEl.value = to ? toIso(to) : "";
}
function readCommittedRange() {
const fromEl = document.getElementById("date-from");
const toEl = document.getElementById("date-to");
return {
from: parseIso(fromEl?.value),
to: parseIso(toEl?.value),
};
}
function updateTriggerLabel() {
const label = document.getElementById("reportsDateRangeLabel");
if (!label) return;
const { from, to } = readCommittedRange();
label.textContent = formatLabel(from, to);
}
function setPresetActive(preset) {
activePreset = preset || "";
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-drp-preset") === activePreset);
});
}
function computePresetRange(preset) {
const t = today();
let from = t;
let to = t;
if (preset === "week") {
from = new Date(t);
from.setDate(t.getDate() - ((t.getDay() + 6) % 7));
} else if (preset === "month") {
from = new Date(t.getFullYear(), t.getMonth(), 1);
}
return { from: startOfDay(from), to: startOfDay(to) };
}
function commitDraft() {
if (draftFrom && !draftTo) draftTo = draftFrom;
if (!draftFrom || !draftTo) return readCommittedRange();
syncHiddenInputs(draftFrom, draftTo);
setPresetActive("");
updateTriggerLabel();
return { from: draftFrom, to: draftTo };
}
function applyRange(from, to, preset) {
syncHiddenInputs(from, to);
draftFrom = from;
draftTo = to;
setPresetActive(preset || "");
updateTriggerLabel();
}
function commitSelection() {
commitDraft();
closePanel();
}
function positionPanel() {
if (!triggerEl || !panelEl || panelEl.hidden) return;
const rect = triggerEl.getBoundingClientRect();
const width = Math.max(rect.width, 304);
let left = rect.left;
if (left + width > window.innerWidth - 12) {
left = Math.max(12, window.innerWidth - width - 12);
}
let top = rect.bottom + 6;
const panelHeight = panelEl.offsetHeight || 360;
if (top + panelHeight > window.innerHeight - 12) {
top = Math.max(12, rect.top - panelHeight - 6);
}
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${width}px`;
}
function closePanel() {
if (panelEl) panelEl.hidden = true;
if (triggerEl) triggerEl.setAttribute("aria-expanded", "false");
window.removeEventListener("resize", positionPanel);
window.removeEventListener("scroll", positionPanel, true);
}
function openPanel() {
const committed = readCommittedRange();
draftFrom = committed.from || today();
draftTo = committed.to || draftFrom;
viewMonth = new Date(draftFrom.getFullYear(), draftFrom.getMonth(), 1);
renderCalendar();
if (panelEl) panelEl.hidden = false;
if (triggerEl) triggerEl.setAttribute("aria-expanded", "true");
positionPanel();
window.addEventListener("resize", positionPanel);
window.addEventListener("scroll", positionPanel, true);
}
function togglePanel() {
if (panelEl?.hidden) openPanel();
else closePanel();
}
function inRange(d, a, b) {
if (!a || !b) return false;
const t = d.getTime();
const lo = Math.min(a.getTime(), b.getTime());
const hi = Math.max(a.getTime(), b.getTime());
return t >= lo && t <= hi;
}
function onDayClick(d) {
if (!draftFrom || (draftFrom && draftTo)) {
draftFrom = d;
draftTo = null;
setPresetActive("");
renderCalendar();
return;
}
if (d < draftFrom) {
draftTo = draftFrom;
draftFrom = d;
} else {
draftTo = d;
}
setPresetActive("");
commitSelection();
}
function renderCalendar() {
const grid = document.getElementById("reportsDateRangeGrid");
const title = document.getElementById("reportsDateRangeMonth");
if (!grid || !viewMonth) return;
const y = viewMonth.getFullYear();
const m = viewMonth.getMonth();
if (title) title.textContent = `${MONTHS[m]} ${y}`;
const first = new Date(y, m, 1);
const startOffset = (first.getDay() + 6) % 7;
const daysInMonth = new Date(y, m + 1, 0).getDate();
let html = "";
for (let i = 0; i < startOffset; i++) {
html += '<span class="reports-drp__day reports-drp__day--empty"></span>';
}
for (let day = 1; day <= daysInMonth; day++) {
const d = new Date(y, m, day);
const classes = ["reports-drp__day"];
if (sameDay(d, today())) classes.push("reports-drp__day--today");
if (draftFrom && sameDay(d, draftFrom)) classes.push("reports-drp__day--edge");
if (draftTo && sameDay(d, draftTo)) classes.push("reports-drp__day--edge");
if (inRange(d, draftFrom, draftTo)) classes.push("reports-drp__day--in-range");
html += `<button type="button" class="${classes.join(" ")}" data-drp-day="${toIso(d)}">${day}</button>`;
}
grid.innerHTML = html;
grid.querySelectorAll("[data-drp-day]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const d = parseIso(btn.getAttribute("data-drp-day"));
if (d) onDayClick(d);
});
});
}
function setPeriod(period) {
const { from, to } = computePresetRange(period);
applyRange(from, to, period);
closePanel();
}
function init(options) {
triggerEl = document.getElementById("reportsDateRangeTrigger");
panelEl = document.getElementById("reportsDateRangePanel");
if (!triggerEl || !panelEl) return;
if (panelEl.parentElement !== document.body) {
document.body.appendChild(panelEl);
}
panelEl.addEventListener("mousedown", (e) => e.stopPropagation());
panelEl.addEventListener("click", (e) => e.stopPropagation());
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
setPeriod(btn.getAttribute("data-drp-preset") || "day");
});
});
document.getElementById("reportsDateRangePrev")?.addEventListener("click", (e) => {
e.stopPropagation();
if (!viewMonth) viewMonth = today();
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1);
renderCalendar();
positionPanel();
});
document.getElementById("reportsDateRangeNext")?.addEventListener("click", (e) => {
e.stopPropagation();
if (!viewMonth) viewMonth = today();
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1);
renderCalendar();
positionPanel();
});
triggerEl.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
document.addEventListener("click", () => {
if (!panelEl.hidden) closePanel();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePanel();
});
const urlParams = new URLSearchParams(window.location.search);
const highlightReportId = urlParams.get("report");
if (highlightReportId) {
global.__wespHighlightReportId = highlightReportId;
setPeriod("month");
} else {
setPeriod("day");
}
if (options?.onReady) options.onReady();
}
global.WespReportsDateRange = {
init,
setPeriod,
commit: commitDraft,
updateTriggerLabel,
formatLabel,
parseIso,
toIso,
};
global.setPeriod = setPeriod;
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,86 @@
/**
* Экспорт /reports выбор раздела и формата (Excel / PDF).
*/
(function (global) {
function appendFilterContext(params) {
const farms = global.WespReportsFilters?.getSelectedFarmNames?.() || [];
const dispensers = global.WespReportsFilters?.getSelectedDispenserNames?.() || [];
const recipes = global.WespReportsFilters?.getSelectedRecipeNames?.() || [];
params.set("filter_farms", farms.length ? farms.join(",") : "all");
params.set("filter_dispensers", dispensers.length ? dispensers.join(",") : "all");
params.set("filter_recipes", recipes.length ? recipes.join(",") : "all");
return params;
}
function buildExportUrl(format, section) {
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams();
appendFilterContext(params);
params.set("format", format || "xlsx");
params.set("section", section || "all");
return `/api/analytics/export?${params.toString()}`;
}
function ensureDates() {
const { date_from, date_to } = global.WespReportsFilters?.getDateParams?.() || {};
if (!date_from || !date_to) {
global.WespZootechNotify?.error?.("Выберите период для экспорта");
return false;
}
return true;
}
function download(format, section) {
if (!ensureDates()) return;
window.location.href = buildExportUrl(format, section);
}
function closeMenu() {
const menu = document.getElementById("reportsExportMenu");
const btn = document.getElementById("reportsExportBtn");
if (menu) menu.hidden = true;
if (btn) btn.setAttribute("aria-expanded", "false");
}
function toggleMenu() {
const menu = document.getElementById("reportsExportMenu");
const btn = document.getElementById("reportsExportBtn");
if (!menu || !btn) return;
const open = menu.hidden;
menu.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function init() {
const btn = document.getElementById("reportsExportBtn");
const menu = document.getElementById("reportsExportMenu");
if (!btn || !menu || btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.addEventListener("click", (e) => {
e.stopPropagation();
toggleMenu();
});
menu.querySelectorAll("[data-reports-export-format]").forEach((item) => {
item.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
download(
item.getAttribute("data-reports-export-format") || "xlsx",
item.getAttribute("data-reports-export-section") || "all"
);
closeMenu();
});
});
menu.addEventListener("mousedown", (e) => e.stopPropagation());
menu.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener("click", () => closeMenu());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu();
});
}
global.WespReportsExport = { init, buildExportUrl, download, appendFilterContext };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,282 @@
/**
* Кастомные выпадашки фильтров /reports (без нативного select macOS).
* Скрытый <select> сохраняется для совместимости с существующим кодом.
* data-multiselect чекбоксы (ферма, кормораздатчик, рейс).
*/
(function (global) {
const instances = new Map();
function closeAll(exceptMenu) {
instances.forEach(({ menu }) => {
if (menu && menu !== exceptMenu) menu.hidden = true;
});
}
function getMultiSelected(wrap) {
try {
const raw = wrap?.dataset?.selectedValues || "[]";
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch (_e) {
return [];
}
}
function setMultiSelected(wrap, values) {
if (!wrap) return;
wrap.dataset.selectedValues = JSON.stringify(values || []);
}
function countSelectableOptions(select) {
return Array.from(select.options).filter((opt) => opt.value).length;
}
const MULTI_COUNT_LABELS = {
"farm-select": "Фермы",
"dispenser-select": "Кормораздатчики",
"recipe-select": "Рейсы",
};
function formatMultiLabel(select, selected) {
const allLabel =
select.querySelector('option[value=""]')?.textContent?.trim() || "—";
const total = countSelectableOptions(select);
const count = selected.length;
if (!count || (total > 0 && count >= total)) return allLabel;
if (count === 1) return selected[0];
const entity = MULTI_COUNT_LABELS[select.id];
if (entity) return `${entity} (${count})`;
return `Выбрано: ${count}`;
}
function updateTriggerLabel(select, trigger, wrap) {
const textEl = trigger.querySelector(".reports-filter-dropdown__label");
let label;
if (select.dataset.multiselect === "true" && wrap) {
label = formatMultiLabel(select, getMultiSelected(wrap));
} else {
const opt = select.selectedOptions?.[0];
label = opt?.textContent?.trim() || "—";
}
if (textEl) textEl.textContent = label;
trigger.setAttribute(
"aria-label",
select.getAttribute("aria-label") || label
);
}
function dispatchSelectChange(select) {
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function createCheckboxRow(labelText, checked, onChange) {
const row = document.createElement("label");
row.className = "reports-filter-dropdown__check";
const boxWrap = document.createElement("span");
boxWrap.className = "zt-checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.className = "zt-checkbox__input";
input.checked = checked;
const box = document.createElement("span");
box.className = "zt-checkbox__box";
box.setAttribute("aria-hidden", "true");
boxWrap.appendChild(input);
boxWrap.appendChild(box);
const text = document.createElement("span");
text.className = "reports-filter-dropdown__check-label";
text.textContent = labelText;
input.addEventListener("change", (e) => {
e.stopPropagation();
onChange(input);
});
row.appendChild(boxWrap);
row.appendChild(text);
return { row, input };
}
function buildSingleMenu(select, menu, trigger, wrap) {
menu.innerHTML = "";
Array.from(select.options).forEach((opt) => {
const item = document.createElement("button");
item.type = "button";
item.className = "reports-filter-dropdown__item";
item.setAttribute("role", "option");
item.dataset.value = opt.value;
item.textContent = opt.textContent;
if (opt.value === select.value) {
item.classList.add("reports-filter-dropdown__item--active");
item.setAttribute("aria-selected", "true");
}
item.addEventListener("click", (e) => {
e.stopPropagation();
select.value = opt.value;
updateTriggerLabel(select, trigger, wrap);
buildSingleMenu(select, menu, trigger, wrap);
menu.hidden = true;
trigger.setAttribute("aria-expanded", "false");
dispatchSelectChange(select);
});
menu.appendChild(item);
});
updateTriggerLabel(select, trigger, wrap);
}
function buildMultiMenu(select, menu, trigger, wrap) {
menu.innerHTML = "";
const selected = getMultiSelected(wrap);
const allLabel =
select.querySelector('option[value=""]')?.textContent?.trim() || "—";
const { row: allRow, input: allInput } = createCheckboxRow(
allLabel,
selected.length === 0,
(input) => {
if (input.checked) {
setMultiSelected(wrap, []);
select.value = "";
}
buildMultiMenu(select, menu, trigger, wrap);
dispatchSelectChange(select);
}
);
menu.appendChild(allRow);
Array.from(select.options).forEach((opt) => {
if (!opt.value) return;
const { row } = createCheckboxRow(opt.textContent, selected.includes(opt.value), (input) => {
let next = getMultiSelected(wrap);
if (input.checked) {
if (!next.includes(opt.value)) next = next.concat(opt.value);
} else {
next = next.filter((v) => v !== opt.value);
}
setMultiSelected(wrap, next);
select.value = "";
buildMultiMenu(select, menu, trigger, wrap);
dispatchSelectChange(select);
});
menu.appendChild(row);
});
updateTriggerLabel(select, trigger, wrap);
}
function buildMenu(select, menu, trigger, wrap) {
if (select.dataset.multiselect === "true") {
buildMultiMenu(select, menu, trigger, wrap);
} else {
buildSingleMenu(select, menu, trigger, wrap);
}
}
function bindSelect(selectId) {
const select = document.getElementById(selectId);
if (!select || select.dataset.dropdownBound === "1") {
return instances.get(selectId);
}
select.dataset.dropdownBound = "1";
const wrap = select.closest(".reports-filter-select");
if (!wrap) return null;
if (select.dataset.multiselect === "true" && !wrap.dataset.selectedValues) {
wrap.dataset.selectedValues = "[]";
}
wrap.querySelector(".reports-filter-select__chev")?.remove();
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className =
"reports-filter-dropdown__trigger reports-filter-bar__control";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
trigger.innerHTML =
'<span class="reports-filter-dropdown__label"></span>' +
'<i class="fas fa-chevron-down reports-filter-dropdown__chev" aria-hidden="true"></i>';
const menu = document.createElement("div");
menu.className = "reports-filter-dropdown__menu";
menu.hidden = true;
menu.setAttribute("role", "listbox");
select.classList.add("reports-filter-select__native");
wrap.insertBefore(trigger, select);
wrap.insertBefore(menu, select);
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
closeAll(willOpen ? menu : null);
menu.hidden = !willOpen;
trigger.setAttribute("aria-expanded", willOpen ? "true" : "false");
});
menu.addEventListener("mousedown", (e) => e.stopPropagation());
menu.addEventListener("click", (e) => e.stopPropagation());
const observer = new MutationObserver(() => {
buildMenu(select, menu, trigger, wrap);
});
observer.observe(select, { childList: true, subtree: true, attributes: true });
buildMenu(select, menu, trigger, wrap);
const api = {
select,
wrap,
trigger,
menu,
rebuild: () => buildMenu(select, menu, trigger, wrap),
observer,
getMultiSelected: () => getMultiSelected(wrap),
setMultiSelected: (values) => {
setMultiSelected(wrap, values);
select.value = "";
buildMenu(select, menu, trigger, wrap);
},
};
instances.set(selectId, api);
return api;
}
function init() {
bindSelect("farm-select");
bindSelect("dispenser-select");
bindSelect("recipe-select");
document.addEventListener("click", () => closeAll());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeAll();
});
}
function refreshAll() {
instances.forEach((api) => api.rebuild?.());
}
function getMultiSelectValues(selectId) {
return instances.get(selectId)?.getMultiSelected?.() || [];
}
function setMultiSelectValues(selectId, values) {
instances.get(selectId)?.setMultiSelected?.(values);
}
global.WespReportsFilterDropdown = {
init,
refreshAll,
bindSelect,
getMultiSelectValues,
setMultiSelectValues,
};
})(typeof window !== "undefined" ? window : globalThis);

Some files were not shown because too many files have changed in this diff Show More