задачи 2 и 8
This commit is contained in:
790
frontend/admin/css/schedule-quality.css
Normal file
790
frontend/admin/css/schedule-quality.css
Normal 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;
|
||||
}
|
||||
}
|
||||
299
frontend/admin/css/schedule-versions.css
Normal file
299
frontend/admin/css/schedule-versions.css
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
},
|
||||
|
||||
462
frontend/admin/js/views/schedule-quality.js
Normal file
462
frontend/admin/js/views/schedule-quality.js
Normal 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] || 'Версия';
|
||||
}
|
||||
423
frontend/admin/js/views/schedule-versions.js
Normal file
423
frontend/admin/js/views/schedule-versions.js
Normal 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}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
98
frontend/admin/views/schedule-quality.html
Normal file
98
frontend/admin/views/schedule-quality.html
Normal 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>
|
||||
79
frontend/admin/views/schedule-versions.html
Normal file
79
frontend/admin/views/schedule-versions.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user