Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+20
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 с ручки, удержание 1–2 с над целью, выбор позиции.
|
||||
* Отмена: отпустить не над целью / 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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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, "&").replace(/</g, "<")
|
||||
);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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="Удалить">×</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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}">×</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);
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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, """)}"><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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>← Назад</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">← Назад</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user