задачи 2 и 8

This commit is contained in:
Zuev
2026-08-11 18:18:08 +03:00
parent 519864b962
commit 18a97b293a
59 changed files with 6237 additions and 276 deletions

View File

@@ -0,0 +1,790 @@
/* Анализ качества: диагностическая рабочая станция учебного отдела. */
.quality-lab {
--quality-ink: #17324d;
--quality-cyan: #0f9fa8;
--quality-amber: #d88419;
--quality-red: #d6534d;
display: grid;
gap: 1rem;
}
.quality-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.55fr);
gap: clamp(1.5rem, 4vw, 4rem);
overflow: hidden;
padding: clamp(1.5rem, 4vw, 3.25rem);
color: #f7fbff;
background:
linear-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.055) 1px, transparent 1px),
radial-gradient(circle at 88% 16%, rgba(16, 194, 193, 0.26), transparent 34%),
linear-gradient(135deg, #12283b, #173b4d 58%, #0e5559);
background-size: 32px 32px, 32px 32px, auto, auto;
border: 1px solid rgba(116, 229, 225, 0.28);
border-radius: var(--radius-lg);
box-shadow: 0 24px 55px rgba(7, 32, 44, 0.22);
}
.quality-hero::after {
content: '';
position: absolute;
right: -4rem;
bottom: -7rem;
width: 19rem;
height: 19rem;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 50%;
box-shadow:
0 0 0 2.5rem rgba(255, 255, 255, 0.025),
0 0 0 5rem rgba(255, 255, 255, 0.018);
pointer-events: none;
}
.quality-hero-copy,
.quality-run-panel {
position: relative;
z-index: 1;
}
.quality-kicker {
display: block;
margin-bottom: 0.65rem;
color: var(--quality-cyan);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.quality-hero .quality-kicker {
color: #72e2dd;
}
.quality-hero h2 {
max-width: 790px;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2rem, 4.5vw, 4.4rem);
font-weight: 500;
letter-spacing: -0.045em;
line-height: 0.98;
}
.quality-hero-copy p {
max-width: 720px;
margin: 1.25rem 0 0;
color: rgba(239, 249, 252, 0.76);
font-size: 0.95rem;
line-height: 1.7;
}
.quality-run-panel {
align-self: end;
display: grid;
gap: 0.65rem;
padding: 1.1rem;
background: rgba(4, 27, 39, 0.38);
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: var(--radius-md);
backdrop-filter: blur(16px);
}
.quality-run-panel label {
color: rgba(238, 249, 252, 0.7);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.quality-run-panel select,
.quality-filters select {
width: 100%;
min-height: 42px;
padding: 0.55rem 2rem 0.55rem 0.75rem;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
font: inherit;
}
.quality-run-panel select {
color: #eef9fb;
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.16);
}
.quality-run-panel select option {
color: #182b38;
background: #f7fbfc;
}
.quality-status {
min-height: 2.75rem;
padding: 0.8rem 1rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-left: 4px solid var(--quality-cyan);
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.quality-status[data-tone="loading"] {
border-left-color: var(--quality-amber);
}
.quality-status[data-tone="success"] {
border-left-color: var(--success);
}
.quality-status[data-tone="warning"],
.quality-status[data-tone="error"] {
border-left-color: var(--quality-red);
}
.quality-results {
display: grid;
gap: 1rem;
}
.quality-results[hidden] {
display: none;
}
.quality-scoreboard {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: clamp(1.5rem, 4vw, 3rem);
align-items: center;
padding: clamp(1.25rem, 3vw, 2.25rem);
background:
linear-gradient(110deg, color-mix(in srgb, var(--quality-cyan) 7%, var(--bg-card)), var(--bg-card) 44%);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
}
.quality-score-orbit {
--quality-ring: var(--quality-cyan);
position: relative;
display: grid;
width: 164px;
aspect-ratio: 1;
place-items: center;
border-radius: 50%;
box-shadow: 0 15px 40px color-mix(in srgb, var(--quality-ring) 24%, transparent);
animation: quality-orbit-arrive 520ms cubic-bezier(.2, .8, .2, 1) both;
}
.quality-score-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.quality-score-ring circle {
fill: none;
stroke-width: 2.5;
}
.quality-score-ring-track {
stroke: color-mix(in srgb, var(--text-secondary) 14%, transparent);
}
.quality-score-ring-value {
stroke: var(--quality-ring);
stroke-linecap: round;
transition: stroke-dasharray 420ms ease;
}
.quality-score-orbit[data-band="attention"] {
--quality-ring: var(--quality-amber);
}
.quality-score-orbit[data-band="critical"] {
--quality-ring: var(--quality-red);
}
.quality-score-orbit[data-band="excellent"] {
--quality-ring: #23965c;
}
.quality-score-inner {
position: relative;
z-index: 1;
display: grid;
width: 132px;
aspect-ratio: 1;
place-content: center;
text-align: center;
background: var(--bg-card);
border-radius: 50%;
}
.quality-score-inner strong {
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-size: 3.2rem;
font-weight: 500;
line-height: 0.95;
}
.quality-score-inner span {
margin-top: 0.4rem;
color: var(--text-secondary);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.quality-score-band {
display: inline-flex;
width: fit-content;
padding: 0.3rem 0.65rem;
color: var(--quality-cyan);
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.quality-score-band[data-band="attention"] {
color: var(--quality-amber);
}
.quality-score-band[data-band="critical"] {
color: var(--quality-red);
}
.quality-score-band[data-band="excellent"] {
color: #23965c;
}
.quality-score-copy h3 {
margin: 0.55rem 0 0;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.6rem, 3vw, 2.5rem);
font-weight: 500;
}
.quality-score-copy p {
max-width: 760px;
margin: 0.55rem 0 1.1rem;
color: var(--text-secondary);
line-height: 1.55;
}
.quality-score-meta {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.quality-score-meta span,
.quality-problem-total {
padding: 0.32rem 0.65rem;
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: 999px;
font-size: 0.75rem;
}
.quality-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(145px, 1fr));
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.quality-metric {
position: relative;
min-height: 210px;
padding: 1.25rem;
background: var(--bg-card);
border-right: 1px solid var(--bg-card-border);
animation: quality-metric-arrive 420ms ease both;
}
.quality-metric-2 { animation-delay: 45ms; }
.quality-metric-3 { animation-delay: 90ms; }
.quality-metric-4 { animation-delay: 135ms; }
.quality-metric-5 { animation-delay: 180ms; }
.quality-metric-6 { animation-delay: 225ms; }
.quality-metric:last-child {
border-right: 0;
}
.quality-metric::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 48px;
height: 3px;
background: var(--quality-cyan);
}
.quality-metric-index {
color: color-mix(in srgb, var(--text-secondary) 65%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.78rem;
}
.quality-metric > strong {
display: block;
margin-top: 1.25rem;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-size: 2rem;
font-weight: 500;
}
.quality-metric h4 {
margin: 0.4rem 0;
color: var(--text-primary);
font-size: 0.8rem;
}
.quality-metric p {
margin: 0;
color: var(--text-secondary);
font-size: 0.72rem;
line-height: 1.45;
}
.quality-metric-penalty {
position: absolute;
bottom: 1rem;
left: 1.25rem;
color: var(--quality-red);
font-size: 0.68rem;
font-weight: 700;
}
.quality-workbench {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(310px, 0.75fr);
gap: 1rem;
align-items: start;
}
.quality-problems-column,
.quality-recommendations {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
}
.quality-problems-column {
padding: clamp(1rem, 2.5vw, 1.75rem);
}
.quality-section-heading {
display: flex;
gap: 1rem;
align-items: end;
justify-content: space-between;
}
.quality-section-heading h3,
.quality-recommendations h3 {
margin: 0;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.65rem;
font-weight: 500;
}
.quality-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 1.25rem 0;
}
.quality-filters label {
display: grid;
gap: 0.35rem;
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
}
.quality-problems {
display: grid;
gap: 0.65rem;
max-height: 820px;
padding-right: 0.2rem;
overflow: auto;
}
.quality-problem {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-left: 4px solid var(--quality-amber);
border-radius: var(--radius-sm);
transition: border-color var(--transition), transform var(--transition), box-shadow var(--transition);
}
.quality-problem[data-severity="CRITICAL"] {
border-left-color: var(--quality-red);
}
.quality-problem[data-severity="LOW"] {
border-left-color: var(--quality-cyan);
}
.quality-problem:hover,
.quality-problem.is-selected {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--quality-cyan) 55%, var(--bg-card-border));
box-shadow: 0 8px 24px rgba(15, 64, 76, 0.08);
}
.quality-problem-main {
display: grid;
grid-template-columns: 76px minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: start;
width: 100%;
padding: 1rem;
color: inherit;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
}
.quality-severity {
width: fit-content;
padding: 0.2rem 0.4rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 4px;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quality-problem-copy {
display: grid;
gap: 0.28rem;
}
.quality-problem-copy strong {
color: var(--text-primary);
font-size: 0.88rem;
}
.quality-problem-copy > span {
color: var(--text-secondary);
font-size: 0.78rem;
line-height: 1.4;
}
.quality-problem-copy small {
color: color-mix(in srgb, var(--text-secondary) 78%, transparent);
font-size: 0.7rem;
}
.quality-penalty {
color: var(--quality-red);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
}
.quality-problem-action {
display: flex;
align-items: center;
padding: 0.7rem;
border-left: 1px solid var(--bg-card-border);
}
.quality-problem-action > span {
max-width: 95px;
color: var(--text-secondary);
font-size: 0.68rem;
line-height: 1.25;
}
.quality-recommendations {
position: sticky;
top: 1rem;
min-height: 360px;
max-height: calc(100vh - 2rem);
padding: 1.25rem;
overflow: auto;
}
.quality-recommendations-empty,
.quality-recommendations-loading {
display: grid;
min-height: 320px;
place-content: center;
padding: 2rem;
text-align: center;
}
.quality-recommendations-mark {
display: grid;
width: 54px;
aspect-ratio: 1;
margin: 0 auto 1rem;
place-items: center;
color: var(--quality-cyan);
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent);
border-radius: 50%;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
}
.quality-recommendations-empty p,
.quality-recommendations-loading p,
.quality-recommendations-head p,
.quality-recommendations-note {
color: var(--text-secondary);
font-size: 0.78rem;
line-height: 1.5;
}
.quality-loader {
width: 34px;
height: 34px;
margin: 0 auto 1rem;
border: 3px solid color-mix(in srgb, var(--quality-cyan) 18%, transparent);
border-top-color: var(--quality-cyan);
border-radius: 50%;
animation: quality-spin 0.8s linear infinite;
}
.quality-candidates {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.quality-candidate {
padding: 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-top: 3px solid var(--text-secondary);
border-radius: var(--radius-sm);
}
.quality-candidate.is-positive {
border-top-color: var(--success);
}
.quality-candidate.is-negative {
border-top-color: var(--quality-red);
}
.quality-candidate-top {
display: grid;
gap: 0.2rem;
}
.quality-candidate-top span {
color: var(--text-secondary);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.quality-candidate-top strong {
color: var(--text-primary);
font-size: 0.94rem;
}
.quality-candidate > p {
margin: 0.6rem 0;
color: var(--text-secondary);
font-size: 0.76rem;
}
.quality-candidate-impact {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.quality-candidate-impact span {
padding: 0.2rem 0.42rem;
color: var(--text-secondary);
background: var(--bg-card);
border-radius: 4px;
font-size: 0.66rem;
}
.quality-change-list {
display: grid;
gap: 0.25rem;
margin: 0.55rem 0;
padding-left: 0.65rem;
border-left: 2px solid var(--success);
}
.quality-change-list.is-negative {
border-left-color: var(--quality-red);
}
.quality-change-list strong,
.quality-change-list span {
font-size: 0.68rem;
}
.quality-change-list strong {
color: var(--text-primary);
}
.quality-change-list span {
color: var(--text-secondary);
}
.quality-apply {
width: 100%;
margin-top: 0.75rem;
}
.quality-empty-state {
display: grid;
min-height: 180px;
place-content: center;
gap: 0.35rem;
color: var(--text-secondary);
text-align: center;
}
.quality-empty-state strong {
color: var(--text-primary);
}
@keyframes quality-orbit-arrive {
from { opacity: 0; transform: scale(0.82) rotate(-14deg); }
to { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes quality-metric-arrive {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes quality-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1180px) {
.quality-metrics {
grid-template-columns: repeat(3, minmax(160px, 1fr));
}
.quality-metric:nth-child(3) {
border-right: 0;
}
.quality-metric:nth-child(-n + 3) {
border-bottom: 1px solid var(--bg-card-border);
}
}
@media (max-width: 920px) {
.quality-hero,
.quality-workbench {
grid-template-columns: 1fr;
}
.quality-recommendations {
position: static;
max-height: none;
}
}
@media (max-width: 680px) {
.quality-hero {
padding: 1.35rem;
}
.quality-scoreboard {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.quality-score-meta {
justify-content: center;
}
.quality-metrics {
grid-template-columns: 1fr;
}
.quality-metric,
.quality-metric:nth-child(3),
.quality-metric:nth-child(-n + 3) {
min-height: 190px;
border-right: 0;
border-bottom: 1px solid var(--bg-card-border);
}
.quality-metric:last-child {
border-bottom: 0;
}
.quality-filters {
grid-template-columns: 1fr;
}
.quality-problem {
grid-template-columns: 1fr;
}
.quality-problem-main {
grid-template-columns: 1fr auto;
}
.quality-severity {
grid-column: 1;
}
.quality-problem-copy {
grid-column: 1;
}
.quality-penalty {
grid-column: 2;
grid-row: 1 / 3;
}
.quality-problem-action {
justify-content: flex-end;
border-top: 1px solid var(--bg-card-border);
border-left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.quality-score-orbit,
.quality-metric,
.quality-loader {
animation: none;
}
}

View File

@@ -0,0 +1,299 @@
.version-studio {
--version-ink: #12253a;
--version-paper: #f5f0e4;
--version-copper: #d46b3c;
--version-mint: #52c7a3;
display: grid;
gap: 1rem;
}
.version-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(260px, .55fr);
gap: clamp(1.5rem, 5vw, 5rem);
overflow: hidden;
padding: clamp(1.6rem, 4vw, 3.5rem);
color: #f8f4eb;
background:
radial-gradient(circle at 82% 22%, rgba(237, 129, 75, .28), transparent 27%),
repeating-linear-gradient(118deg, rgba(255, 255, 255, .025) 0 1px, transparent 1px 19px),
linear-gradient(135deg, #102438, #19364b 54%, #233f45);
border: 1px solid rgba(240, 173, 119, .25);
border-radius: var(--radius-lg, 20px);
box-shadow: 0 26px 65px rgba(5, 20, 32, .27);
}
.version-hero::after {
content: '01 / LIVE';
position: absolute;
right: -1.5rem;
bottom: 1.2rem;
color: rgba(255, 255, 255, .055);
font-family: Georgia, serif;
font-size: clamp(4rem, 10vw, 9rem);
letter-spacing: -.08em;
pointer-events: none;
}
.version-hero > * { position: relative; z-index: 1; }
.version-eyebrow {
display: block;
margin-bottom: .55rem;
color: var(--version-copper);
font-size: .68rem;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
}
.version-hero .version-eyebrow { color: #f3a170; }
.version-hero h2 {
max-width: 760px;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2rem, 4.5vw, 4.5rem);
font-weight: 500;
letter-spacing: -.05em;
line-height: .98;
}
.version-hero p {
max-width: 700px;
margin: 1.25rem 0 0;
color: rgba(242, 247, 248, .72);
line-height: 1.7;
}
.version-hero-controls {
align-self: end;
display: grid;
gap: .65rem;
padding: 1rem;
background: rgba(3, 17, 28, .42);
border: 1px solid rgba(255, 255, 255, .12);
border-radius: var(--radius-md);
backdrop-filter: blur(14px);
}
.version-hero-controls label {
color: rgba(255, 255, 255, .62);
font-size: .7rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
.version-hero-controls select {
width: 100%;
min-height: 42px;
padding: .55rem .7rem;
color: #f8fafc;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .15);
border-radius: var(--radius-sm);
}
.version-hero-controls option { color: #152536; background: #f7f4ed; }
.version-status {
min-height: 2.8rem;
padding: .8rem 1rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-left: 4px solid var(--version-copper);
border-radius: var(--radius-sm);
}
.version-status[data-tone="success"] { border-left-color: var(--success); }
.version-status[data-tone="error"] { border-left-color: var(--error); }
.version-status[data-tone="loading"] { border-left-color: var(--warning); }
.version-constructor-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, .55fr);
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
padding: 1.15rem 1.3rem;
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border: 1px solid color-mix(in srgb, var(--version-copper) 30%, var(--bg-card-border));
border-radius: var(--radius-lg, 20px);
}
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .55rem; align-items: end; }
.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
.version-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 1.25rem;
align-items: center;
min-height: 120px;
padding: 1.4rem;
background: linear-gradient(100deg, color-mix(in srgb, var(--version-mint) 9%, var(--bg-card)), var(--bg-card));
border: 1px solid color-mix(in srgb, var(--version-mint) 35%, var(--bg-card-border));
border-radius: var(--radius-lg, 20px);
}
.version-live-mark {
display: grid;
width: 68px;
aspect-ratio: 1;
place-content: center;
color: #eafff8;
background: #16856a;
border-radius: 50%;
font-size: .7rem;
font-weight: 900;
letter-spacing: .12em;
box-shadow: 0 0 0 7px color-mix(in srgb, var(--version-mint) 15%, transparent);
}
.version-current h3,
.version-section-head h3,
.version-card h4,
.version-diff-panel h3 { margin: 0; color: var(--text-primary); }
.version-current p,
.version-card p,
.version-empty p { margin: .45rem 0 0; color: var(--text-secondary); line-height: 1.5; }
.version-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; }
.version-stats span,
.version-status-chip {
padding: .28rem .55rem;
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: 999px;
font-size: .72rem;
}
.version-workbench {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(330px, .85fr);
gap: 1rem;
align-items: start;
}
.version-drafts-panel,
.version-diff-panel,
.version-archive-panel,
.version-history-panel {
padding: 1.25rem;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg, 20px);
}
.version-diff-panel { position: sticky; top: 1rem; min-height: 310px; }
.version-section-head {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
margin-bottom: 1rem;
}
.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; }
.version-draft-list { display: grid; gap: .8rem; }
.version-card {
position: relative;
overflow: hidden;
padding: 1rem;
background: color-mix(in srgb, var(--version-paper) 4%, var(--bg-input));
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
}
.version-card::before {
content: attr(data-number);
position: absolute;
right: .5rem;
top: -.8rem;
color: color-mix(in srgb, var(--version-copper) 12%, transparent);
font-family: Georgia, serif;
font-size: 5rem;
}
.version-card > * { position: relative; }
.version-card-top { display: flex; justify-content: space-between; gap: 1rem; }
.version-card-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1rem; }
.version-card small { display: block; margin-top: .45rem; color: var(--text-placeholder); }
.version-empty {
display: grid;
min-height: 270px;
place-content: center;
text-align: center;
}
.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; }
.version-empty p { max-width: 360px; }
.version-diff-score {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: .55rem;
margin: 1rem 0;
}
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; }
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
.version-change { padding: .75rem; border-left: 3px solid var(--version-copper); background: var(--bg-input); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.version-change[data-type="ADDED"] { border-left-color: var(--success); }
.version-change[data-type="REMOVED"] { border-left-color: var(--error); }
.version-change strong { color: var(--text-primary); }
.version-change span { display: block; margin-top: .3rem; color: var(--text-secondary); font-size: .78rem; line-height: 1.45; }
.version-archive-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: .75rem; }
.version-history { display: grid; gap: 0; }
.version-history-row { display: grid; grid-template-columns: 120px 1fr auto; gap: 1rem; padding: .8rem 0; border-bottom: 1px solid var(--bg-card-border); }
.version-history-row:last-child { border-bottom: 0; }
.version-history-row time { color: var(--text-placeholder); font-size: .75rem; }
.version-history-row strong { color: var(--text-primary); }
.version-history-row p { margin: .25rem 0 0; color: var(--text-secondary); font-size: .8rem; }
.version-modal {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
padding: 1rem;
place-items: center;
background: rgba(4, 13, 22, .75);
backdrop-filter: blur(8px);
}
.version-modal[hidden] { display: none; }
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; }
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
.version-modal-card form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
.version-modal-card textarea { width: 100%; resize: vertical; padding: .8rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); font: inherit; }
.version-modal-close { position: absolute; top: .7rem; right: .8rem; color: var(--text-secondary); background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
.version-modal-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: .4rem; }
.version-modal-summary { padding: .75rem; color: var(--text-secondary); background: var(--bg-input); border-radius: var(--radius-sm); font-size: .8rem; line-height: 1.5; }
@media (max-width: 900px) {
.version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; }
.version-diff-panel { position: static; }
.version-current { grid-template-columns: auto 1fr; }
.version-current .version-card-actions { grid-column: 1 / -1; }
}
@media (max-width: 560px) {
.version-current { grid-template-columns: 1fr; }
.version-live-mark { width: 54px; }
.version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
}

View File

@@ -13,6 +13,8 @@
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
<link rel="stylesheet" href="css/teacher-absences.css">
<link rel="stylesheet" href="css/schedule-quality.css">
<link rel="stylesheet" href="css/schedule-versions.css">
</head>
<body>
@@ -87,6 +89,27 @@
</svg>
<span>Конструктор правил</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-versions">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
<span>Версии расписания</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-quality">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
<span>Качество расписания</span>
</a>
<a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3z"></path>

View File

@@ -101,6 +101,22 @@ const ROUTES = {
await module.initSchedule();
}
},
'schedule-versions': {
title: 'Версии расписания',
file: '/admin/views/schedule-versions.html',
init: async () => {
const module = await import('./views/schedule-versions.js');
await module.initScheduleVersions();
}
},
'schedule-quality': {
title: 'Анализ качества расписания',
file: '/admin/views/schedule-quality.html',
init: async () => {
const module = await import('./views/schedule-quality.js');
await module.initScheduleQuality();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',

View File

@@ -1,13 +1,13 @@
const capabilities = {
ADMIN: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
defaultSettingsTab: 'general',
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
},
EDUCATION_OFFICE: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'],
adminTabs: ['dashboard', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'],
defaultSettingsTab: 'time-slots',
settingsTabs: ['time-slots', 'edu-forms']
},

View File

@@ -0,0 +1,462 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
let qualityState = null;
export async function initScheduleQuality() {
const root = document.querySelector('.quality-lab');
if (!root) return;
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
await loadSemesters();
}
async function loadSemesters() {
const select = qualityState?.root.querySelector('#quality-semester');
if (!select) return;
try {
const semesters = await api.get('/api/schedule/semesters');
select.innerHTML = (semesters || []).map(semester => `
<option value="${escapeHtml(String(semester.id))}">
${escapeHtml(semesterLabel(semester))}
</option>
`).join('');
if (!semesters?.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
return;
}
const today = localIsoDate(new Date());
const current = semesters.find(semester =>
semester.startDate <= today && semester.endDate >= today
);
select.value = String(current?.id ?? semesters[0].id);
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
select.value = savedSemesterId;
}
await loadVersions();
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadVersions() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const select = qualityState?.root.querySelector('#quality-version');
if (!select || !semesterId) return;
select.innerHTML = '<option value="">Загрузка версий…</option>';
try {
qualityState.versions = await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
);
select.innerHTML = qualityState.versions.map(version => `
<option value="${escapeHtml(String(version.id))}">
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
</option>
`).join('');
if (!qualityState.versions.length) {
select.innerHTML = '<option value="">Версии не созданы</option>';
return;
}
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
select.value = String((saved || published || qualityState.versions[0]).id);
localStorage.setItem('magistr.quality.semesterId', semesterId);
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
}
}
async function runAnalysis() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
if (!semesterId) {
qualityStatus('Выберите семестр для анализа.', 'warning');
return;
}
if (!versionId) {
qualityStatus('Выберите версию расписания для анализа.', 'warning');
return;
}
localStorage.setItem('magistr.quality.semesterId', semesterId);
localStorage.setItem('magistr.quality.versionId', versionId);
const button = qualityState.root.querySelector('#quality-run');
button.disabled = true;
button.textContent = 'Анализируем…';
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
qualityState.root.querySelector('#quality-results').hidden = true;
try {
qualityState.analysis = await api.get(
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
);
qualityState.selectedProblemId = null;
renderAnalysis();
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
'success'
);
} catch (error) {
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
} finally {
button.disabled = false;
button.textContent = 'Выполнить анализ';
}
}
function renderAnalysis() {
const analysis = qualityState?.analysis;
if (!analysis) return;
qualityState.root.querySelector('#quality-results').hidden = false;
const band = qualityBand(analysis.score);
const orbit = qualityState.root.querySelector('#quality-score-orbit');
orbit.dataset.band = band.code;
qualityState.root.querySelector('#quality-score-ring-value')
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
.map((metric, index) => `
<article class="quality-metric quality-metric-${index + 1}">
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
<h4>${escapeHtml(metric.label)}</h4>
<p>${escapeHtml(metric.explanation)}</p>
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
</article>
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRecommendationsEmpty();
}
function renderProblems() {
const container = qualityState?.root.querySelector('#quality-problems');
if (!container || !qualityState.analysis) return;
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
qualityState.visibleProblems = filterQualityProblems(
qualityState.analysis.problems || [], severity, category
);
if (!qualityState.visibleProblems.length) {
container.innerHTML = `
<div class="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
return `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<small>
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
Подобрать улучшения
</button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
</div>
</article>
`;
}).join('');
}
async function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]');
if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return;
qualityState.selectedProblemId = problem.id;
renderProblems();
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
renderRecommendationsMessage(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик остаётся изолированным'
: 'Архивная версия доступна только для проверки',
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
);
return;
}
if (!problem.optimizable) {
renderRecommendationsMessage(
'Ручное решение сохранено',
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
);
return;
}
await loadRecommendations(problem);
}
async function loadRecommendations(problem) {
const panel = qualityState.root.querySelector('#quality-recommendations');
panel.innerHTML = `
<div class="quality-recommendations-loading">
<span class="quality-loader"></span>
<strong>Проверяем варианты</strong>
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
</div>
`;
try {
const params = new URLSearchParams({
semesterId: String(qualityState.analysis.semesterId),
versionId: String(qualityState.analysis.scheduleVersionId),
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
lessonDate: problem.lessonDate
});
const result = await api.get(
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
);
renderRecommendations(problem, result);
} catch (error) {
renderRecommendationsMessage(
'Не удалось подобрать варианты',
error.message || 'Повторите попытку после обновления анализа.'
);
}
}
function renderRecommendations(problem, result) {
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidates = result.candidates || [];
if (!candidates.length) {
renderRecommendationsMessage(
'Свободных улучшений не найдено',
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
);
return;
}
panel.innerHTML = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span>
<h3>${escapeHtml(problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
</div>
<div class="quality-candidates">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
</div>
<p class="quality-recommendations-note">
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
</p>
`;
panel._qualityCandidates = candidates;
}
function recommendationCard(candidate, index) {
const impactClass = candidate.penaltyDelta > 0
? 'is-positive'
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
const impact = candidate.scoreDelta > 0
? `+${candidate.scoreDelta} к оценке`
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
return `
<article class="quality-candidate ${impactClass}">
<div class="quality-candidate-top">
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
<strong>${escapeHtml(candidate.label)}</strong>
</div>
<p>${escapeHtml(candidate.description)}</p>
<div class="quality-candidate-impact">
<span>${escapeHtml(impact)}</span>
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
</div>
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
Применить после подтверждения
</button>
</article>
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
<div class="quality-change-list is-${tone}">
<strong>${escapeHtml(label)}</strong>
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
async function handleRecommendationClick(event) {
const button = event.target.closest('[data-candidate-index]');
if (!button || qualityState?.applying) return;
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
if (!candidate) return;
const confirmed = window.confirm(
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
);
if (!confirmed) return;
qualityState.applying = true;
button.disabled = true;
button.textContent = 'Применяем…';
try {
await api.post('/api/edu-office/schedule/overrides', candidate.override);
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
await runAnalysis();
} catch (error) {
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
button.disabled = false;
button.textContent = 'Применить после подтверждения';
} finally {
qualityState.applying = false;
}
}
function renderRecommendationsEmpty() {
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
renderRecommendationsMessage(
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
isDraft
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
);
return;
}
renderRecommendationsMessage(
'Локальные улучшения',
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
);
}
function problemActionLabel(problem) {
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
return 'Исправляется в конструкторе';
}
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
return 'Только просмотр';
}
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
}
function renderRecommendationsMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
</div>
`;
panel._qualityCandidates = [];
}
function qualityStatus(message, tone = '') {
const status = qualityState?.root.querySelector('#quality-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const categoryMatches = !category || problemCategory(problem.type) === category;
return severityMatches && categoryMatches;
});
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
}
function formatDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function problemWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
if (mod10 === 1) return 'проблема';
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
return 'проблем';
}
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}

View File

@@ -0,0 +1,423 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
let versionState = null;
export async function initScheduleVersions() {
const root = document.querySelector('.version-studio');
if (!root) return;
versionState = {
root,
semesters: [],
versions: [],
history: [],
modal: null,
loading: false
};
bindEvents();
await loadSemesters();
}
function bindEvents() {
const root = versionState.root;
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-current')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-modal-close')?.addEventListener('click', closeModal);
root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal);
root.querySelector('#version-modal')?.addEventListener('click', event => {
if (event.target.id === 'version-modal') closeModal();
});
root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal();
});
}
async function loadSemesters() {
const select = versionState.root.querySelector('#version-semester');
try {
versionState.semesters = await api.get('/api/schedule/semesters');
select.innerHTML = versionState.semesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('');
if (!versionState.semesters.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
setStatus('Сначала создайте учебный год и семестр.', 'error');
return;
}
const savedSemester = localStorage.getItem('magistr.schedule.semesterId');
const today = localIsoDate(new Date());
const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today);
const selected = versionState.semesters.find(item => String(item.id) === savedSemester)
|| current
|| versionState.semesters[0];
select.value = String(selected.id);
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadWorkspace() {
const semesterId = selectedSemesterId();
if (!semesterId || versionState.loading) return;
versionState.loading = true;
localStorage.setItem('magistr.schedule.semesterId', semesterId);
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
try {
[versionState.versions, versionState.history] = await Promise.all([
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`),
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
]);
renderWorkspace();
setStatus('Контур версий синхронизирован.', 'success');
} catch (error) {
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
} finally {
versionState.loading = false;
}
}
function renderWorkspace() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
const drafts = versionState.versions.filter(version => version.status === 'DRAFT');
const archive = versionState.versions.filter(version => version.status === 'ARCHIVED');
renderCurrent(published);
renderDrafts(drafts);
renderArchive(archive);
renderHistory(versionState.history);
}
function renderCurrent(version) {
const container = versionState.root.querySelector('#version-current');
if (!version) {
container.innerHTML = `
<div class="version-live-mark">ПУСТО</div>
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
`;
return;
}
container.innerHTML = `
<div class="version-live-mark">LIVE</div>
<div>
<span class="version-eyebrow">Сейчас видят пользователи</span>
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
<div class="version-stats">
<span>${escapeHtml(String(version.ruleCount))} правил</span>
<span>${escapeHtml(String(version.slotCount))} слотов</span>
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
</div>
</div>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div>
`;
}
function renderDrafts(drafts) {
const container = versionState.root.querySelector('#version-drafts');
versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length);
if (!drafts.length) {
container.innerHTML = `
<div class="version-empty">
<span>+</span><h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
`;
return;
}
container.innerHTML = drafts.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<div class="version-card-top">
<div>
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
</div>
</div>
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
</div>
</article>
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
return;
}
container.innerHTML = versions.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
</div>
</article>
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
return;
}
container.innerHTML = history.map(item => `
<article class="version-history-row">
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
<div>
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
</div>
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
</article>
`).join('');
}
async function handleVersionAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const version = versionState.versions.find(item => String(item.id) === button.dataset.id);
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.versionId', String(version.id));
navigateTo('schedule');
return;
}
if (action === 'quality') {
localStorage.setItem('magistr.quality.semesterId', String(version.semesterId));
localStorage.setItem('magistr.quality.versionId', String(version.id));
navigateTo('schedule-quality');
return;
}
if (action === 'diff') {
await loadDiff(version);
return;
}
if (action === 'publish') {
await openPublishModal(version);
return;
}
if (action === 'restore') {
openActionModal('restore', version);
}
}
async function loadDiff(version) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
<h3>Что изменится после публикации</h3>
<div class="version-diff-score">
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
</div>
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
<div class="version-change-list">
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
</div>
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
<article class="version-change" data-type="${escapeHtml(change.type)}">
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
</article>
`;
}
function openCreateModal() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
value: '',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus('Проверяем целостность черновика перед публикацией…', 'loading');
try {
const [validation, diff] = await Promise.all([
api.get(`/api/edu-office/schedule/versions/${version.id}/validate`),
api.get(`/api/edu-office/schedule/versions/${version.id}/diff`)
]);
const summary = validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
value: '',
submit: 'Опубликовать',
summary
});
setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error');
} catch (error) {
setStatus(error.message || 'Не удалось проверить черновик.', 'error');
}
}
function openActionModal(mode, version) {
openModal({
mode,
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
value: '',
submit: 'Восстановить'
});
}
function openModal(config) {
versionState.modal = config;
const root = versionState.root;
root.querySelector('#version-modal-kicker').textContent = config.kicker;
root.querySelector('#version-modal-title').textContent = config.title;
root.querySelector('#version-modal-copy').textContent = config.copy;
root.querySelector('#version-modal-label').textContent = config.label;
root.querySelector('#version-modal-value').value = config.value || '';
const summary = root.querySelector('#version-modal-summary');
summary.hidden = !config.summary;
summary.textContent = config.summary || '';
const submit = root.querySelector('#version-modal-submit');
submit.textContent = config.submit;
submit.disabled = config.valid === false;
root.querySelector('#version-modal').hidden = false;
root.querySelector('#version-modal-value').focus();
}
function closeModal() {
const modal = versionState?.root.querySelector('#version-modal');
if (modal) modal.hidden = true;
if (versionState) versionState.modal = null;
}
async function submitModal(event) {
event.preventDefault();
const config = versionState.modal;
if (!config || config.valid === false) return;
const value = versionState.root.querySelector('#version-modal-value').value.trim();
if (!value) {
setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error');
return;
}
const submit = versionState.root.querySelector('#version-modal-submit');
submit.disabled = true;
submit.textContent = 'Выполняем…';
try {
if (config.mode === 'create') {
const created = await api.post('/api/edu-office/schedule/versions', {
semesterId: Number(selectedSemesterId()),
name: value,
basedOnVersionId: config.version?.id || null
});
localStorage.setItem('magistr.schedule.versionId', String(created.id));
setStatus(`Черновик «${created.name}» создан.`, 'success');
} else {
await api.post(
`/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`,
{ reason: value }
);
setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success');
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Операцию не удалось выполнить.', 'error');
submit.disabled = false;
submit.textContent = config.submit;
}
}
function navigateTo(tab) {
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click();
}
function selectedSemesterId() {
return versionState?.root.querySelector('#version-semester')?.value || '';
}
function setStatus(message, tone = '') {
const status = versionState?.root.querySelector('#version-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
function formatDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

View File

@@ -67,6 +67,9 @@ export async function initSchedule() {
const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionNote = document.getElementById('schedule-version-note');
const scheduleVersionManage = document.getElementById('schedule-version-manage');
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
@@ -125,6 +128,7 @@ export async function initSchedule() {
let academicYears = [];
let semesters = [];
let approvedTeacherPreferences = [];
let scheduleVersions = [];
let selectedVisualYearId = '';
let selectedVisualSemesterId = '';
let selectedVisualGroupIds = new Set();
@@ -139,8 +143,11 @@ export async function initSchedule() {
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
await loadScheduleVersions();
renderRuleGroups([]);
renderRuleSlots([{}]);
syncRuleSemesterWithVersion();
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
@@ -148,6 +155,17 @@ export async function initSchedule() {
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionSelect?.addEventListener('change', async () => {
if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
}
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
});
scheduleVersionManage?.addEventListener('click', () => {
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
});
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
@@ -284,10 +302,62 @@ export async function initSchedule() {
updatePreferenceHints();
}
async function loadScheduleVersions() {
const responses = await Promise.all(semesters.map(semester =>
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
));
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT');
const storedId = localStorage.getItem('magistr.schedule.versionId');
const stored = scheduleVersions.find(version => String(version.id) === storedId);
const selected = stored || scheduleVersions[0] || null;
scheduleVersionSelect.innerHTML = scheduleVersions.length
? scheduleVersions.map(version => `
<option value="${version.id}">
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
</option>
`).join('')
: '<option value="">Черновики не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : '';
if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
} else {
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
}
setRuleEditorEnabled(Boolean(selected));
}
function selectedScheduleVersion() {
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
}
function syncRuleSemesterWithVersion() {
const version = selectedScheduleVersion();
if (!version) return;
ruleSemesterSelect.value = String(version.semesterId);
ruleSemesterSelect.disabled = true;
syncSelects(ruleSemesterSelect);
}
function setRuleEditorEnabled(enabled) {
document.getElementById('schedule-rule-editor')?.classList.toggle('is-disabled', !enabled);
ruleForm.querySelectorAll('input, select, button').forEach(control => {
control.disabled = !enabled;
});
if (enabled) ruleSemesterSelect.disabled = true;
}
async function loadRules() {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion();
if (!version) {
rules = [];
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
setVisualState('Нет выбранного черновика');
return;
}
try {
rules = await api.get('/api/admin/schedule-rules');
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
@@ -1244,6 +1314,7 @@ export async function initSchedule() {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
scheduleVersionId: Number(rule.scheduleVersionId || scheduleVersionSelect.value),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
@@ -1319,6 +1390,7 @@ export async function initSchedule() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
const typeLimits = readTypeLimits();
validateTypeLimits(typeLimits, slots);
@@ -1328,6 +1400,7 @@ export async function initSchedule() {
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
subjectId: Number(ruleSubjectSelect.value),
semesterId: Number(ruleSemesterSelect.value),
scheduleVersionId: Number(scheduleVersionSelect.value),
...typeLimits,
groupIds,
slots
@@ -1343,6 +1416,7 @@ export async function initSchedule() {
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
renderRuleGroups(rule.groupIds || []);
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
syncRuleSemesterWithVersion();
hideAlert('schedule-rule-alert');
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -1354,6 +1428,7 @@ export async function initSchedule() {
fillTypeLimits({});
renderRuleGroups([]);
renderRuleSlots([{}]);
syncRuleSemesterWithVersion();
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
}

View File

@@ -0,0 +1,98 @@
<section class="quality-lab" aria-labelledby="quality-lab-title">
<header class="quality-hero">
<div class="quality-hero-copy">
<span class="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Черновик можно оценить до публикации, не показывая промежуточное
расписание студентам и преподавателям.
</p>
</div>
<div class="quality-run-panel">
<label for="quality-semester">Семестр</label>
<select id="quality-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
</select>
<label for="quality-version">Версия</label>
<select id="quality-version" data-native-select="true">
<option value="">Сначала выберите семестр</option>
</select>
<button type="button" class="btn btn-md btn-primary" id="quality-run">
Выполнить анализ
</button>
</div>
</header>
<div class="quality-status" id="quality-status" role="status" aria-live="polite">
Выберите семестр, чтобы получить объяснимую оценку.
</div>
<div class="quality-results" id="quality-results" hidden>
<section class="quality-scoreboard" aria-label="Итог анализа">
<div class="quality-score-orbit" id="quality-score-orbit">
<svg class="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle class="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle class="quality-score-ring-value" id="quality-score-ring-value"
cx="18" cy="18" r="15.9155" pathLength="100"></circle>
</svg>
<div class="quality-score-inner">
<strong id="quality-score">—</strong>
<span>из 100</span>
</div>
</div>
<div class="quality-score-copy">
<span class="quality-score-band" id="quality-score-band">Нет данных</span>
<h3 id="quality-semester-label">Семестр</h3>
<p id="quality-score-summary"></p>
<div class="quality-score-meta" id="quality-score-meta"></div>
</div>
</section>
<section class="quality-metrics" id="quality-metrics" aria-label="Метрики качества"></section>
<section class="quality-workbench">
<div class="quality-problems-column">
<div class="quality-section-heading">
<div>
<span class="quality-kicker">Карта неудобств</span>
<h3>Что снижает оценку</h3>
</div>
<span class="quality-problem-total" id="quality-problem-total"></span>
</div>
<div class="quality-filters">
<label>
Важность
<select id="quality-severity-filter" data-native-select="true">
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select id="quality-type-filter" data-native-select="true">
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div class="quality-problems" id="quality-problems"></div>
</div>
<aside class="quality-recommendations" id="quality-recommendations" aria-live="polite">
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>Локальные улучшения</h3>
<p>Выберите проблему, чтобы проверить свободные слоты и аудитории.</p>
</div>
</aside>
</section>
</div>
</section>

View File

@@ -0,0 +1,79 @@
<section class="version-studio" aria-labelledby="version-studio-title">
<header class="version-hero">
<div>
<span class="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div class="version-hero-controls">
<label for="version-semester">Рабочий семестр</label>
<select id="version-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
</select>
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
</div>
</header>
<div class="version-status" id="version-status" role="status" aria-live="polite">Загружаем контур версий…</div>
<section class="version-current" id="version-current" aria-label="Опубликованная версия"></section>
<div class="version-workbench">
<section class="version-drafts-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">0</span>
</div>
<div class="version-draft-list" id="version-drafts"></div>
</section>
<aside class="version-diff-panel" id="version-diff">
<div class="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
</aside>
</div>
<section class="version-archive-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div class="version-archive-list" id="version-archive"></div>
</section>
<section class="version-history-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div class="version-history" id="version-history"></div>
</section>
<div class="version-modal" id="version-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button type="button" class="version-modal-close" id="version-modal-close" aria-label="Закрыть">×</button>
<span class="version-eyebrow" id="version-modal-kicker">Новая операция</span>
<h3 id="version-modal-title">Версия расписания</h3>
<p id="version-modal-copy"></p>
<form id="version-modal-form">
<label for="version-modal-value" id="version-modal-label">Название</label>
<textarea id="version-modal-value" rows="3" maxlength="2000" required></textarea>
<div class="version-modal-summary" id="version-modal-summary" hidden></div>
<div class="version-modal-actions">
<button type="button" class="btn btn-md btn-ghost" id="version-modal-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="version-modal-submit">Продолжить</button>
</div>
</form>
</div>
</div>
</section>

View File

@@ -1,4 +1,19 @@
<div class="card create-card">
<section class="version-constructor-bar" id="schedule-version-context">
<div>
<span class="version-eyebrow">Изолированный контекст</span>
<h2>Рабочий черновик</h2>
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p>
</div>
<div class="version-constructor-controls">
<label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true">
<option value="">Загрузка черновиков…</option>
</select>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Управление версиями</button>
</div>
</section>
<div class="card create-card" id="schedule-rule-editor">
<div class="card-header-row">
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>