Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>http://localhost:5173/</loc></url>
<url><loc>http://localhost:5173/pages/about</loc></url>
<url><loc>http://localhost:5173/pages/privacy</loc></url>
<url><loc>http://localhost:5173/pages/terms</loc></url>
</urlset>
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
File diff suppressed because one or more lines are too long
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
@@ -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
@@ -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);
}
@@ -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;
}
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
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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,95 @@
/**
* K-hub «Мультисервер»: manual conflict resolution (orchestrator vs farm hub).
*/
(function (global) {
const API = "/api/v1/sync/conflicts";
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderList(root, items, enterpriseId) {
if (!items.length) {
root.innerHTML = '<p class="text-muted small">Нет конфликтов синхронизации.</p>';
return;
}
root.innerHTML =
'<table class="table table-sm zt-multiserver-table"><thead><tr>' +
"<th>Таблица</th><th>Запись</th><th>Ферма</th><th></th></tr></thead><tbody>" +
items
.map(
(c) =>
`<tr data-conflict-id="${escapeHtml(c.id)}">` +
`<td>${escapeHtml(c.table_name)}</td>` +
`<td>${escapeHtml(c.record_id)}</td>` +
`<td>${escapeHtml(c.farm_hub_id || "—")}</td>` +
`<td><button type="button" class="btn btn-sm btn-outline-primary" data-action="open-conflict">Открыть</button></td>` +
"</tr>",
)
.join("") +
"</tbody></table>";
root.querySelectorAll("[data-action=open-conflict]").forEach((btn) => {
btn.addEventListener("click", () => {
const row = btn.closest("tr");
const id = row && row.getAttribute("data-conflict-id");
if (id) openDetail(root, id, enterpriseId);
});
});
}
function openDetail(root, conflictId, enterpriseId) {
fetch(`${API}/${conflictId}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((detail) => {
root.innerHTML =
`<div class="zt-multiserver-detail">` +
`<h4>${escapeHtml(detail.table_name)} · ${escapeHtml(detail.record_id)}</h4>` +
`<div class="row"><div class="col-md-6"><h5>Оркестр</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.orchestrator_snapshot, null, 2))}</pre></div>` +
`<div class="col-md-6"><h5>Сервер</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.hub_snapshot, null, 2))}</pre></div></div>` +
`<div class="zt-multiserver-actions">` +
`<button type="button" class="btn btn-primary btn-sm" data-resolution="keep_orchestrator">Принять с оркестра</button> ` +
`<button type="button" class="btn btn-secondary btn-sm" data-resolution="keep_hub">Принять с сервера</button> ` +
`<button type="button" class="btn btn-link btn-sm" data-action="back-list">Назад</button>` +
`</div></div>`;
root.querySelector("[data-action=back-list]")?.addEventListener("click", () => load(root, enterpriseId));
root.querySelectorAll("[data-resolution]").forEach((btn) => {
btn.addEventListener("click", () => {
const resolution = btn.getAttribute("data-resolution");
fetch(`${API}/${conflictId}/resolve?enterprise_id=${encodeURIComponent(enterpriseId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution }),
}).then(() => load(root, enterpriseId));
});
});
});
}
function load(root, enterpriseId) {
root.innerHTML = '<p class="text-muted small">Загрузка конфликтов…</p>';
fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => renderList(root, items || [], enterpriseId))
.catch(() => {
root.innerHTML = '<p class="text-danger small">Не удалось загрузить конфликты.</p>';
});
}
global.WespMultiserverPanel = {
mount(rootEl, enterpriseId) {
if (!rootEl) return;
load(rootEl, enterpriseId || "");
},
pendingCount(enterpriseId) {
return fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => (Array.isArray(items) ? items.length : 0))
.catch(() => 0);
},
};
})(window);
@@ -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,
};
}
@@ -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,
};
}
@@ -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("month");
}
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);
@@ -0,0 +1,183 @@
/**
* Общие фильтры /reports даты, ферма, кормораздатчик, рейс.
*/
(function (global) {
function parseLocalDate(iso) {
if (!iso) return null;
const p = String(iso).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 getDateParams() {
global.WespReportsDateRange?.commit?.();
const from = document.getElementById("date-from")?.value || "";
const to = document.getElementById("date-to")?.value || "";
return { date_from: from, date_to: to };
}
function getMultiValues(selectId) {
const fromDropdown =
global.WespReportsFilterDropdown?.getMultiSelectValues?.(selectId);
if (Array.isArray(fromDropdown)) return fromDropdown.filter(Boolean);
const value = (document.getElementById(selectId)?.value || "").trim();
return value ? [value] : [];
}
function getSelectedFarmNames() {
const wrap = document.getElementById("reportsFarmFilterWrap");
if (wrap?.hidden) return [];
return getMultiValues("farm-select");
}
/** @deprecated используйте getSelectedFarmNames */
function getSelectedFarm() {
const names = getSelectedFarmNames();
return names.length === 1 ? names[0] : "";
}
function getSelectedDispenserNames() {
return getMultiValues("dispenser-select");
}
function getEffectiveDispenserNames() {
const selected = getSelectedDispenserNames();
if (selected.length) return selected;
const farms = getSelectedFarmNames();
if (!farms.length) return [];
const names = new Set();
farms.forEach((farm) => {
(global.reportsDispensersByFarm?.[farm] || []).forEach((name) => names.add(name));
});
return Array.from(names);
}
function getDispenserName() {
const names = getSelectedDispenserNames();
return names.length === 1 ? names[0] : "";
}
function getDispenserIdsForNames(names) {
const map = global.reportsDispenserIdByName || {};
return (names || []).map((name) => map[name]).filter(Boolean);
}
function getDispenserIds() {
return getDispenserIdsForNames(getEffectiveDispenserNames());
}
function getDispenserId() {
const ids = getDispenserIds();
return ids.length === 1 ? ids[0] : "";
}
function getRecipeIdsForDispensers(names) {
const map = global.reportsRecipesByDispenserName || {};
const ids = new Set();
(names || []).forEach((name) => {
(map[name] || []).forEach((recipe) => {
if (recipe?.id) ids.add(String(recipe.id));
});
});
return Array.from(ids);
}
function getSelectedRecipeNames() {
return getMultiValues("recipe-select");
}
function getRecipeName() {
const names = getSelectedRecipeNames();
return names.length === 1 ? names[0] : "";
}
function getRecipeIds() {
const names = getSelectedRecipeNames();
if (!names.length) return [];
const select = document.getElementById("recipe-select");
if (!select) return [];
const ids = [];
names.forEach((name) => {
const opt = Array.from(select.options).find((o) => o.value === name);
if (opt?.dataset?.recipeId) ids.push(String(opt.dataset.recipeId));
});
return ids;
}
function getRecipeId() {
const ids = getRecipeIds();
return ids.length === 1 ? ids[0] : "";
}
function buildAnalyticsParams() {
const params = new URLSearchParams(getDateParams());
const recipeIds = getRecipeIds();
if (recipeIds.length === 1) {
params.set("recipe_id", recipeIds[0]);
return params;
}
if (recipeIds.length > 1) {
params.set("recipe_ids", recipeIds.join(","));
return params;
}
const dispenserNames = getEffectiveDispenserNames();
if (dispenserNames.length) {
const fromDispensers = getRecipeIdsForDispensers(dispenserNames);
if (fromDispensers.length) params.set("recipe_ids", fromDispensers.join(","));
}
return params;
}
function buildAlertsParams() {
const params = new URLSearchParams(getDateParams());
const dispenserIds = getDispenserIds();
if (dispenserIds.length === 1) {
params.set("dispenser_id", dispenserIds[0]);
} else if (dispenserIds.length > 1) {
params.set("dispenser_id", dispenserIds.join(","));
}
return params;
}
function clearMultiSelect(selectId) {
global.WespReportsFilterDropdown?.setMultiSelectValues?.(selectId, []);
const select = document.getElementById(selectId);
if (select) select.value = "";
}
function clearDispenserSelection() {
clearMultiSelect("dispenser-select");
}
function clearFarmSelection() {
clearMultiSelect("farm-select");
}
function clearRecipeSelection() {
clearMultiSelect("recipe-select");
}
global.WespReportsFilters = {
parseLocalDate,
getDateParams,
getSelectedFarmNames,
getSelectedFarm,
getSelectedDispenserNames,
getEffectiveDispenserNames,
getDispenserName,
getDispenserId,
getDispenserIds,
getRecipeIdsForDispensers,
getSelectedRecipeNames,
getRecipeName,
getRecipeId,
getRecipeIds,
buildAnalyticsParams,
buildAlertsParams,
clearDispenserSelection,
clearFarmSelection,
clearRecipeSelection,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,883 @@
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js";
import { createSetupOrchestrator } from "/static/js/setup-orchestrator.js";
import { SetupTouchKeyboard } from "/static/js/setup-touch-keyboard.js";
import {
DEFAULT_PASSWORD_MIN_LEN,
validateNewUserCredentials,
} from "/static/js/wesp-user-credentials.js";
const API = {
status: "/api/setup/status",
deviceRole: "/api/setup/device-role",
network: "/api/setup/network",
sync: "/api/setup/sync",
syncProgress: "/api/setup/sync/progress",
users: "/api/setup/users",
hardware: "/api/setup/hardware/check",
kioskPrepare: "/api/setup/kiosk/prepare",
kioskSetupAvailable: "/api/kiosk/setup-available",
complete: "/api/setup/complete",
kioskAccessLink: "/api/kiosk/access-link",
kioskStatus: "/api/kiosk/status",
};
const THEME_STORAGE_KEY = "theme";
const STEP_LABELS = {
theme: "Тема",
welcome: "Начало",
device: "Устройство",
network: "Сеть",
sync: "Сервер",
users: "Пользователи",
hardware: "Весы",
kiosk: "Терминал",
bootstrap: "Синхронизация",
done: "Готово",
};
function passwordMinLen(statusData) {
const n = Number(statusData?.user_rules?.password_min_len);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PASSWORD_MIN_LEN;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = globalThis.WespUserMessages?.messageFromResponseBody?.(
data,
"Не удалось выполнить запрос"
) || "Не удалось выполнить запрос";
throw new Error(msg);
}
return data;
}
function setupUserError(message, fallback) {
return globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) || fallback || "Не удалось выполнить операцию.";
}
async function canIssueKioskAccessLink() {
try {
const data = await fetchJson(API.kioskSetupAvailable);
return !!data.can_issue_access_link;
} catch {
return false;
}
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function field(label, input) {
const wrap = el("div", "wesp-setup__field");
wrap.appendChild(el("label", "wesp-setup__label", label));
wrap.appendChild(input);
return wrap;
}
function fieldWithSuffix(label, input, suffixText) {
const wrap = el("div", "wesp-setup__field");
wrap.appendChild(el("label", "wesp-setup__label", label));
const group = el("div", "wesp-setup__input-group");
group.appendChild(input);
group.appendChild(el("span", "wesp-setup__input-suffix", suffixText));
wrap.appendChild(group);
return wrap;
}
function normalizeHostnameLabel(value) {
return String(value || "").trim().toLowerCase().replace(/\.local$/i, "");
}
function formatSyncServerDisplay(serverUrl) {
const raw = String(serverUrl || "").trim().replace(/\/+$/, "");
if (raw) return raw;
return "http://komton_srv_1.local";
}
function buildSyncServerUrl(raw) {
const value = String(raw || "").trim().replace(/\/+$/, "");
if (!value) return "";
if (/^https?:\/\//i.test(value)) return value;
if (/^\d+\.\d+\.\d+\.\d+(:\d+)?$/i.test(value)) return `http://${value}`;
const host = normalizeHostnameLabel(value);
return host ? `http://${host}.local` : "";
}
function readSetupTheme() {
try {
const t = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
return t === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
function applySetupTheme(theme) {
const next = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
/* ignore */
}
}
function stepIndicatorLabel(step) {
return STEP_LABELS[step] || step || "";
}
function textInput(id, placeholder, type = "text") {
const input = document.createElement("input");
input.className = "wesp-setup__input";
input.id = id;
input.type = type;
input.placeholder = placeholder;
return input;
}
function postSetupLandingPath(statusData) {
const role = statusData?.setup?.device_role;
const scaleHw = Boolean(statusData?.hardware?.scale_hardware_platform);
if (role === "client" && scaleHw) return "/scales";
return "/login";
}
export async function mountSetupWizard(root) {
const orchestrator = createSetupOrchestrator();
let statusData = null;
let bootstrapTimer = 0;
let activeTouchKeyboard = null;
let introStarted = false;
const intro = el("div", "wesp-setup__intro");
intro.setAttribute("aria-hidden", "false");
root.appendChild(intro);
const panel = el("div", "wesp-setup__panel wesp-setup__panel--hidden");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-live", "polite");
root.appendChild(panel);
const statusPromise = loadStatusEarly();
async function loadStatusEarly() {
statusData = await fetchJson(API.status);
if (statusData.setup?.setup_completed) {
window.location.href = postSetupLandingPath(statusData);
return statusData;
}
if (statusData.setup?.device_role === "client") {
orchestrator.setDeviceRole("client");
} else {
orchestrator.setDeviceRole("server");
}
return statusData;
}
async function playIntroAnimation() {
if (introStarted) return;
introStarted = true;
await mountWespLogoLoader(intro, {
loop: false,
embedded: true,
transparentBackground: true,
knockoutBackground: true,
darkTheme: readSetupTheme() === "dark",
hideChrome: true,
onComplete: () => revealWelcome(),
});
}
async function revealWelcome() {
intro.classList.add("wesp-setup__intro--done");
intro.setAttribute("aria-hidden", "true");
await statusPromise;
if (statusData?.setup?.setup_completed) return;
panel.classList.remove("wesp-setup__panel--hidden");
renderTheme();
window.setTimeout(() => {
if (intro.isConnected) intro.remove();
}, 480);
}
playIntroAnimation();
function setPanelBusy(busy, label = "Сохранение…") {
let overlay = panel.querySelector(".wesp-setup__busy");
if (busy) {
if (!overlay) {
overlay = el("div", "wesp-setup__busy");
overlay.appendChild(el("span", "wesp-setup__spinner"));
overlay.appendChild(el("span", "wesp-setup__busy-label", label));
panel.appendChild(overlay);
} else {
overlay.querySelector(".wesp-setup__busy-label").textContent = label;
overlay.hidden = false;
}
panel.classList.add("wesp-setup__panel--busy");
panel.setAttribute("aria-busy", "true");
return;
}
overlay?.remove();
panel.classList.remove("wesp-setup__panel--busy");
panel.removeAttribute("aria-busy");
}
async function withStepLoading(label, fn) {
setPanelBusy(true, label);
try {
return await fn();
} finally {
setPanelBusy(false);
}
}
function renderShell(title, lead) {
activeTouchKeyboard?.destroy();
activeTouchKeyboard = null;
panel.replaceChildren();
panel.classList.remove("wesp-setup__panel--busy");
panel.removeAttribute("aria-busy");
const header = el("div", "wesp-setup__header");
header.appendChild(
el("div", "wesp-setup__step-indicator", `Шаг · ${stepIndicatorLabel(orchestrator.currentStep)}`),
);
header.appendChild(el("h1", "wesp-setup__title", title));
if (lead) header.appendChild(el("p", "wesp-setup__lead", lead));
panel.appendChild(header);
const content = el("div", "wesp-setup__content");
panel.appendChild(content);
return content;
}
function populateActionRow(row, ...buttons) {
row.replaceChildren();
let primaryIndex = -1;
buttons.forEach((b, i) => {
if (b.classList.contains("wesp-setup__btn--primary")) primaryIndex = i;
});
buttons.forEach((b, i) => {
if (!b.classList.contains("wesp-setup__btn--primary")) {
b.classList.add("wesp-setup__btn--ghost");
}
if (i === primaryIndex && primaryIndex > 0) {
row.appendChild(el("div", "wesp-setup__actions-fill"));
}
row.appendChild(b);
});
}
function actions(...buttons) {
const footer = el("div", "wesp-setup__footer");
const row = el("div", "wesp-setup__actions");
populateActionRow(row, ...buttons);
footer.appendChild(row);
panel.appendChild(footer);
return row;
}
function msg(text, kind, target) {
const m = el("p", `wesp-setup__msg${kind ? ` wesp-setup__msg--${kind}` : ""}`, text || "");
(target || panel.querySelector(".wesp-setup__content") || panel).appendChild(m);
return m;
}
function btn(label, className, onClick) {
const b = el("button", `wesp-setup__btn${className ? ` ${className}` : ""}`, label);
b.type = "button";
b.addEventListener("click", onClick);
return b;
}
async function loadStatus() {
statusData = await fetchJson(API.status);
if (statusData.setup?.setup_completed) {
window.location.href = postSetupLandingPath(statusData);
return statusData;
}
if (statusData.setup?.device_role === "client") {
orchestrator.setDeviceRole("client");
} else {
orchestrator.setDeviceRole("server");
}
return statusData;
}
function shouldShowBootstrap() {
return !!statusData?.setup?.sync_server_connected;
}
function goAfterKiosk() {
if (shouldShowBootstrap()) renderBootstrap();
else goComplete();
}
async function goComplete() {
orchestrator.setStep("done");
const data = await withStepLoading("Завершение…", () =>
fetchJson(API.complete, { method: "POST" }),
);
const fallbackRedirect = postSetupLandingPath(statusData);
const redirect = data.redirect || fallbackRedirect;
const opensScales = redirect === "/scales";
renderShell(
"Готово",
opensScales
? "Система настроена. Сейчас откроется экран весов."
: "Система настроена. Сейчас откроется страница входа.",
);
msg(data.message, "ok");
actions(btn("Открыть", "wesp-setup__btn--primary", () => {
window.location.href = redirect;
}));
window.setTimeout(() => {
window.location.href = redirect;
}, 2200);
}
function renderTheme() {
orchestrator.setStep("theme");
const content = renderShell(
"Оформление",
"Выберите тему. Её можно сменить позже в меню терминала.",
);
let selected = readSetupTheme();
applySetupTheme(selected);
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards wesp-setup__theme-grid");
const options = [
["light", "Светлая", "Светлый фон и контрастные элементы."],
["dark", "Тёмная", "Удобнее при слабом освещении."],
];
const buttons = [];
options.forEach(([value, title, hint]) => {
const b = el("button", `wesp-setup__role-btn wesp-setup__role-card wesp-setup__theme-card wesp-setup__theme-card--${value}`, "");
b.type = "button";
b.innerHTML = `<strong>${title}</strong><span>${hint}</span>`;
if (selected === value) b.classList.add("is-selected");
b.addEventListener("click", () => {
selected = value;
applySetupTheme(selected);
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
});
buttons.push(b);
grid.appendChild(b);
});
content.appendChild(grid);
actions(btn("Далее", "wesp-setup__btn--primary", () => renderWelcome()));
}
function renderWelcome() {
orchestrator.setStep("welcome");
renderShell(
"Добро пожаловать",
"Мастер поможет выбрать роль устройства и базовые параметры.",
);
const health = statusData?.health;
if (health?.ok) {
msg("Система готова к настройке.", "ok");
} else {
msg("Проверка health… при ошибках миграций обратитесь к установщику.", "error");
}
actions(btn("Начать", "wesp-setup__btn--primary", () => renderDevice()));
}
function renderDevice() {
orchestrator.setStep("device");
const content = renderShell("Тип устройства", "Где будет работать это устройство?");
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards");
const roles = [
["server", "Главный сервер"],
["client", "Весовой терминал"],
];
let selected = orchestrator.deviceRole === "client" ? "client" : "server";
const buttons = [];
roles.forEach(([value, title]) => {
const b = el("button", "wesp-setup__role-btn wesp-setup__role-card", "");
b.type = "button";
b.innerHTML = `<strong>${title}</strong>`;
if (selected === value) b.classList.add("is-selected");
b.addEventListener("click", () => {
selected = value;
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
});
buttons.push(b);
grid.appendChild(b);
});
content.appendChild(grid);
const statusEl = msg("");
actions(
btn("Назад", "", () => renderWelcome()),
btn("Далее", "wesp-setup__btn--primary", async () => {
try {
const data = await withStepLoading("Сохранение…", () =>
fetchJson(API.deviceRole, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_role: selected }),
}),
);
if (data?.connection && statusData) {
statusData.connection = data.connection;
}
orchestrator.setDeviceRole(selected);
if (selected === "server") renderNetwork();
else renderSync();
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
);
}
function renderNetwork() {
orchestrator.setStep("network");
const content = renderShell(
"Сеть",
"Как этот сервер будет называться в вашей Wi‑Fi сети.",
);
const net = statusData?.network || {};
const hostDefault = normalizeHostnameLabel(
net.local_hostname || net.default_local_hostname || "komton_srv_1",
);
const hostLocked = !!net.local_hostname_locked_by_env;
const hostInput = textInput("setupHost", hostDefault);
hostInput.placeholder = "komton_srv_1";
hostInput.disabled = hostLocked;
hostInput.autocomplete = "off";
hostInput.spellcheck = false;
hostInput.addEventListener("input", () => {
const cleaned = normalizeHostnameLabel(hostInput.value);
if (hostInput.value !== cleaned) hostInput.value = cleaned;
});
content.appendChild(fieldWithSuffix("Имя сервера", hostInput, ".local"));
content.appendChild(
el(
"p",
"wesp-setup__hint",
"Латиница и цифры, без пробелов. Суффикс .local добавится автоматически.",
),
);
if (net.detected?.lan_ip) {
content.appendChild(el("p", "wesp-setup__hint", `IP в сети: ${net.detected.lan_ip}`));
}
const statusEl = msg("");
actions(
btn("Назад", "", () => renderDevice()),
btn("Далее", "wesp-setup__btn--primary", async () => {
try {
const body = { local_hostname: normalizeHostnameLabel(hostInput.value) };
if (!net.mdns_enabled_locked_by_env) {
body.mdns_enabled = true;
}
await withStepLoading("Сохранение…", async () => {
await fetchJson(API.network, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await loadStatus();
});
renderUsers();
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
);
}
function renderUsers() {
orchestrator.setStep("users");
const content = renderShell(
"Учётные записи",
"Создайте учётную запись зоотехника для работы с рецептами.",
);
const zootechUsers = statusData?.zootech_users || [];
if (zootechUsers.length) {
content.appendChild(
el(
"p",
"wesp-setup__info",
`Уже есть: ${zootechUsers.join(", ")}. Тот же логин — обновит пароль, новый — добавит ещё одного зоотехника.`,
),
);
}
const zootechLogin = textInput("zootechLogin", zootechUsers[0] || "zootech");
const zootechPass = textInput("zootechPass", "Пароль", "password");
const zootechConfirm = textInput("zootechConfirm", "Повтор пароля", "password");
content.appendChild(field("Логин", zootechLogin));
content.appendChild(field("Пароль", zootechPass));
content.appendChild(field("Подтверждение", zootechConfirm));
const statusEl = msg("");
actions(
btn("Назад", "", () => renderNetwork()),
btn("Далее", "wesp-setup__btn--primary", async () => {
const validationError = validateNewUserCredentials({
login: zootechLogin.value,
password: zootechPass.value,
confirmPassword: zootechConfirm.value,
passwordMinLen: passwordMinLen(statusData),
label: "Зоотехник",
});
if (validationError) {
statusEl.textContent = validationError;
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
return;
}
try {
const data = await withStepLoading("Проверка…", () =>
fetchJson(API.users, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zootech: {
login: zootechLogin.value.trim(),
password: zootechPass.value,
confirm: zootechConfirm.value,
},
}),
}),
);
await loadStatus();
statusEl.textContent = data.message || "Сохранено.";
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
window.setTimeout(() => goComplete(), 350);
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
);
}
function renderHardware() {
orchestrator.setStep("hardware");
const content = renderShell(
"Весы",
"Проверьте подключение весов или выполните калибровку.",
);
const platform = statusData?.hardware || {};
if (platform.scale_hardware_platform) {
content.appendChild(
el(
"p",
"wesp-setup__hint",
"Платформа ARM — можно подключить весы HX711 к Raspberry Pi.",
),
);
} else {
content.appendChild(
el(
"p",
"wesp-setup__info wesp-setup__info--warn",
platform.message || "Весы не найдены",
),
);
}
const statusEl = msg("");
actions(
btn("Назад", "", () => renderSync()),
btn("Проверить весы", "", async () => {
if (!platform.scale_hardware_platform) {
statusEl.textContent = platform.message || "Весы не найдены";
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
return;
}
try {
const data = await withStepLoading("Проверка весов…", () =>
fetchJson(API.hardware, { method: "POST" }),
);
statusEl.textContent = data.ok
? `OK · вес ${data.hardware?.current_weight_kg ?? "—"} кг`
: "HX711 не отвечает — проверьте подключение или включите симуляцию в админке.";
statusEl.className = `wesp-setup__msg ${data.ok ? "wesp-setup__msg--ok" : "wesp-setup__msg--error"}`;
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
btn("Калибровка", "", () => {
if (window.WespKioskCalibration?.openModal) {
window.WespKioskCalibration.openModal({ zIndex: 10050, theme: readSetupTheme() });
return;
}
window.open("/calibration?embed=1", "_blank", "noopener,noreferrer");
}),
btn("Далее", "wesp-setup__btn--primary", () => renderKiosk()),
);
}
async function renderKiosk() {
orchestrator.setStep("kiosk");
const localSetup = await canIssueKioskAccessLink();
if (!localSetup) {
const content = renderShell(
"Настройка терминала",
"Этот шаг выполняется на самом терминале.",
);
content.appendChild(el("p", "wesp-setup__info", "Настройка производится на терминале."));
actions(
btn("Назад", "", () => renderHardware()),
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
);
return;
}
const content = renderShell(
"Настройка терминала",
"Привязка планшета тракториста по QR-коду.",
);
content.appendChild(
el(
"p",
"wesp-setup__info",
"Дополнительные планшеты можно привязать позже через меню ☰ → Привязать терминал.",
),
);
const qrWrap = el("div", "wesp-setup__qr-wrap");
const qr = el("img", "wesp-setup__qr");
qr.alt = "QR Start URL";
const urlEl = el("p", "wesp-setup__url");
qrWrap.appendChild(qr);
content.appendChild(qrWrap);
content.appendChild(urlEl);
const statusEl = msg("");
try {
await withStepLoading("Подготовка QR…", async () => {
await fetchJson(API.kioskPrepare, { method: "POST" });
const data = await fetchJson(API.kioskAccessLink);
qr.src = data.qr_image_url || "";
urlEl.textContent = data.start_url || "";
});
statusEl.textContent = "Вставьте ссылку в Fully Kiosk → Start URL.";
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
actions(
btn("Назад", "", () => renderHardware()),
btn("Проверить статус", "", async () => {
try {
const st = await withStepLoading("Проверка…", () => fetchJson(API.kioskStatus));
statusEl.textContent = st.paired ? "Терминал привязан." : "Ожидание привязки…";
statusEl.className = `wesp-setup__msg ${st.paired ? "wesp-setup__msg--ok" : ""}`;
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
btn("Пропустить", "", () => goAfterKiosk()),
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
);
}
function renderSync() {
orchestrator.setStep("sync");
const content = renderShell(
"Подключение к серверу",
"Укажите адрес главного сервера и имя этого терминала.",
);
const conn = statusData?.connection || {};
const serverLocked = !!conn.server_url_locked_by_env;
const serverInput = textInput(
"syncServer",
serverLocked ? conn.server_url || conn.env_server_url || "" : formatSyncServerDisplay(conn.server_url),
);
serverInput.placeholder = "komton_srv_1.local";
serverInput.disabled = serverLocked;
content.appendChild(field("Адрес сервера", serverInput));
if (serverLocked) {
content.appendChild(
el(
"p",
"wesp-setup__hint",
"Адрес задан в настройках системы (WESP_SYNC_SERVER_URL).",
),
);
} else {
content.appendChild(
el(
"p",
"wesp-setup__hint",
"Имя сервера (komton_srv_1.local) или IP, например http://192.168.0.10.",
),
);
}
const nameDefault =
conn.client_name ||
normalizeHostnameLabel(statusData?.network?.local_hostname || "") ||
statusData?.network?.detected?.os_hostname ||
"vesy_1";
const nameInput = textInput("syncName", nameDefault);
nameInput.placeholder = "vesy_1";
content.appendChild(field("Название терминала", nameInput));
const statusEl = msg("");
let actionsRow = null;
async function submitSync(offline) {
const serverUrl = buildSyncServerUrl(serverInput.value);
if (!serverUrl && !serverLocked) {
statusEl.textContent = "Укажите адрес сервера.";
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
return;
}
const body = {
client_name: nameInput.value.trim(),
offline: !!offline,
};
if (!serverLocked) {
body.server_url = serverUrl;
}
await withStepLoading(offline ? "Сохранение…" : "Подключение…", () =>
fetchJson(API.sync, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
await loadStatus();
renderHardware();
}
function showSyncFailureChoices(errorMessage) {
statusEl.textContent = setupUserError(errorMessage, "Не удалось подключиться к серверу.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
if (!actionsRow) return;
populateActionRow(
actionsRow,
btn("Исправить адрес", "", () => {
serverInput.focus();
statusEl.textContent = "";
statusEl.className = "wesp-setup__msg";
populateActionRow(
actionsRow,
btn("Назад", "", () => renderDevice()),
btn("Далее", "wesp-setup__btn--primary", onNext),
);
}),
btn("Повторить", "wesp-setup__btn--primary", onNext),
btn("Продолжить без сервера", "", async () => {
try {
await submitSync(true);
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}),
);
}
async function onNext() {
try {
await submitSync(false);
} catch (e) {
showSyncFailureChoices(e.message);
}
}
actionsRow = actions(
btn("Назад", "", () => renderDevice()),
btn("Далее", "wesp-setup__btn--primary", onNext),
);
activeTouchKeyboard = new SetupTouchKeyboard(panel);
if (!serverInput.disabled) {
activeTouchKeyboard.attach(serverInput, { layout: "url", maxLength: 256 });
}
activeTouchKeyboard.attach(nameInput, { layout: "hostname", maxLength: 200 });
nameInput.addEventListener("input", () => {
const cleaned = normalizeHostnameLabel(nameInput.value);
if (nameInput.value !== cleaned) nameInput.value = cleaned;
});
window.setTimeout(() => {
if (!serverInput.disabled) serverInput.focus();
else nameInput.focus();
}, 80);
}
function renderBootstrap() {
if (!shouldShowBootstrap()) {
goComplete();
return;
}
orchestrator.setStep("bootstrap");
const content = renderShell(
"Первая синхронизация",
"Дождитесь загрузки данных с сервера.",
);
const bar = el("div", "wesp-setup__progress-bar");
const fill = el("div", "wesp-setup__progress-fill");
bar.appendChild(fill);
content.appendChild(bar);
const statusEl = msg("Ожидание синхронизации…");
function stopPoll() {
if (bootstrapTimer) window.clearInterval(bootstrapTimer);
bootstrapTimer = 0;
}
async function poll() {
try {
const data = await fetchJson(API.syncProgress);
const p = data.initial_sync_progress || {};
const percent = p.percent != null ? Number(p.percent) : data.first_bootstrap_done ? 100 : 10;
fill.style.width = `${Math.min(100, percent)}%`;
orchestrator.setBootstrapProgress(percent / 100);
if (data.first_bootstrap_done) {
stopPoll();
statusEl.textContent = "Синхронизация завершена.";
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
} else if (p.active) {
statusEl.textContent = `Синхронизация… ${Math.round(percent)}%`;
}
} catch (e) {
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
}
}
poll();
bootstrapTimer = window.setInterval(poll, 2000);
actions(
btn("Назад", "", () => {
stopPoll();
renderKiosk();
}),
btn("Далее", "wesp-setup__btn--primary", () => {
stopPoll();
goComplete();
}),
);
}
await statusPromise;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
const root = document.getElementById("wespSetupRoot");
if (root) mountSetupWizard(root);
});
} else {
const root = document.getElementById("wespSetupRoot");
if (root) mountSetupWizard(root);
}
@@ -0,0 +1,81 @@
/**
* Прогноз запаса на /feed_consumption.
*/
(function (global) {
const COPY = () => global.WespAnalyticsCopy || {};
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
let forecastByComponentId = {};
async function fetchForecast() {
try {
const resp = await fetch("/api/analytics/stock-forecast");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function renderBanner(data) {
const el = document.getElementById("stockForecastBanner");
if (!el) return;
const text = (data && data.alertBanner) || "";
if (!text) {
el.hidden = true;
el.textContent = "";
return;
}
el.hidden = false;
el.innerHTML =
`<strong>${escapeHtml(COPY().stock?.bannerTitle || "Обратите внимание:")}</strong> ` +
escapeHtml(text);
}
function applyToCards() {
document.querySelectorAll(".component-card[data-forecast-key], .component-card[data-component-id]").forEach((card) => {
const key = card.getAttribute("data-forecast-key") || card.getAttribute("data-component-id");
const fc = forecastByComponentId[key];
if (!fc) return;
const adjEl = card.querySelector("[data-stock-days-adjusted]");
const explEl = card.querySelector("[data-stock-explanation]");
const label = fc.daysLeftAdjustedLabel || fc.days_left_adjusted_label;
if (adjEl && label) {
adjEl.textContent = label;
const daysVal = fc.daysLeftAdjusted != null ? fc.daysLeftAdjusted : fc.days_left_adjusted;
adjEl.closest(".info-item")?.classList.toggle(
"info-item--warn",
daysVal != null && daysVal <= 2
);
}
if (explEl && fc.explanation) {
explEl.textContent = fc.explanation;
explEl.hidden = false;
}
});
}
async function refresh() {
const data = await fetchForecast();
forecastByComponentId = {};
(data?.items || []).forEach((item) => {
const key = item.forecast_key || item.component_id || item.name_key;
if (key) forecastByComponentId[key] = item;
});
renderBanner(data);
applyToCards();
return data;
}
function getForecast(componentId) {
return forecastByComponentId[componentId] || null;
}
global.WespStockForecast = { refresh, getForecast, applyToCards };
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,100 @@
/**
* Reads site JWT from localStorage and seeds __WESP_ORCH__ before zootech pages load.
*/
(function (global) {
const TOKEN_KEY = "compton.access_token";
const ENTERPRISE_KEY = "compton.enterprise_id";
const USER_KEY = "compton.user";
const DEBUG_ENDPOINT = "http://127.0.0.1:7898/ingest/d209dffb-c63e-477b-8b08-a57520eaee9b";
const SESSION = "785e22";
function debugLog(hypothesisId, message, data) {
// #region agent log
fetch(DEBUG_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": SESSION },
body: JSON.stringify({
sessionId: SESSION,
runId: "login-fix",
hypothesisId,
location: "wesp-orchestrator-auth-bridge.js",
message,
data,
timestamp: Date.now(),
}),
}).catch(function () {});
// #endregion
}
function readJson(key) {
try {
const raw = global.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (_err) {
return null;
}
}
const token = global.localStorage.getItem(TOKEN_KEY) || "";
const enterpriseId =
global.localStorage.getItem(ENTERPRISE_KEY) ||
new URLSearchParams(global.location.search).get("enterprise_id") ||
"";
const user = readJson(USER_KEY);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
apiBase: "/api/v1",
accessToken: token,
enterpriseId: enterpriseId,
userEmail: user && user.email ? user.email : "",
});
debugLog("H1", "auth bridge init", {
hasToken: Boolean(token),
hasEnterprise: Boolean(enterpriseId),
path: global.location.pathname,
});
if (token && !enterpriseId) {
fetch("/api/v1/enterprise/enterprises", {
headers: { Authorization: "Bearer " + token },
})
.then(function (resp) {
return resp.ok ? resp.json() : [];
})
.then(function (rows) {
const entId = Array.isArray(rows) && rows[0] && rows[0].id ? String(rows[0].id) : "";
if (!entId) {
debugLog("H1", "enterprise resolve failed", { count: Array.isArray(rows) ? rows.length : -1 });
return;
}
global.localStorage.setItem(ENTERPRISE_KEY, entId);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, { enterpriseId: entId });
debugLog("H1", "enterprise resolved async", { enterpriseId: entId });
})
.catch(function () {
debugLog("H1", "enterprise resolve error", {});
});
}
global.ComptonWespAuth = {
TOKEN_KEY,
ENTERPRISE_KEY,
USER_KEY,
persistSession(accessToken, userPayload, entId) {
if (accessToken) global.localStorage.setItem(TOKEN_KEY, accessToken);
if (userPayload) global.localStorage.setItem(USER_KEY, JSON.stringify(userPayload));
if (entId) global.localStorage.setItem(ENTERPRISE_KEY, entId);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
accessToken: accessToken || "",
enterpriseId: entId || "",
userEmail: userPayload && userPayload.email ? userPayload.email : "",
});
},
clearSession() {
global.localStorage.removeItem(TOKEN_KEY);
global.localStorage.removeItem(ENTERPRISE_KEY);
global.localStorage.removeItem(USER_KEY);
},
};
})(window);
@@ -0,0 +1,101 @@
/**
* Orchestrator boot adapter: JWT, enterprise scope, API base for copied WESP static UI.
*/
(function (global) {
const cfg = global.__WESP_ORCH__ || {};
const API_BASE = cfg.apiBase || "/api/v1";
const AUTH_MAP = {
"/api/auth/change_credentials": "/api/v1/users/me/password",
};
const WESP_API_PREFIXES = [
"/api/admin/",
"/api/notifications",
"/api/updates/",
"/api/sync/",
"/api/feed_dispensers",
"/api/components",
"/api/recipes",
"/api/reports",
"/api/analytics/",
"/api/feed-quality/",
"/api/periods",
"/api/auth/",
];
function readToken() {
return (global.__WESP_ORCH__ && global.__WESP_ORCH__.accessToken) || "";
}
function readEnterpriseId() {
return (global.__WESP_ORCH__ && global.__WESP_ORCH__.enterpriseId) || "";
}
function isExternalUrl(url) {
return /^https?:\/\//i.test(url);
}
function patchUrl(url) {
if (typeof url !== "string" || isExternalUrl(url)) return url;
for (const [from, to] of Object.entries(AUTH_MAP)) {
if (url === from || url.startsWith(from + "?")) {
return to + url.slice(from.length);
}
}
if (url.startsWith("/api/v1/sync/")) {
return url;
}
for (let i = 0; i < WESP_API_PREFIXES.length; i++) {
if (url.startsWith(WESP_API_PREFIXES[i])) {
return url;
}
}
if (url.startsWith("/api/") && !url.startsWith("/api/v1/")) {
return API_BASE + url.slice(4);
}
return url;
}
function withAuth(url, init) {
if (isExternalUrl(url)) {
return init || {};
}
const token = readToken();
const enterpriseId = readEnterpriseId();
const headers = new Headers((init && init.headers) || {});
if (token && !headers.has("Authorization")) {
headers.set("Authorization", "Bearer " + token);
}
if (enterpriseId && !headers.has("X-Enterprise-Id")) {
headers.set("X-Enterprise-Id", enterpriseId);
}
return Object.assign({}, init || {}, { headers });
}
function appendEnterpriseQuery(url) {
if (isExternalUrl(url)) return url;
const enterpriseId = readEnterpriseId();
if (!enterpriseId || url.indexOf("enterprise_id=") >= 0) {
return url;
}
const sep = url.indexOf("?") >= 0 ? "&" : "?";
return url + sep + "enterprise_id=" + encodeURIComponent(enterpriseId);
}
const nativeFetch = global.fetch.bind(global);
global.fetch = function (input, init) {
if (typeof input === "string") {
const patched = appendEnterpriseQuery(patchUrl(input));
return nativeFetch(patched, withAuth(patched, init));
}
return nativeFetch(input, withAuth("", init));
};
global.WespOrchestratorBoot = {
apiBase: API_BASE,
get enterpriseId() {
return readEnterpriseId();
},
};
})(window);
@@ -0,0 +1,85 @@
/**
* Orchestrator login adapter for WESP login.html persist JWT + enterprise after /api/auth/login.
*/
(function (global) {
const DEBUG_ENDPOINT = "http://127.0.0.1:7898/ingest/d209dffb-c63e-477b-8b08-a57520eaee9b";
const SESSION = "785e22";
function debugLog(hypothesisId, message, data) {
// #region agent log
fetch(DEBUG_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": SESSION },
body: JSON.stringify({
sessionId: SESSION,
runId: "login-fix",
hypothesisId,
location: "wesp-orchestrator-login.js",
message,
data,
timestamp: Date.now(),
}),
}).catch(function () {});
// #endregion
}
async function resolveEnterpriseId(accessToken) {
try {
const resp = await fetch("/api/v1/enterprise/enterprises", {
headers: { Authorization: "Bearer " + accessToken },
});
if (!resp.ok) return "";
const rows = await resp.json();
return Array.isArray(rows) && rows[0] && rows[0].id ? String(rows[0].id) : "";
} catch (_err) {
return "";
}
}
async function persistOrchestratorSession(payload) {
const token = payload && payload.access_token ? String(payload.access_token) : "";
const user = payload && payload.user ? payload.user : null;
if (!token || !user) {
debugLog("H2", "login missing token/user", { hasToken: Boolean(token), hasUser: Boolean(user) });
return false;
}
const enterpriseId = await resolveEnterpriseId(token);
if (global.ComptonWespAuth && typeof global.ComptonWespAuth.persistSession === "function") {
global.ComptonWespAuth.persistSession(token, user, enterpriseId);
}
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
accessToken: token,
enterpriseId: enterpriseId,
userEmail: user.email || "",
});
debugLog("H1", "session persisted", { hasEnterprise: Boolean(enterpriseId), email: user.email || "" });
return Boolean(enterpriseId);
}
const nativeFetch = global.fetch.bind(global);
global.fetch = function orchestratorLoginFetch(input, init) {
const url = typeof input === "string" ? input : input && input.url ? input.url : "";
if (url.indexOf("/api/auth/login") === -1) {
return nativeFetch(input, init);
}
debugLog("H3", "wesp login request", { url: url.split("?")[0] });
return nativeFetch(input, init).then(async function (response) {
const cloned = response.clone();
let data = {};
try {
data = await cloned.json();
} catch (_err) {
debugLog("H3", "login response not json", { status: response.status });
return response;
}
if (response.ok && data.status === "success") {
await persistOrchestratorSession(data);
} else {
debugLog("H3", "login failed", { status: response.status, bodyStatus: data.status });
}
return response;
});
};
debugLog("H4", "orchestrator login adapter ready", { path: global.location.pathname });
})(window);
@@ -0,0 +1,202 @@
/**
* Bootstrap shell-модалки (.wesp-shell-modal) единая анимация открытия/закрытия.
*/
(function (global) {
const WIRED = "data-wesp-shell-modal-wired";
const ALLOW_HIDE = "data-wesp-shell-allow-hide";
const MODAL_MS = 240;
function prefersReducedMotion() {
return global.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
function getDialog(el) {
return el?.querySelector(".modal-dialog--shell");
}
function wireShellModal(el) {
if (!el || el.getAttribute(WIRED)) return;
el.setAttribute(WIRED, "1");
el.addEventListener("show.bs.modal", function () {
el.classList.remove("wesp-shell-closing");
if (prefersReducedMotion()) {
el.classList.add("wesp-shell-visible");
return;
}
el.classList.remove("wesp-shell-visible");
requestAnimationFrame(function () {
requestAnimationFrame(function () {
el.classList.add("wesp-shell-visible");
});
});
});
el.addEventListener("shown.bs.modal", function () {
el.removeAttribute("aria-hidden");
});
el.addEventListener("hide.bs.modal", function (e) {
if (el.getAttribute(ALLOW_HIDE) === "1") {
el.removeAttribute(ALLOW_HIDE);
el.classList.remove("wesp-shell-visible", "wesp-shell-closing");
return;
}
if (prefersReducedMotion()) {
el.classList.remove("wesp-shell-visible");
return;
}
e.preventDefault();
el.classList.add("wesp-shell-closing");
el.classList.remove("wesp-shell-visible");
const dialog = getDialog(el);
let finished = false;
const finish = function () {
if (finished) return;
finished = true;
el.setAttribute(ALLOW_HIDE, "1");
const instance = global.bootstrap?.Modal?.getInstance(el);
instance?.hide();
};
if (dialog) {
dialog.addEventListener(
"transitionend",
function (ev) {
if (ev.target === dialog) finish();
},
{ once: true }
);
}
global.setTimeout(finish, MODAL_MS + 50);
});
el.addEventListener("hidden.bs.modal", function () {
el.classList.remove("wesp-shell-closing", "wesp-shell-visible");
el.setAttribute("aria-hidden", "true");
});
}
function wireAllShellModals() {
document.querySelectorAll(".modal.wesp-shell-modal").forEach(wireShellModal);
}
function show(modalId) {
const el = document.getElementById(modalId);
if (!el) return null;
wireShellModal(el);
const instance = global.bootstrap?.Modal?.getOrCreateInstance(el, {
backdrop: true,
keyboard: true,
focus: true,
});
instance?.show();
return instance;
}
function hide(modalId) {
const el = document.getElementById(modalId);
if (!el) return;
const instance = global.bootstrap?.Modal?.getInstance(el);
if (instance) instance.hide();
}
function whenPageEnterDone(run) {
const html = document.documentElement;
let ran = false;
let observer = null;
let fallbackTimer = 0;
const once = function () {
if (ran) return;
ran = true;
if (observer) {
observer.disconnect();
observer = null;
}
if (fallbackTimer) {
global.clearTimeout(fallbackTimer);
fallbackTimer = 0;
}
requestAnimationFrame(run);
};
if (html.classList.contains("wesp-page-enter-done")) {
once();
return;
}
if (
!html.classList.contains("wesp-page-enter-pending") &&
!html.classList.contains("wesp-page-enter-ready")
) {
once();
return;
}
observer = new MutationObserver(function () {
if (!html.classList.contains("wesp-page-enter-done")) return;
once();
});
observer.observe(html, { attributes: true, attributeFilter: ["class"] });
fallbackTimer = global.setTimeout(function () {
if (!html.classList.contains("wesp-page-enter-done")) return;
once();
}, 1200);
}
function animateGridCards(gridSelector, options) {
if (prefersReducedMotion()) return;
const force = Boolean(options && options.force);
const run = function () {
const grid = document.querySelector(gridSelector);
if (!grid) return;
const cards = grid.querySelectorAll(".dispenser-card, .component-card, .report-card");
if (
!force &&
cards.length > 0 &&
Array.from(cards).every(function (card) {
return card.classList.contains("wesp-grid-enter-item");
})
) {
return;
}
cards.forEach(function (card, i) {
card.classList.remove("wesp-grid-enter-item");
card.style.removeProperty("--wesp-grid-i");
void card.offsetWidth;
card.style.setProperty("--wesp-grid-i", String(Math.min(i, 12)));
card.classList.add("wesp-grid-enter-item");
});
};
whenPageEnterDone(run);
}
function revealContent(el) {
if (!el || prefersReducedMotion()) return;
el.classList.remove("wesp-content-reveal");
void el.offsetWidth;
el.classList.add("wesp-content-reveal");
}
global.WespZootechModals = {
show,
hide,
wireAllShellModals,
animateGridCards,
revealContent,
prefersReducedMotion,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wireAllShellModals);
} else {
wireAllShellModals();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,17 @@
/** Локальные SVG-иконки зоотех-UI (офлайн, без CDN). Исходники: /static/icons/*.svg */
export const ROUTE_ICON_PATH =
"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";
export const ROUTE_ICON_VIEWBOX = "5 2 14 20";
export const COW_ICON_PATH =
"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";
export function routeIconSvg() {
return `<svg viewBox="${ROUTE_ICON_VIEWBOX}" fill="currentColor" aria-hidden="true"><path d="${ROUTE_ICON_PATH}"/></svg>`;
}
export function cowIconSvg() {
return `<svg viewBox="0 0 640 512" fill="currentColor" aria-hidden="true" focusable="false"><path d="${COW_ICON_PATH}"/></svg>`;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Общий навбар операторских страниц.
* <div id="wespZootechNavMount" data-active-page="recipes"></div>
*/
(function () {
/* Один файл — одинаковый слот в светлой и тёмной теме (logo.png другой aspect ratio). */
const LOGO_LIGHT = "/static/logo2.png";
const LOGO_DARK = "/static/logo2.png";
function brandLogoHtml() {
return `<img src="${LOGO_LIGHT}" alt="Комптон" class="logo logo--light-theme">
<img src="${LOGO_DARK}" alt="" class="logo logo--dark-theme" aria-hidden="true">`;
}
const PAGES = [
{ id: "recipes", href: "/recipes", icon: "fa-utensils", label: "Рецепты" },
{ id: "lab", href: "/lab", icon: "fa-flask", label: "Lab" },
{ id: "feed_dispensers", href: "/feed_dispensers", icon: "fa-server", label: "Оборудование" },
{ id: "components", href: "/components", icon: "fa-box", label: "Компоненты" },
{ id: "reports", href: "/reports", icon: "fa-chart-bar", label: "Отчеты" },
{ id: "feed_consumption", href: "/feed_consumption", icon: "fa-chart-line", label: "Учёт кормов" },
];
function navItem(page, active, mobile) {
const cls = mobile
? `mobile-menu-item${active ? " active" : ""}`
: `menu-btn${active ? " active" : ""}`;
const tag = mobile ? "a" : "a";
if (page.id === "settings") {
return `<${tag} href="javascript:void(0)" data-action="open-settings" onclick="typeof openSettings==='function'&&openSettings(event)" class="${cls}">
<i class="fas fa-cog"></i><span>Настройки</span></${tag}>`;
}
return `<${tag} href="${page.href}" class="${cls}">
<i class="fas ${page.icon}"></i><span>${page.label}</span></${tag}>`;
}
function pagesForNav(canLab) {
if (canLab) return PAGES;
return PAGES.filter((p) => p.id !== "lab");
}
function renderNav(activeId, pages) {
const desktop = pages.map((p) => navItem(p, p.id === activeId, false)).join("");
const mobile = pages.map((p) => navItem(p, p.id === activeId, true)).join("");
const settingsDesktop = navItem({ id: "settings" }, false, false);
const settingsMobile = navItem({ id: "settings" }, false, true);
return `<nav class="mobile-navbar">
<div class="container">
<div class="nav-content">
<div class="nav-brand">
<a href="/recipes" class="nav-logo-link" aria-label="Рецепты">
${brandLogoHtml()}
</a>
</div>
<div class="hamburger-menu" id="hamburgerMenu" role="button" aria-label="Меню" tabindex="0">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</div>
<div class="desktop-menu">${desktop}${settingsDesktop}</div>
<div class="user-section">
<span id="userInfo" class="user-info"><i class="fas fa-user"></i> <span id="userLogin"></span></span>
<a href="#" data-action="logout" onclick="typeof logout==='function'&&logout();return false;" class="logout-btn">
<i class="fas fa-sign-out-alt"></i><span>Выход</span>
</a>
</div>
</div>
</div>
<div class="mobile-menu-overlay" id="mobileMenuOverlay">
<div class="mobile-menu">
<div class="mobile-menu-header">
<div class="mobile-brand">
<a href="/recipes" class="nav-logo-link" aria-label="Рецепты">
${brandLogoHtml()}
</a>
</div>
<div class="close-menu" id="closeMenu" role="button" aria-label="Закрыть" tabindex="0">
<i class="fas fa-times"></i>
</div>
</div>
<div class="mobile-menu-items">${mobile}${settingsMobile}</div>
<div class="mobile-menu-footer">
<div class="mobile-user-info" id="mobileUserInfo"></div>
<a href="#" data-action="logout" onclick="typeof logout==='function'&&logout();return false;" class="mobile-logout-btn">
<i class="fas fa-sign-out-alt"></i><span>Выход</span>
</a>
</div>
</div>
</div>
</nav>`;
}
function wireMobileMenu() {
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
if (!hamburgerMenu || !mobileMenuOverlay) return;
const open = () => {
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
};
const close = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
};
hamburgerMenu.addEventListener("click", open);
closeMenu?.addEventListener("click", close);
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) close();
});
}
async function fetchCanLab() {
try {
const response = await fetch("/api/auth/check");
const data = await response.json();
if (data.status === "success" && data.authenticated) {
return Boolean(data.can_lab);
}
} catch (error) {
console.error("Ошибка проверки доступа к Lab:", error);
}
return false;
}
async function mount() {
const mountEl = document.getElementById("wespZootechNavMount");
if (!mountEl) return;
const active = mountEl.getAttribute("data-active-page") || "";
const canLab = await fetchCanLab();
window.WESP_CAN_LAB = canLab;
mountEl.outerHTML = renderNav(active, pagesForNav(canLab));
wireMobileMenu();
window.WespPageEnter?.init?.();
window.WespLabAccess?.apply?.(canLab);
}
window.WespZootechNav = { remount: mount };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();
@@ -0,0 +1,835 @@
/**
* Hub «К»: уведомления и план на день. FAB «К», модалка, badge.
*/
(function (global) {
const MODAL_ID = "zootechNotificationCenterModal";
const FAB_CLASS = "wesp-zt-notify-fab";
const POLL_MS = 60000;
const SCROLL_LOAD_THRESHOLD = 80;
let fabEl = null;
let badgeEl = null;
let modalEl = null;
let listEl = null;
let detailEl = null;
let hubEl = null;
let planMountEl = null;
let multiserverMountEl = null;
let titleEl = null;
let readAllBtn = null;
let backBtn = null;
let normsBtn = null;
let pollTimer = null;
let itemsCache = [];
let currentView = "hub";
let dailyPlanPanel = null;
let toolbarEl = null;
let scrollRootEl = null;
let categoryFilter = "all";
let sortMode = "newest";
let unreadOnly = false;
let prevDate = null;
let loadingMore = false;
let listLoading = false;
let scrollWired = false;
const STORAGE_KEY = "wesp-notify-prefs";
const CATEGORY_LABELS = {
daily_plan: "План на день",
recipe: "Рейсы",
sync: "Синхронизация",
feed_quality: "Отклонения",
component: "Компоненты",
sklad: "Склад",
equipment: "Оборудование",
report: "Отчёты",
general: "Общее",
};
const CATEGORY_FILTERS = [
{ id: "all", label: "Все" },
{ id: "daily_plan", label: "План" },
{ id: "recipe", label: "Рейсы" },
{ id: "sync", label: "Синхр." },
{ id: "feed_quality", label: "Отклон." },
{ id: "component", label: "Компон." },
{ id: "sklad", label: "Склад" },
{ id: "equipment", label: "Оборуд." },
];
const KIND_ORDER = { error: 0, warning: 1, info: 2, success: 3 };
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function resolveNotificationHref(item) {
if (!item || !item.linkKind) return null;
if (item.linkKind === "component") return "/components";
if (item.linkKind === "sklad") return "/feed_consumption";
if (item.linkKind === "equipment") return "/feed_dispensers";
if (!item.linkId) return null;
const id = encodeURIComponent(item.linkId);
if (item.linkKind === "recipe") return `/recipes?recipe=${id}`;
if (item.linkKind === "report_loading") return `/reports?report=${id}`;
if (item.linkKind === "daily_plan") return `/recipes?dailyPlan=${id}`;
return null;
}
async function markReadQuiet(id) {
const item = itemsCache.find((x) => x.id === id);
if (!item || item.read) return;
try {
await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" });
item.read = true;
await refreshBadge();
} catch {
/* ignore */
}
}
async function openNotificationTarget(item) {
if (isDailyPlanItem(item)) {
await markReadQuiet(item.id);
openDailyPlan();
return;
}
const href = resolveNotificationHref(item);
if (!href) {
await showDetail(item.id);
return;
}
await markReadQuiet(item.id);
if (global.bootstrap?.Modal) {
const inst = global.bootstrap.Modal.getInstance(modalEl);
inst?.hide();
}
global.location.href = href;
}
function relativeTime(iso) {
if (!iso) return "";
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const min = Math.floor(diff / 60000);
if (min < 1) return "только что";
if (min < 60) return `${min} мин назад`;
const hrs = Math.floor(min / 60);
if (hrs < 24) return `${hrs} ч назад`;
const days = Math.floor(hrs / 24);
return `${days} дн назад`;
}
function loadPrefs() {
try {
const raw = global.sessionStorage?.getItem(STORAGE_KEY);
if (!raw) return;
const prefs = JSON.parse(raw);
if (prefs.categoryFilter) categoryFilter = prefs.categoryFilter;
if (prefs.sortMode) sortMode = prefs.sortMode;
if (typeof prefs.unreadOnly === "boolean") unreadOnly = prefs.unreadOnly;
} catch {
/* ignore */
}
}
function savePrefs() {
try {
global.sessionStorage?.setItem(
STORAGE_KEY,
JSON.stringify({ categoryFilter, sortMode, unreadOnly })
);
} catch {
/* ignore */
}
}
function itemTimestamp(item) {
return item?.createdAt ? new Date(item.createdAt).getTime() : 0;
}
function sortItems(items) {
const list = [...items];
if (sortMode === "oldest") {
return list.sort((a, b) => itemTimestamp(a) - itemTimestamp(b));
}
if (sortMode === "unread_first") {
return list.sort((a, b) => {
const ra = a.read ? 1 : 0;
const rb = b.read ? 1 : 0;
if (ra !== rb) return ra - rb;
return itemTimestamp(b) - itemTimestamp(a);
});
}
if (sortMode === "severity") {
return list.sort((a, b) => {
const ka = KIND_ORDER[a.kind] ?? 9;
const kb = KIND_ORDER[b.kind] ?? 9;
if (ka !== kb) return ka - kb;
return itemTimestamp(b) - itemTimestamp(a);
});
}
return list.sort((a, b) => itemTimestamp(b) - itemTimestamp(a));
}
function formatDayLabel(isoDate) {
if (!isoDate) return "Ранее";
const parts = String(isoDate).slice(0, 10).split("-");
if (parts.length !== 3) return isoDate;
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((startToday - startThat) / 86400000);
if (diffDays === 0) return "Сегодня";
if (diffDays === 1) return "Вчера";
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
function dayKeyFromItem(item) {
if (!item?.createdAt) return "";
const d = new Date(item.createdAt);
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 inferCategory(item) {
const raw = item?.category || "general";
if (raw === "daily_plan") return "daily_plan";
if (item?.linkKind === "daily_plan" || item?.page === "daily_plan") return "daily_plan";
const text = `${item?.title || ""} ${item?.detail || ""}`.toLowerCase();
if (
text.includes("из плана") ||
text.includes("в плане") ||
text.includes("замен") ||
text.includes("исключ") ||
text.includes("снова в план") ||
text.includes("план на день")
) {
return "daily_plan";
}
return raw;
}
function isDailyPlanItem(item) {
return inferCategory(item) === "daily_plan";
}
function categoryLabel(item) {
return CATEGORY_LABELS[inferCategory(item)] || CATEGORY_LABELS.general;
}
function buildListUrl(date) {
const params = new URLSearchParams({ sort: sortMode });
if (date) params.set("date", date);
if (categoryFilter && categoryFilter !== "all") params.set("category", categoryFilter);
if (unreadOnly) params.set("unread", "1");
return `/api/notifications?${params.toString()}`;
}
function ensureFab() {
if (fabEl && fabEl.isConnected) return fabEl;
fabEl = document.querySelector(`.${FAB_CLASS}`);
if (!fabEl) {
fabEl = document.createElement("button");
fabEl.type = "button";
fabEl.className = FAB_CLASS;
fabEl.setAttribute("data-action", "open-notification-center");
fabEl.setAttribute("aria-label", "Центр «К»");
fabEl.innerHTML =
'<span class="wesp-zt-notify-fab__glyph" aria-hidden="true">К</span>' +
'<span class="wesp-zt-notify-fab__badge" hidden aria-hidden="true"></span>';
document.body.appendChild(fabEl);
}
badgeEl = fabEl.querySelector(".wesp-zt-notify-fab__badge");
fabEl.addEventListener("click", openCenter);
return fabEl;
}
function categoryFilterButtonsHtml() {
return CATEGORY_FILTERS.map(
(f) =>
`<button type="button" class="zt-tab${f.id === categoryFilter ? " active" : ""}" ` +
`data-notify-category="${escapeHtml(f.id)}">${escapeHtml(f.label)}</button>`
).join("");
}
function modalTemplate() {
return (
`<div class="modal wesp-shell-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide">' +
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none zt-k-hub-modal">' +
'<div class="zt-k-hub-modal__header">' +
'<div class="zt-k-hub-modal__header-left">' +
'<button type="button" class="zt-k-hub-modal__back" data-action="k-hub-back" hidden>&larr; Назад</button>' +
'<h2 class="wesp-shell-bsmodal-title zt-k-hub-modal__title" data-k-hub-title>Комптон - хаб</h2>' +
"</div>" +
'<div class="zt-k-hub-modal__header-right">' +
'<button type="button" class="btn btn-sm btn-outline-primary zt-k-hub-plan-norms-btn" data-action="k-hub-open-component-norms" hidden title="Изменить СВ% компонентов только в плане на день — рецепты в справочнике не меняются">Коррекция СВ%</button>' +
'<button type="button" class="zt-notify-center__read-all" data-action="notify-read-all" hidden>Прочитать все</button>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
"</div></div>" +
'<div class="zt-k-hub-modal__body">' +
'<div class="zt-k-hub" data-k-hub-panel></div>' +
'<div class="zt-notify-center__notify" data-k-notify-panel hidden>' +
'<div class="zt-notify-toolbar" data-notify-toolbar>' +
'<div class="zt-notify-toolbar__filters zt-tabs" role="tablist" data-notify-categories>' +
categoryFilterButtonsHtml() +
"</div>" +
'<div class="zt-notify-toolbar__row">' +
'<label class="zt-notify-toolbar__unread">' +
'<input type="checkbox" data-notify-unread-only> Только непрочит.' +
"</label>" +
'<label class="zt-notify-toolbar__sort">' +
'<span class="visually-hidden">Сортировка</span>' +
'<select data-notify-sort aria-label="Сортировка">' +
'<option value="newest">Сначала новые</option>' +
'<option value="oldest">Сначала старые</option>' +
'<option value="unread_first">Непрочит. сверху</option>' +
'<option value="severity">По важности</option>' +
"</select></label></div></div>" +
'<div class="zt-notify-list" data-notify-list></div>' +
'<div class="zt-notify-load-more" data-notify-load-more hidden></div>' +
'<div class="zt-notify-detail" data-notify-detail hidden></div>' +
"</div>" +
'<div class="zt-k-hub-plan-mount" data-k-plan-panel hidden></div>' +
'<div class="zt-k-hub-multiserver-mount" data-k-multiserver-panel hidden></div>' +
"</div></div></div></div>"
);
}
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);
}
hubEl = modalEl.querySelector("[data-k-hub-panel]");
listEl = modalEl.querySelector("[data-notify-list]");
detailEl = modalEl.querySelector("[data-notify-detail]");
toolbarEl = modalEl.querySelector("[data-notify-toolbar]");
scrollRootEl = modalEl.querySelector(".zt-k-hub-modal__body");
planMountEl = modalEl.querySelector("[data-k-plan-panel]");
multiserverMountEl = modalEl.querySelector("[data-k-multiserver-panel]");
titleEl = modalEl.querySelector("[data-k-hub-title]");
readAllBtn = modalEl.querySelector("[data-action='notify-read-all']");
backBtn = modalEl.querySelector("[data-action='k-hub-back']");
normsBtn = modalEl.querySelector("[data-action='k-hub-open-component-norms']");
global.WespZootechModals?.wireAllShellModals?.();
readAllBtn?.addEventListener("click", readAll);
backBtn?.addEventListener("click", () => showHub());
normsBtn?.addEventListener("click", () => {
global.openDailyPlanComponentNormsModal?.();
});
wireNotifyToolbar();
wireScrollLoad();
hubEl?.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action");
if (action === "hub-open-notifications") openNotifications();
if (action === "hub-open-daily-plan") openDailyPlan();
if (action === "hub-open-multiserver") openMultiserver();
});
});
wireModalLifecycle();
renderHubCards();
return modalEl;
}
function syncToolbarUi() {
if (!toolbarEl) return;
toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => {
const cat = btn.getAttribute("data-notify-category") || "all";
btn.classList.toggle("active", cat === categoryFilter);
});
const sortSelect = toolbarEl.querySelector("[data-notify-sort]");
if (sortSelect) sortSelect.value = sortMode;
const unreadCb = toolbarEl.querySelector("[data-notify-unread-only]");
if (unreadCb) unreadCb.checked = unreadOnly;
}
function wireNotifyToolbar() {
if (!toolbarEl || toolbarEl.dataset.wired === "1") return;
toolbarEl.dataset.wired = "1";
loadPrefs();
syncToolbarUi();
toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => {
btn.addEventListener("click", () => {
categoryFilter = btn.getAttribute("data-notify-category") || "all";
savePrefs();
syncToolbarUi();
reloadNotifications();
});
});
toolbarEl.querySelector("[data-notify-sort]")?.addEventListener("change", (ev) => {
sortMode = ev.target.value || "newest";
savePrefs();
reloadNotifications();
});
toolbarEl.querySelector("[data-notify-unread-only]")?.addEventListener("change", (ev) => {
unreadOnly = Boolean(ev.target.checked);
savePrefs();
reloadNotifications();
});
}
function wireScrollLoad() {
if (scrollWired || !scrollRootEl) return;
scrollWired = true;
scrollRootEl.addEventListener("scroll", () => {
if (currentView !== "notifications" || !prevDate || loadingMore || listLoading) return;
const nearBottom =
scrollRootEl.scrollTop + scrollRootEl.clientHeight >=
scrollRootEl.scrollHeight - SCROLL_LOAD_THRESHOLD;
if (nearBottom) loadMoreNotifications();
});
}
function itemPreview(item) {
const detail = String(item?.detail || "").trim();
if (detail) {
const cut = detail.replace(/\.\s+\d{1,2}\s+\S+\s+\d{4}.*$/u, "").trim();
const text = cut || detail;
return text.length > 140 ? `${text.slice(0, 137)}` : text;
}
return item?.title || "";
}
function renderNotifyItem(item) {
const unread = !item.read ? " zt-notify-item--unread" : "";
const cat = categoryLabel(item);
const preview = itemPreview(item);
const showPreview = preview && preview !== item.title;
return (
`<button type="button" class="zt-notify-item${unread}" data-notify-id="${escapeHtml(item.id)}">` +
`<span class="zt-notify-item__dot zt-notify-item__dot--${escapeHtml(item.kind || "info")}"></span>` +
`<span class="zt-notify-item__main">` +
`<span class="zt-notify-item__title">${escapeHtml(item.title)}</span>` +
(showPreview
? `<span class="zt-notify-item__preview">${escapeHtml(preview)}</span>`
: "") +
`<span class="zt-notify-item__meta">` +
`<span class="zt-notify-item__category">${escapeHtml(cat)}</span>` +
`<span class="zt-notify-item__time">${escapeHtml(relativeTime(item.createdAt))}</span>` +
"</span></span></button>"
);
}
function renderLoadMoreState() {
const el = modalEl?.querySelector("[data-notify-load-more]");
if (!el) return;
if (loadingMore) {
el.hidden = false;
el.innerHTML =
'<div class="zt-notify-load-more__spinner"><div class="zt-spinner" role="status"></div>' +
'<span>Загрузка…</span></div>';
return;
}
if (prevDate) {
el.hidden = false;
el.innerHTML =
`<p class="zt-notify-load-more__hint">Прокрутите вниз — загрузятся уведомления за ${escapeHtml(formatDayLabel(prevDate))}</p>`;
return;
}
el.hidden = true;
el.innerHTML = "";
}
function renderHubCards() {
if (!hubEl) return;
hubEl.innerHTML =
'<div class="zt-k-hub__grid">' +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-notifications">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-bell"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">Уведомления</span>' +
'<span class="zt-k-hub-card__desc">План, рейсы, синхронизация и отклонения</span>' +
"</span></button>" +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-daily-plan">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-calendar-day"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">План на день</span>' +
'<span class="zt-k-hub-card__desc">Рейсы, ингредиенты и выгрузка по периодам</span>' +
"</span></button>" +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-multiserver">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-server"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">Мультисервер</span>' +
'<span class="zt-k-hub-card__desc">Конфликты синхронизации оркестр ↔ фермы</span>' +
"</span></button></div>";
hubEl.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action");
if (action === "hub-open-notifications") openNotifications();
if (action === "hub-open-daily-plan") openDailyPlan();
if (action === "hub-open-multiserver") openMultiserver();
});
});
}
function applyModalLayout(view) {
if (!modalEl) return;
const dialog = modalEl.querySelector(".modal-dialog");
const body = modalEl.querySelector(".zt-k-hub-modal__body");
modalEl.classList.remove("zt-k-hub--plan", "zt-k-hub--notify");
body?.classList.remove("zt-k-hub-modal__body--plan");
dialog?.classList.remove("modal-dialog--shell-plan", "modal-dialog--shell-notify");
if (view === "daily-plan") {
modalEl.classList.add("zt-k-hub--plan");
body?.classList.add("zt-k-hub-modal__body--plan");
dialog?.classList.add("modal-dialog--shell-plan");
} else if (view === "notifications") {
modalEl.classList.add("zt-k-hub--notify");
dialog?.classList.add("modal-dialog--shell-notify");
}
}
function updateHeader(view) {
currentView = view;
applyModalLayout(view);
if (!titleEl || !backBtn || !readAllBtn) return;
if (normsBtn) normsBtn.hidden = view !== "daily-plan";
if (view === "hub") {
titleEl.textContent = "Комптон - хаб";
backBtn.hidden = true;
readAllBtn.hidden = true;
} else if (view === "notifications") {
titleEl.textContent = "Уведомления";
backBtn.hidden = false;
readAllBtn.hidden = false;
} else if (view === "daily-plan") {
titleEl.textContent = "План на день";
backBtn.hidden = false;
readAllBtn.hidden = true;
} else if (view === "multiserver") {
titleEl.textContent = "Мультисервер";
backBtn.hidden = false;
readAllBtn.hidden = true;
}
}
function dismissPlanOverlays() {
modalEl?.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => el.remove());
}
function destroyDailyPlanPanel() {
dismissPlanOverlays();
global.closeDailyPlanComponentNormsModal?.();
dailyPlanPanel?.destroy?.();
dailyPlanPanel = null;
global.WespDailyPlanPanel?._setActivePanel?.(null);
}
function wireModalLifecycle() {
ensureModal();
if (modalEl.dataset.kHubLifecycleWired === "1") return;
modalEl.dataset.kHubLifecycleWired = "1";
modalEl.addEventListener("hide.bs.modal", dismissPlanOverlays);
modalEl.addEventListener("hidden.bs.modal", destroyDailyPlanPanel);
}
function showHub() {
ensureModal();
updateHeader("hub");
hubEl.hidden = false;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = true;
if (multiserverMountEl) multiserverMountEl.hidden = true;
destroyDailyPlanPanel();
listEl.hidden = false;
detailEl.hidden = true;
}
function setBadge(unreadCount) {
ensureFab();
if (!badgeEl) return;
const count = Number(unreadCount) || 0;
if (count <= 0) {
badgeEl.hidden = true;
badgeEl.textContent = "";
fabEl.classList.remove("has-unread");
return;
}
badgeEl.hidden = false;
badgeEl.textContent = count > 9 ? "9+" : String(count);
fabEl.classList.add("has-unread");
}
async function refreshBadge() {
try {
const resp = await fetch("/api/notifications?summary=1");
if (!resp.ok) return;
const data = await resp.json();
setBadge(data.unreadCount);
} catch {
/* ignore */
}
}
function mergeItems(existing, incoming) {
const map = new Map(existing.map((x) => [x.id, x]));
incoming.forEach((item) => map.set(item.id, item));
return Array.from(map.values());
}
function matchesCategoryFilter(item) {
if (!categoryFilter || categoryFilter === "all") return true;
return inferCategory(item) === categoryFilter;
}
function filterVisibleItems(items) {
let list = items.filter(matchesCategoryFilter);
if (unreadOnly) list = list.filter((x) => !x.read);
return list;
}
async function fetchDay(date) {
const resp = await fetch(buildListUrl(date));
if (!resp.ok) throw new Error("Не удалось загрузить уведомления");
return resp.json();
}
async function loadInitialNotifications() {
let date = null;
let found = [];
for (let step = 0; step < 31; step += 1) {
const data = await fetchDay(date);
setBadge(data.unreadCount);
if (data.items?.length) {
found = mergeItems(found, data.items);
prevDate = data.hasMore ? data.prevDate : null;
return found;
}
if (!data.hasMore || !data.prevDate) {
prevDate = null;
return found;
}
date = data.prevDate;
}
prevDate = null;
return found;
}
async function reloadNotifications() {
listLoading = true;
itemsCache = [];
prevDate = null;
detailEl.hidden = true;
listEl.hidden = false;
listEl.innerHTML =
'<div class="zt-notify-empty zt-notify-empty--loading">' +
'<div class="zt-spinner" role="status"></div><p>Загрузка…</p></div>';
renderLoadMoreState();
try {
itemsCache = await loadInitialNotifications();
renderList();
} catch {
itemsCache = [];
prevDate = null;
renderList();
} finally {
listLoading = false;
renderLoadMoreState();
}
}
async function loadMoreNotifications() {
if (!prevDate || loadingMore || listLoading) return;
loadingMore = true;
renderLoadMoreState();
const loadDate = prevDate;
try {
const data = await fetchDay(loadDate);
itemsCache = mergeItems(itemsCache, data.items || []);
prevDate = data.hasMore ? data.prevDate : null;
setBadge(data.unreadCount);
renderList();
} catch {
/* keep current list */
} finally {
loadingMore = false;
renderLoadMoreState();
}
}
function wireListClicks() {
listEl.querySelectorAll("[data-notify-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-notify-id");
if (id) showDetail(id);
});
});
}
function renderList() {
ensureModal();
wireNotifyToolbar();
syncToolbarUi();
detailEl.hidden = true;
listEl.hidden = false;
const sorted = sortItems(filterVisibleItems(itemsCache));
if (!sorted.length) {
const hint =
unreadOnly || categoryFilter !== "all"
? prevDate
? "За сегодня ничего нет — прокрутите вниз для более ранних"
: "Нет уведомлений по выбранному фильтру"
: "Нет уведомлений";
listEl.innerHTML =
`<div class="zt-notify-empty"><i class="fas fa-bell-slash"></i><p>${escapeHtml(hint)}</p></div>`;
renderLoadMoreState();
return;
}
let html = "";
let lastGroup = null;
sorted.forEach((item) => {
const group = dayKeyFromItem(item);
if (group !== lastGroup) {
html += `<div class="zt-notify-group">${escapeHtml(formatDayLabel(group))}</div>`;
lastGroup = group;
}
html += renderNotifyItem(item);
});
listEl.innerHTML = html;
wireListClicks();
renderLoadMoreState();
}
async function showDetail(id) {
const item = itemsCache.find((x) => x.id === id);
if (!item) return;
ensureModal();
listEl.hidden = true;
modalEl.querySelector("[data-notify-load-more]")?.setAttribute("hidden", "");
detailEl.hidden = false;
detailEl.innerHTML =
`<button type="button" class="zt-notify-detail__back" data-action="notify-back">&larr; Назад</button>` +
`<p class="zt-notify-detail__kind zt-notify-detail__kind--${escapeHtml(item.kind)}">${escapeHtml(item.title)}</p>` +
`<p class="zt-notify-detail__meta">${escapeHtml(categoryLabel(item))}</p>` +
`<p class="zt-notify-detail__text">${escapeHtml(item.detail)}</p>` +
`<p class="zt-notify-detail__when">${escapeHtml(relativeTime(item.createdAt))}</p>` +
(resolveNotificationHref(item) || isDailyPlanItem(item)
? `<button type="button" class="btn btn-primary btn-sm mt-3" data-action="notify-open-target">Открыть план</button>`
: "");
detailEl.querySelector("[data-action='notify-open-target']")?.addEventListener("click", () => {
openNotificationTarget(item);
});
detailEl.querySelector("[data-action='notify-back']")?.addEventListener("click", () => {
renderList();
});
if (!item.read) {
try {
await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" });
item.read = true;
await refreshBadge();
} catch {
/* ignore */
}
}
}
async function readAll() {
try {
await fetch("/api/notifications/read-all", { method: "PATCH" });
await reloadNotifications();
} catch {
/* ignore */
}
}
function showModal() {
if (global.WespZootechModals?.show) {
global.WespZootechModals.show(MODAL_ID);
} else if (global.bootstrap?.Modal) {
global.bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
async function openCenter() {
ensureModal();
showHub();
showModal();
}
async function openNotifications() {
ensureModal();
updateHeader("notifications");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = false;
planMountEl.hidden = true;
destroyDailyPlanPanel();
await reloadNotifications();
showModal();
}
function openDailyPlan() {
ensureModal();
updateHeader("daily-plan");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = false;
destroyDailyPlanPanel();
dailyPlanPanel = global.WespDailyPlanPanel?.createDailyPlanPanel?.();
if (dailyPlanPanel && planMountEl) {
dailyPlanPanel.mount(planMountEl);
global.WespDailyPlanPanel?._setActivePanel?.(dailyPlanPanel);
} else if (planMountEl) {
planMountEl.innerHTML =
'<div class="zt-k-hub-plan__empty"><p>Модуль плана на день не загружен.</p></div>';
}
showModal();
}
function openMultiserver() {
ensureModal();
updateHeader("multiserver");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = true;
multiserverMountEl.hidden = false;
destroyDailyPlanPanel();
const enterpriseId = global.__WESP_ORCH__?.enterpriseId || "";
if (global.WespMultiserverPanel?.mount) {
global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId);
} else if (multiserverMountEl) {
multiserverMountEl.innerHTML =
'<div class="zt-k-hub-plan__empty"><p>Модуль мультисервера не загружен.</p></div>';
}
showModal();
}
function init() {
if (!document.body.classList.contains("zootech-app-body")) return;
loadPrefs();
ensureFab();
ensureModal();
refreshBadge();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(refreshBadge, POLL_MS);
}
global.WespZootechNotificationCenter = {
init,
openCenter,
openNotifications,
openDailyPlan,
openMultiserver,
refreshBadge,
setBadge,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,148 @@
/**
* Человекочитаемые title/detail для центра уведомлений.
*/
(function (global) {
const MONTHS = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
];
function formatWhen(date) {
const d = date instanceof Date ? date : new Date();
const day = d.getDate();
const month = MONTHS[d.getMonth()];
const year = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year}, ${hh}:${mm}`;
}
function normalizeTitle(text) {
const t = String(text || "").trim();
if (/^Сохранено\b/i.test(t)) return "Сохранено";
return t || "Уведомление";
}
function detectCategory(page, text, record) {
if (record.category) return record.category;
if (page === "recipes") return "recipe";
if (page === "components") return "component";
if (page === "feed_consumption") return "sklad";
if (page === "feed_dispensers") return "equipment";
if (page === "reports") return "report";
if (record.category === "feed_quality") return "feed_quality";
if (record.category === "daily_plan") return "daily_plan";
if (page === "daily_plan") return "daily_plan";
const lower = text.toLowerCase();
if (
lower.includes("план на день") ||
lower.includes("из плана") ||
lower.includes("в плане") ||
lower.includes("замен")
) {
return "daily_plan";
}
if (lower.includes("рейс") || lower.includes("рецепт")) return "recipe";
if (lower.includes("компонент")) return "component";
if (lower.includes("склад") || lower.includes("остат")) return "sklad";
if (lower.includes("синхрон")) return "sync";
if (lower.includes("перегруз") || lower.includes("недогруз") || lower.includes("миксер") || lower.includes("смешиван")) return "feed_quality";
return "general";
}
function resolvePage(explicit) {
if (explicit) return explicit;
const path = (global.location && global.location.pathname) || "";
if (path.startsWith("/recipes")) return "recipes";
if (path.startsWith("/components")) return "components";
if (path.startsWith("/feed_dispensers")) return "feed_dispensers";
if (path.startsWith("/reports")) return "reports";
if (path.startsWith("/feed_consumption")) return "feed_consumption";
return "general";
}
function q(name) {
return name ? `«${name}»` : "";
}
function buildNotificationRecord(kind, plainText, opts) {
const options = opts && typeof opts === "object" ? opts : {};
const record = options.record && typeof options.record === "object" ? options.record : {};
const when = formatWhen();
const page = resolvePage(record.page || options.page);
const text = String(plainText || "").trim();
let title = record.title || normalizeTitle(text);
let detail = record.detail || "";
if (!detail) {
const recipe = record.recipeName || record.recipe;
const component = record.componentName || record.component;
const period = record.periodName || record.period;
if (/^Сохранено$/i.test(title) && recipe) {
detail = `Рейс ${q(recipe)} сохранён — ${when}`;
} else if (/рейс удал/i.test(text) && recipe && period) {
detail = `Рейс ${q(recipe)} удалён из периода ${q(period)}${when}`;
} else if (/рейс удал/i.test(text) && recipe) {
detail = `Рейс ${q(recipe)} удалён — ${when}`;
} else if (/рейс скопирован/i.test(text) && recipe) {
detail = `Рейс ${q(recipe)} скопирован — ${when}`;
} else if (/вставлен из буфера/i.test(text)) {
detail = `Рейс вставлен из буфера — ${when}`;
} else if (/перенес/i.test(text) && recipe && period) {
detail = `Рейс ${q(recipe)} перенесён в период ${q(period)}${when}`;
} else if (/компонент добавлен/i.test(text) && component) {
detail = `Компонент ${q(component)} добавлен — ${when}`;
} else if (/компонент обнов/i.test(text) && component) {
detail = `Компонент ${q(component)} обновлён — ${when}`;
} else if (/компонент удал/i.test(text) && component) {
detail = `Компонент ${q(component)} удалён — ${when}`;
} else if (/убрано со склада/i.test(text) && component) {
detail = `Компонент ${q(component)} убран со склада — ${when}`;
} else if (/на склад/i.test(text) && component) {
detail = `Компонент ${q(component)} добавлен на склад — ${when}`;
} else if (/настройки сохранены/i.test(text)) {
detail = `Настройки сохранены — ${when}`;
} else if (record.category === "feed_quality" || page === "reports" && /перегруз|недогруз|миксер|смешиван/i.test(text)) {
detail = record.detail || (text ? `${text}${when}` : `Отклонение по рейсу — ${when}`);
} else if (text) {
detail = `${text}${when}`;
} else {
detail = `Событие — ${when}`;
}
}
return {
title,
detail,
kind: kind || "info",
category: detectCategory(page, text, record),
page,
linkKind:
record.linkKind ||
(page === "components"
? "component"
: page === "feed_consumption"
? "sklad"
: page === "feed_dispensers"
? "equipment"
: null),
};
}
global.WespZootechNotificationMessages = {
formatWhen,
buildNotificationRecord,
resolvePage,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,285 @@
/**
* Toast-уведомления зоотех-страниц (нижний pill-toast, стиль Яндекс Музыки).
*/
(function (global) {
const HOST_ID = "wespZtToastHost";
const DEFAULT_DURATION_MS = 4000;
const EXIT_MS = 220;
const UM = () => global.WespUserMessages || {};
const userFacingMessage = (message, fallback) => {
const api = UM();
if (typeof api.userFacingMessage === "function") {
return api.userFacingMessage(message, fallback);
}
const text = message == null ? "" : String(message).trim();
return text || fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
};
let hostEl = null;
let activeToast = null;
let dismissTimer = null;
function ensureHost() {
if (hostEl && hostEl.isConnected) return hostEl;
hostEl = document.getElementById(HOST_ID);
if (!hostEl) {
hostEl = document.createElement("div");
hostEl.id = HOST_ID;
hostEl.className = "wesp-zt-toast-host";
hostEl.setAttribute("aria-live", "polite");
hostEl.setAttribute("aria-atomic", "true");
document.body.appendChild(hostEl);
}
return hostEl;
}
function clearDismissTimer() {
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
}
function normalizeType(type) {
if (type === "error" || type === "warning") return type;
return "success";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function normalizeDisplayMessage(message) {
if (message && typeof message === "object") {
if (Array.isArray(message.parts)) return message;
if (message.text != null) message = message.text;
}
const text = message == null ? "" : String(message).trim();
if (/^Сохранено\b/i.test(text)) {
return "Сохранено";
}
return text;
}
function renderMessageContent(message, opts) {
if (message && typeof message === "object") {
if (Array.isArray(message.parts)) {
return message.parts
.map((part) => {
const text = escapeHtml(part && part.text != null ? part.text : "");
return part && part.bold ? `<strong>${text}</strong>` : text;
})
.join("");
}
if (message.text != null) {
return escapeHtml(message.text);
}
}
const text = message == null ? "" : String(message);
if (opts && Array.isArray(opts.parts)) {
return opts.parts
.map((part) => {
const chunk = escapeHtml(part && part.text != null ? part.text : "");
return part && part.bold ? `<strong>${chunk}</strong>` : chunk;
})
.join("");
}
return escapeHtml(text);
}
function removeToast(toastEl, immediate) {
if (!toastEl || !toastEl.isConnected) return Promise.resolve();
clearDismissTimer();
if (immediate) {
toastEl.remove();
if (activeToast === toastEl) activeToast = null;
return Promise.resolve();
}
return new Promise((resolve) => {
toastEl.classList.add("is-exiting");
const onEnd = () => {
toastEl.removeEventListener("animationend", onEnd);
toastEl.remove();
if (activeToast === toastEl) activeToast = null;
resolve();
};
toastEl.addEventListener("animationend", onEnd);
setTimeout(onEnd, EXIT_MS + 40);
});
}
function dismissAll() {
clearDismissTimer();
if (!activeToast) return;
const toast = activeToast;
activeToast = null;
return removeToast(toast, false);
}
function plainTextFromMessage(message) {
const api = UM();
if (typeof api.plainText === "function") return api.plainText(message);
if (message && typeof message === "object") {
if (message instanceof Error) return "";
if (Array.isArray(message.parts)) {
return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join("");
}
if (message.text != null) return String(message.text);
}
return message == null ? "" : String(message);
}
function persistNotification(type, message, opts) {
if (opts && opts.skipRecord) return;
const plain = plainTextFromMessage(
message && typeof message === "object" && Array.isArray(message.parts)
? message
: normalizeDisplayMessage(message)
);
if (!plain.trim() && !(opts && opts.record)) return;
const builder = global.WespZootechNotificationMessages;
const payload = builder
? builder.buildNotificationRecord(type, plain, opts)
: {
title: plain.slice(0, 200) || "Уведомление",
detail: plain,
kind: type,
category: "general",
page: "general",
};
fetch("/api/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(() => {
global.WespZootechNotificationCenter?.refreshBadge?.();
})
.catch(() => {
/* fire-and-forget */
});
}
function show(type, message, opts) {
const normalizedType = normalizeType(type);
const duration =
opts && Number.isFinite(opts.duration) ? opts.duration : DEFAULT_DURATION_MS;
const displayMessage =
message && typeof message === "object" && Array.isArray(message.parts)
? message
: normalizeDisplayMessage(message);
const html = renderMessageContent(displayMessage, opts);
const plainText =
displayMessage && typeof displayMessage === "object"
? ""
: String(displayMessage || "");
const isMultiline = plainText.length > 42 || /\n/.test(plainText);
const run = async () => {
if (activeToast) {
await removeToast(activeToast, false);
}
const root = ensureHost();
const toast = document.createElement("div");
toast.className = `wesp-zt-toast wesp-zt-toast--${normalizedType}${
isMultiline ? " wesp-zt-toast--multiline" : ""
}`;
toast.setAttribute("role", "status");
const accent = document.createElement("span");
accent.className = "wesp-zt-toast__accent";
accent.setAttribute("aria-hidden", "true");
const messageEl = document.createElement("p");
messageEl.className = "wesp-zt-toast__message";
messageEl.innerHTML = html;
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "wesp-zt-toast__close";
closeBtn.setAttribute("aria-label", "Закрыть уведомление");
closeBtn.textContent = "×";
closeBtn.addEventListener("click", () => {
dismissAll();
});
toast.append(accent, messageEl, closeBtn);
root.appendChild(toast);
activeToast = toast;
if (duration > 0) {
dismissTimer = setTimeout(() => {
dismissAll();
}, duration);
}
};
persistNotification(normalizedType, displayMessage, opts);
run();
}
function success(message, opts) {
show("success", message, opts);
}
function error(message, opts) {
const safe = userFacingMessage(message, opts && opts.fallback);
show("error", safe, opts);
}
function warning(message, opts) {
const safe = userFacingMessage(message, opts && opts.fallback);
show("warning", safe, opts);
}
function open(payload) {
const data = payload && typeof payload === "object" ? payload : {};
show(data.type || "success", data.message, data);
}
function createAdapter() {
return {
success,
error,
warning,
open,
dismissAll,
};
}
global.WespZootechNotify = {
success,
error,
warning,
open,
dismissAll,
createAdapter,
userFacingMessage,
messageFromResponseBody: (body, fallback) => {
const api = UM();
if (typeof api.messageFromResponseBody === "function") {
return api.messageFromResponseBody(body, fallback);
}
return userFacingMessage(null, fallback);
},
isTechnicalMessage: (message) => {
const api = UM();
if (typeof api.isTechnicalMessage === "function") return api.isTechnicalMessage(message);
return false;
},
DEFAULT_ERROR_MESSAGE: UM().DEFAULT_ERROR_MESSAGE || "Не удалось выполнить операцию. Попробуйте ещё раз.",
get instance() {
return createAdapter();
},
};
})(typeof window !== "undefined" ? window : globalThis);
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>стандарты чистоты</title>
<script src="/static/js/wesp-theme-boot.js"></script>
<script>document.documentElement.classList.add('wesp-page-enter-pending');</script>
<script src="/static/js/wesp-page-enter.js?v=2"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=2"></script>
<script src="/static/js/wesp-lab-access.js?v=1"></script>
<script src="/static/js/wesp-zootech-nav.js?v=5"></script>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet">
<link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-lab-sandbox.css?v=10" rel="stylesheet">
<link href="/static/css/wesp-lab-profiles.css?v=4" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<script src="/static/js/wesp-zootech-notification-messages.js?v=1"></script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script src="/static/js/wesp-zootech-notify.js?v=6"></script>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-app-body lab-sandbox-page lab-profiles-page lab-heisenberg-prime">
<div id="wespZootechNavMount" data-active-page="lab"></div>
<div class="page-content wesp-page-main">
<div class="container py-3">
<div class="lab-sandbox-header lab-profiles-header" data-wesp-enter>
<p class="lab-heisenberg-prime-kicker mb-2"><span class="lab-heisenberg-prime-tag">PRIME</span> Heisenberg</p>
<h1><i class="fas fa-sliders-h text-primary me-2"></i>Стандарты чистоты</h1>
<p class="mb-0">Я не бизнесмен — я специалист. <a href="/lab">лаборатория</a></p>
</div>
<div class="lab-profiles-layout" data-wesp-enter>
<aside class="lab-sandbox-panel">
<div class="lab-sandbox-panel__title">Реестр стандартов</div>
<div class="mb-2">
<select id="labProfilesType" class="form-select form-select-sm">
<option value="">Все линейки</option>
<option value="DAIRY">Молоко</option>
<option value="BEEF">Говядина</option>
</select>
</div>
<input id="labProfilesSearch" class="form-control form-control-sm mb-2" type="search" placeholder="Это не мет">
<div id="labProfilesList" class="lab-profiles-list">
<div class="lab-sandbox-empty">Готовим список…</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary w-100 mt-2" id="labProfilesNew">+ Новый стандарт</button>
</aside>
<section class="lab-sandbox-panel lab-profiles-editor">
<div id="labProfilesEmpty" class="lab-sandbox-empty">Выбери стандарт</div>
<div id="labProfilesForm" hidden>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div class="lab-sandbox-panel__title mb-0">Настройка формулы</div>
<div class="lab-profiles-toolbar mt-0">
<button type="button" class="btn btn-sm btn-outline-danger" id="labProfilesDelete" hidden>Никаких полумер</button>
<button type="button" class="btn btn-sm btn-primary" id="labProfilesSave">Запомни это</button>
</div>
</div>
<div class="lab-profiles-meta">
<div>
<label class="form-label small">Код</label>
<input id="labProfileKey" class="form-control form-control-sm" type="text" placeholder="Это не мет">
</div>
<div>
<label class="form-label small">Название</label>
<input id="labProfileLabel" class="form-control form-control-sm" type="text" placeholder="Синий — значит чистый">
</div>
<div>
<label class="form-label small">Линейка</label>
<select id="labProfileType" class="form-select form-select-sm">
<option value="DAIRY">Молоко</option>
<option value="BEEF">Говядина</option>
</select>
</div>
<div>
<label class="form-label small">Масса, кг</label>
<input id="labProfileMass" class="form-control form-control-sm" type="number" step="0.1">
</div>
<div>
<label class="form-label small">Удой, кг/сут</label>
<input id="labProfileMilk" class="form-control form-control-sm" type="number" step="0.1" title="Для динамической нормы уСП по GfE — наука о переменах">
</div>
<div>
<label class="form-label small">№ в справочнике</label>
<input id="labProfileExternal" class="form-control form-control-sm" type="number" step="1">
</div>
<div>
<label class="form-label small" for="labProfileNormsMethod">Методика норм</label>
<select id="labProfileNormsMethod" class="form-select form-select-sm">
<option value="wesp">WESP / GfE</option>
<option value="racion_moscow">RACION Москва</option>
<option value="racion_piter">RACION Петербург</option>
</select>
</div>
</div>
<div id="labProfileRacionFields" class="lab-profiles-meta d-none mb-2">
<div>
<label class="form-label small" for="labProfileFat">Жирность молока, %</label>
<input id="labProfileFat" class="form-control form-control-sm" type="number" step="0.1" value="4">
</div>
<div>
<label class="form-label small" for="labProfileLactation">Лактация №</label>
<input id="labProfileLactation" class="form-control form-control-sm" type="number" step="1" value="2" min="1" max="3">
</div>
<div class="lab-profile-piter-only d-none">
<label class="form-label small" for="labProfileKonc">ОЭ/СВ, МДж/кг СВ</label>
<input id="labProfileKonc" class="form-control form-control-sm" type="number" step="0.1" value="10.3">
</div>
</div>
<div id="labProfilesSeedPanel" class="lab-profiles-seed alert alert-light py-2 px-3 small mb-2" hidden>
<div class="d-flex flex-wrap align-items-end gap-2">
<div class="flex-grow-1" style="min-width: 12rem;">
<label class="form-label small mb-0" for="labProfilesSeedSearch">Справочник zootech</label>
<input id="labProfilesSeedSearch" class="form-control form-control-sm mb-1" type="search" placeholder="Фильтр по названию или №">
<select id="labProfilesSeedSelect" class="form-select form-select-sm" size="1">
<option value="">— выбери строку —</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="labProfilesSeedApply" disabled>Загрузить нормы</button>
</div>
<p class="mb-0 mt-1 text-muted">Справочник zootech (БД). Пустой список — <code>python3 scripts/import_seed.py --norms</code>. Подставляет min/max; сохранение — «Запомни это».</p>
</div>
<div id="labProfilesGfePanel" class="lab-profiles-gfe alert alert-secondary py-2 px-3 small mb-2" hidden>
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<strong>GfE 2001</strong>
<span id="labProfilesGfeValues" class="text-muted"></span>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto" id="labProfilesGfeApplyUsp" disabled>Нижняя уСП</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="labProfilesGfeApplyNel" disabled>Нижняя ЧЭЛ</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="labProfilesGfeApplyAll" disabled>Обе нижние</button>
</div>
<p class="mb-0 text-muted">Считаются только <strong>уСП</strong> и <strong>ЧЭЛ</strong> из массы и удоя. Остальные границы — из справочника или вручную (СВ, минералы, клетчатка, RNB).</p>
</div>
<div id="labProfilesNormsSummary" class="lab-profiles-norms-summary alert alert-light py-2 px-3 small mb-2" hidden>
<div class="d-flex flex-wrap align-items-center gap-2">
<strong>Источники норм</strong>
<span id="labProfilesNormsSummaryText" class="text-muted"></span>
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="labProfilesNormsRefresh">Обновить разбор</button>
</div>
<p class="mb-0 mt-1 text-muted">В БД — сохранённые границы профиля. RACION — расчёт по Москва/Петербург. WESP — из справочника zootech (fallback).</p>
</div>
<div class="lab-profiles-toolbar">
<label class="small mb-0"><input type="checkbox" id="labProfilesOnlyFilled" checked> Только с границами</label>
<label class="small mb-0"><input type="checkbox" id="labProfilesOnlyCalc"> Только в расчёте</label>
<label class="small mb-0" for="labProfilesSourceFilter">Источник</label>
<select id="labProfilesSourceFilter" class="form-select form-select-sm" style="width: auto; min-width: 10rem;">
<option value="all">Все</option>
<option value="in_db">В БД</option>
<option value="seed">Справочник WESP</option>
<option value="racion" data-racion-only>RACION (всё)</option>
<option value="racion_only" data-racion-only>RACION NPitV</option>
<option value="derived" data-racion-only>Производные</option>
<option value="new_racion" data-racion-only>Только RACION (не WESP)</option>
</select>
<input id="labProfilesNormSearch" class="form-control form-control-sm" type="search" placeholder="Поиск показателя" style="max-width: 12rem;">
<span id="labProfilesNormCount" class="small text-muted ms-auto"></span>
</div>
<div class="lab-profiles-table-wrap">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Показатель</th>
<th>Источник</th>
<th>нижняя</th>
<th>верхняя</th>
</tr>
</thead>
<tbody id="labProfilesNormsBody"></tbody>
</table>
</div>
</div>
</section>
</div>
</div>
</div>
<script src="/static/js/pages/lab-profiles.js?v=10"></script>
</body>
</html>
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>лаборатория</title>
<script src="/static/js/wesp-theme-boot.js"></script>
<script>document.documentElement.classList.add('wesp-page-enter-pending');</script>
<script src="/static/js/wesp-page-enter.js?v=2"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=2"></script>
<script src="/static/js/wesp-lab-access.js?v=1"></script>
<script src="/static/js/wesp-zootech-nav.js?v=5"></script>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-lab-sandbox.css?v=10" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<script src="/static/js/wesp-zootech-notification-messages.js?v=1"></script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script src="/static/js/wesp-zootech-notify.js?v=6"></script>
<script src="/static/js/wesp-dialog.js"></script>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-app-body lab-sandbox-page">
<div id="wespZootechNavMount" data-active-page="lab"></div>
<div class="page-content wesp-page-main">
<div class="container py-3">
<div class="lab-sandbox-header" data-wesp-enter>
<h1><i class="fas fa-flask text-primary me-2"></i>Скажи моё имя. — Хайзенберг. — Ты чертовски прав.</h1>
<p>Я здесь, чтобы готовить · <a href="/lab/profiles">Стандарты чистоты</a> · <em>LAB тест — в нормах</em> / <em>полная матрица</em></p>
</div>
<div class="lab-sandbox-flow" data-wesp-enter>
<aside class="lab-sandbox-panel">
<div class="lab-sandbox-step"><span>1</span> Наука</div>
<div class="lab-sandbox-panel__title">Формула и стандарт</div>
<div class="lab-sandbox-field">
<label class="form-label">Партия</label>
<select id="labSandboxRecipe" class="form-select form-select-sm"></select>
</div>
<div class="lab-sandbox-field">
<label class="form-label">Стандарт чистоты</label>
<select id="labSandboxProfile" class="form-select form-select-sm"></select>
</div>
<div class="lab-sandbox-field">
<label class="form-label">Линейка продукта</label>
<select id="labSandboxRationType" class="form-select form-select-sm">
<option value="BEEF">Меф</option>
<option value="DAIRY">Say my name</option>
</select>
</div>
<p id="labSandboxMeta" class="small text-muted mb-0"></p>
<div class="lab-sandbox-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" data-action="seed">Взять партию</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-action="apply">Отправить партию</button>
</div>
<hr class="my-3">
<div class="lab-sandbox-panel__title">Границы чистоты</div>
<div id="labSandboxNorms"></div>
</aside>
<section class="lab-sandbox-panel">
<div class="lab-sandbox-step"><span>2</span> Готовка</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="lab-sandbox-panel__title mb-0">Состав партии</div>
<div class="lab-sandbox-actions mt-0">
<button type="button" class="btn btn-sm btn-outline-primary" data-action="add-line">+ Реагент</button>
<button type="button" class="btn btn-sm btn-primary" data-action="save">Запомни это</button>
<button type="button" class="btn btn-sm btn-success" data-action="recalculate">Примени науку</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm lab-sandbox-table mb-0">
<thead>
<tr>
<th>Реагент</th>
<th>кг/сут</th>
<th>В дело</th>
<th>Смесь</th>
<th>Себест.</th>
<th></th>
</tr>
</thead>
<tbody id="labSandboxLinesBody">
<tr><td colspan="6" class="lab-sandbox-empty">Сначала выбери партию</td></tr>
</tbody>
</table>
</div>
</section>
<aside class="lab-sandbox-panel lab-sandbox-results">
<div class="lab-sandbox-step"><span>3</span> Кристалл</div>
<div class="lab-sandbox-panel__title">Итоговая формула</div>
<div id="labSandboxResults">
<div class="lab-sandbox-empty">Нажми «Примени науку»</div>
</div>
</aside>
</div>
<section class="lab-agrostar-section mt-4" data-wesp-enter>
<div class="lab-sandbox-header mb-3">
<h2 class="h5 mb-1"><i class="fas fa-vial text-primary me-2"></i>Поставка чистоты</h2>
<p class="mb-0">AgroStar xml/pdf/xlsx · кидай что прислали. Разберём, покажем, привяжем к реагентам.</p>
</div>
<div class="lab-sandbox-panel">
<div class="lab-sandbox-step"><span>4</span> Поставка</div>
<div id="labAgrostarDrop" class="lab-agrostar-drop" tabindex="0" role="button" aria-label="Загрузить файл для разбора">
<input type="file" id="labAgrostarFile" accept=".xml,.pdf,.xlsx,.xls,text/xml,application/pdf,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" hidden>
<i class="fas fa-flask lab-agrostar-drop__icon" aria-hidden="true"></i>
<p class="lab-agrostar-drop__title mb-1">Кинь файл сюда</p>
<p class="lab-agrostar-drop__hint mb-0 small text-muted">Приготовим науку, понюхаем, привяжем к реагентам, приготовим партию... и посмотрим, кто из нас химик.</p>
<p id="labAgrostarFileName" class="lab-agrostar-drop__file small mt-2 mb-0" hidden></p>
</div>
<div id="labAgrostarStatus" class="small text-muted mt-2 mb-0"></div>
<div id="labAgrostarMeta" class="small mt-2 mb-3" hidden></div>
<div id="labAgrostarTableWrap" class="table-responsive" hidden>
<div class="lab-sandbox-panel__title" id="labImportTableTitle">Кому в каталог</div>
<table class="table table-sm lab-sandbox-table mb-2">
<thead id="labImportTableHead">
<tr>
<th>Образец</th>
<th>СВ, %</th>
<th>Реагент</th>
<th>Косяки</th>
</tr>
</thead>
<tbody id="labAgrostarTableBody"></tbody>
</table>
</div>
<div id="labAgrostarSniff" class="lab-agrostar-sniff" hidden>
<div class="lab-sandbox-panel__title">Что нанюхали</div>
<div id="labAgrostarSniffBody"></div>
</div>
<div class="lab-sandbox-actions mt-0">
<button type="button" class="btn btn-sm btn-outline-secondary" id="labAgrostarClear" disabled>Забудь это</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="labAgrostarPreview" disabled>Сначала понюхаем</button>
<button type="button" class="btn btn-sm btn-primary" id="labAgrostarApply" disabled>Запомни это</button>
</div>
<div id="labAgrostarResult" class="small mt-2"></div>
</div>
</section>
<section class="lab-formulate-section mt-4" data-wesp-enter>
<div class="lab-sandbox-header mb-3">
<h2 class="h5 mb-1"><i class="fas fa-magic text-primary me-2"></i>Автоготовка</h2>
<p class="mb-0">Грубые → сочные → концентраты → добавки → расчёт. Я не в опасности. Я и есть опасность.</p>
</div>
<div class="row g-3">
<div class="col-lg-4 lab-formulate-step-panel lab-formulate-step-panel--calc d-none">
<div class="lab-sandbox-panel h-100">
<div class="lab-sandbox-panel__title">Химия — наука о переменах</div>
<div class="lab-sandbox-field">
<label class="form-label" for="labFormulateProfile">Стандарт чистоты</label>
<select id="labFormulateProfile" class="form-select form-select-sm"></select>
</div>
<div class="lab-sandbox-field">
<label class="form-label" for="labFormulateNormsMethod">Методика норм</label>
<select id="labFormulateNormsMethod" class="form-select form-select-sm">
<option value="wesp">WESP (GfE)</option>
<option value="racion_moscow">Москва (РАСХН)</option>
<option value="racion_piter">Петербург</option>
</select>
</div>
<div id="labFormulateRacionFields" class="d-none">
<div class="row g-2">
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateFat">Жирность, %</label>
<input id="labFormulateFat" type="number" class="form-control form-control-sm" value="4" step="0.1">
</div>
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateLactation">Лактация №</label>
<input id="labFormulateLactation" type="number" class="form-control form-control-sm" value="2" min="1" max="3" step="1">
</div>
</div>
<div class="lab-sandbox-field lab-formulate-piter-only d-none">
<label class="form-label" for="labFormulateKonc">ОЭ/СВ, МДж/кг СВ</label>
<input id="labFormulateKonc" type="number" class="form-control form-control-sm" value="10.3" step="0.1">
</div>
</div>
<div class="row g-2">
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateMass">Масса, кг</label>
<input id="labFormulateMass" type="number" class="form-control form-control-sm" step="1">
</div>
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateMilk">Удой, кг/сут</label>
<input id="labFormulateMilk" type="number" class="form-control form-control-sm" step="0.1">
</div>
</div>
<div class="row g-2">
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateTotalKg">Σ кг/гол/сут</label>
<input id="labFormulateTotalKg" type="number" class="form-control form-control-sm" value="7.3" step="0.1">
</div>
<div class="col-6 lab-sandbox-field">
<label class="form-label" for="labFormulateCostWeight">Цена отклонения</label>
<input id="labFormulateCostWeight" type="number" class="form-control form-control-sm" value="100" step="1">
</div>
</div>
<div class="lab-sandbox-panel__title mt-3">На что смотрим</div>
<div id="labFormulateOptimizeKeys" class="lab-formulate-opt-keys"></div>
<p id="labFormulateSummary" class="small text-muted mt-3 mb-2">Я принял решение на основе науки</p>
<div id="labFormulatePrefilterNote" class="alert alert-info py-1 px-2 small d-none mb-2">
Слишком много вариантов — отфильтруем топ-18. Время — деньги.
</div>
<div class="lab-sandbox-actions">
<button type="button" class="btn btn-sm btn-primary" id="labFormulateRun" disabled>Хайзенберг</button>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="lab-sandbox-panel h-100">
<nav id="labFormulateSteps" class="lab-formulate-steps mb-3" aria-label="Этапы автоготовки"></nav>
<div id="labFormulatePanes"></div>
<div id="labFormulateReviewPane" class="lab-formulate-pane d-none">
<div class="lab-sandbox-panel__title">Рецепт на столе</div>
<ul id="labFormulateReview" class="small mb-2"></ul>
<p class="small text-muted mb-0">Позитив — это хорошо. Я за позитив, но позитив не меняет реальность.</p>
</div>
</div>
</div>
<div class="col-lg-12">
<div class="lab-sandbox-panel">
<div class="lab-sandbox-panel__title">99,1% чистоты:</div>
<div id="labFormulateResult">
<div class="lab-sandbox-empty">Скажи моё имя и жми кнопку</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<script src="/static/js/pages/lab-heisenberg-prime.js?v=2"></script>
<script src="/static/js/pages/lab-sandbox.js?v=6"></script>
<script src="/static/js/pages/lab-agrostar-import.js?v=11"></script>
<script src="/static/js/pages/lab-formulate.js?v=8"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff