This commit is contained in:
@@ -630,7 +630,7 @@
|
||||
}
|
||||
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -780,6 +780,438 @@
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.schedule-view-lesson-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.schedule-override-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin: 0.35rem 0;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.schedule-override-edit {
|
||||
width: 100%;
|
||||
margin-top: 0.55rem;
|
||||
padding: 0.42rem 0.6rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bg-card-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-input));
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-override-edit:hover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--bg-input));
|
||||
}
|
||||
|
||||
.schedule-override-count {
|
||||
display: inline-grid;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-left: 0.4rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
body.schedule-override-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-override-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(520px, 680px);
|
||||
}
|
||||
|
||||
.schedule-override-drawer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-override-backdrop {
|
||||
border: 0;
|
||||
background: rgba(4, 10, 24, 0.68);
|
||||
cursor: default;
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.schedule-override-panel {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border-left: 1px solid var(--bg-card-border);
|
||||
background: color-mix(in srgb, var(--bg-card) 88%, transparent);
|
||||
box-shadow: -24px 0 64px rgba(0, 0, 0, 0.28);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(135%);
|
||||
backdrop-filter: blur(24px) saturate(135%);
|
||||
animation: schedule-override-enter 0.22s ease-out;
|
||||
}
|
||||
|
||||
@keyframes schedule-override-enter {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.schedule-override-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.35rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.schedule-override-header h2 {
|
||||
margin: 0.18rem 0 0;
|
||||
}
|
||||
|
||||
.schedule-override-eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.schedule-override-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
margin: 0 1.5rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-override-tab {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 3px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-override-tab.active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-card));
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.schedule-override-body {
|
||||
min-height: 0;
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schedule-override-empty {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
padding: 1.25rem;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-override-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.schedule-override-comparison article {
|
||||
min-width: 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-override-comparison .schedule-override-after {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--bg-card-border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-input));
|
||||
}
|
||||
|
||||
.schedule-override-comparison span,
|
||||
.schedule-override-comparison strong,
|
||||
.schedule-override-comparison p {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.schedule-override-comparison span {
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.schedule-override-comparison strong {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.schedule-override-comparison p {
|
||||
margin: 0.42rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.schedule-override-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0 0 1.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.schedule-override-fieldset legend {
|
||||
margin-bottom: 0.55rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.schedule-override-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.schedule-override-date {
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
padding: 0.38rem 0.2rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-override-date span,
|
||||
.schedule-override-date strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.schedule-override-date span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.schedule-override-date strong {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.schedule-override-date.active,
|
||||
.schedule-override-calendar-day.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--bg-input));
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.schedule-override-date:disabled,
|
||||
.schedule-override-calendar-day:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-toggle {
|
||||
width: 100%;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.schedule-override-calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
max-height: 390px;
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schedule-override-calendar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-override-month {
|
||||
padding: 0.55rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.schedule-override-month h4 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-grid > strong {
|
||||
padding: 0.2rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-day,
|
||||
.schedule-override-calendar-blank {
|
||||
min-width: 0;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-day {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-override-calendar-day:not(:disabled):hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.schedule-override-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.schedule-override-fields select,
|
||||
#schedule-override-comment {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#schedule-override-comment {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.schedule-override-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.schedule-override-registry-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.schedule-override-registry-heading h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.schedule-override-registry {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.schedule-override-registry-item {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-override-registry-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.schedule-override-registry-topline time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.schedule-override-action {
|
||||
display: inline-flex;
|
||||
padding: 0.2rem 0.48rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.schedule-override-action.action-cancel {
|
||||
background: color-mix(in srgb, var(--danger, #ef4444) 13%, transparent);
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.schedule-override-registry-item h4 {
|
||||
margin: 0.65rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.schedule-override-registry-item p,
|
||||
.schedule-override-registry-item blockquote {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.schedule-override-registry-item p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schedule-override-registry-item blockquote {
|
||||
margin: 0.65rem 0;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.schedule-override-registry-item .btn {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.schedule-view-state {
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
@@ -944,4 +1376,46 @@
|
||||
.schedule-view-day-tabs {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.schedule-override-drawer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.schedule-override-drawer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-override-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-override-panel {
|
||||
width: 100%;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.schedule-override-header,
|
||||
.schedule-override-body {
|
||||
padding-right: 1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.schedule-override-tabs {
|
||||
margin-right: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.schedule-override-comparison,
|
||||
.schedule-override-fields,
|
||||
.schedule-override-calendar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-override-week {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.schedule-override-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
611
frontend/admin/js/views/schedule-override-panel.js
Normal file
611
frontend/admin/js/views/schedule-override-panel.js
Normal file
@@ -0,0 +1,611 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
|
||||
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||||
const ACTION_LABELS = {
|
||||
MOVE: 'Перенос',
|
||||
REPLACE: 'Замена',
|
||||
CANCEL: 'Отмена'
|
||||
};
|
||||
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
const MONTH_LABELS = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
export function canEditScheduleOverrides(role) {
|
||||
return EDIT_ROLES.has(role);
|
||||
}
|
||||
|
||||
export function overrideActionForChanges(changes) {
|
||||
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
|
||||
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function weekDates(value) {
|
||||
const selected = parseIsoDate(value);
|
||||
const day = selected.getDay() || 7;
|
||||
const monday = addDays(selected, 1 - day);
|
||||
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
|
||||
}
|
||||
|
||||
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
|
||||
const safeSlots = Array.isArray(slots) ? slots : [];
|
||||
const byId = preferredId == null
|
||||
? null
|
||||
: safeSlots.find(slot => String(slot.id) === String(preferredId));
|
||||
if (byId) return byId;
|
||||
const normalizedStart = trimTime(startTime);
|
||||
const normalizedEnd = trimTime(endTime);
|
||||
if (!normalizedStart || !normalizedEnd) return null;
|
||||
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
|
||||
&& trimTime(slot.endTime) === normalizedEnd) || null;
|
||||
}
|
||||
|
||||
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
|
||||
if (!canEditScheduleOverrides(role)) {
|
||||
return {
|
||||
setRegistry() {},
|
||||
openLesson() {},
|
||||
openRegistry() {}
|
||||
};
|
||||
}
|
||||
|
||||
const drawer = document.getElementById('schedule-override-drawer');
|
||||
const form = document.getElementById('schedule-override-form');
|
||||
const empty = document.getElementById('schedule-override-empty');
|
||||
const registryButton = document.getElementById('schedule-view-overrides-open');
|
||||
const registryCount = document.getElementById('schedule-view-overrides-count');
|
||||
const registry = document.getElementById('schedule-override-registry');
|
||||
const registryPeriod = document.getElementById('schedule-override-registry-period');
|
||||
const calendar = document.getElementById('schedule-override-calendar');
|
||||
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
|
||||
const week = document.getElementById('schedule-override-week');
|
||||
const timeSelect = document.getElementById('schedule-override-time');
|
||||
const teacherSelect = document.getElementById('schedule-override-teacher');
|
||||
const classroomSelect = document.getElementById('schedule-override-classroom');
|
||||
const formatSelect = document.getElementById('schedule-override-format');
|
||||
const commentInput = document.getElementById('schedule-override-comment');
|
||||
const restoreButton = document.getElementById('schedule-override-restore');
|
||||
const cancelButton = document.getElementById('schedule-override-cancel');
|
||||
const saveButton = document.getElementById('schedule-override-save');
|
||||
|
||||
const state = {
|
||||
overrides: [],
|
||||
selectedLesson: null,
|
||||
selectedOverride: null,
|
||||
source: null,
|
||||
availability: null,
|
||||
targetDate: null,
|
||||
timeSlots: [],
|
||||
busy: false
|
||||
};
|
||||
|
||||
registryButton.hidden = false;
|
||||
fillReferenceSelects();
|
||||
bindEvents();
|
||||
renderRegistry();
|
||||
|
||||
return { setRegistry, openLesson, openRegistry };
|
||||
|
||||
function bindEvents() {
|
||||
drawer.querySelectorAll('[data-override-close]').forEach(button => {
|
||||
button.addEventListener('click', close);
|
||||
});
|
||||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||||
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
|
||||
});
|
||||
registryButton.addEventListener('click', openRegistry);
|
||||
calendarToggle.addEventListener('click', () => {
|
||||
calendar.hidden = !calendar.hidden;
|
||||
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
|
||||
});
|
||||
week.addEventListener('click', selectDateFromButton);
|
||||
calendar.addEventListener('click', selectDateFromButton);
|
||||
registry.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-override-registry-id]');
|
||||
if (!button) return;
|
||||
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
|
||||
if (item) openOverride(item);
|
||||
});
|
||||
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
|
||||
select.addEventListener('change', updateComparison);
|
||||
});
|
||||
form.addEventListener('submit', save);
|
||||
cancelButton.addEventListener('click', cancelLesson);
|
||||
restoreButton.addEventListener('click', restoreRule);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !drawer.hidden) close();
|
||||
});
|
||||
}
|
||||
|
||||
function setRegistry(overrides, range) {
|
||||
state.overrides = Array.isArray(overrides) ? overrides : [];
|
||||
registryCount.textContent = String(state.overrides.length);
|
||||
const activeRange = range || getRange?.();
|
||||
registryPeriod.textContent = activeRange
|
||||
? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}`
|
||||
: 'Период не выбран';
|
||||
renderRegistry();
|
||||
}
|
||||
|
||||
async function openLesson(lesson) {
|
||||
if (!lesson?.scheduleRuleSlotId) return;
|
||||
const originalDate = lesson.originalLessonDate || lesson.date;
|
||||
const existing = state.overrides.find(override =>
|
||||
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
|
||||
&& override.lessonDate === originalDate
|
||||
) || null;
|
||||
await prepareEditor(lesson, existing);
|
||||
}
|
||||
|
||||
async function openOverride(override) {
|
||||
const syntheticLesson = {
|
||||
scheduleRuleSlotId: override.baseRuleSlotId,
|
||||
date: override.targetLessonDate || override.lessonDate,
|
||||
originalLessonDate: override.lessonDate,
|
||||
scheduleOverrideId: override.id,
|
||||
subjectName: override.subjectName,
|
||||
lessonTypeName: override.lessonTypeName,
|
||||
groupNames: override.groupNames,
|
||||
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
|
||||
timeSlotOrder: override.sourceTimeSlotOrder,
|
||||
startTime: override.sourceStartTime,
|
||||
endTime: override.sourceEndTime,
|
||||
teacherId: override.newTeacherId || override.sourceTeacherId,
|
||||
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
|
||||
classroomId: override.newClassroomId || override.sourceClassroomId,
|
||||
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
|
||||
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
|
||||
};
|
||||
await prepareEditor(syntheticLesson, override);
|
||||
}
|
||||
|
||||
function openRegistry() {
|
||||
open();
|
||||
activateTab('registry');
|
||||
renderRegistry();
|
||||
}
|
||||
|
||||
async function prepareEditor(lesson, existing) {
|
||||
open();
|
||||
activateTab('edit');
|
||||
form.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'Загрузка доступных дат и времени...';
|
||||
hideAlert('schedule-override-alert');
|
||||
|
||||
state.selectedLesson = lesson;
|
||||
state.selectedOverride = existing;
|
||||
state.source = sourceFrom(lesson, existing);
|
||||
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
|
||||
|
||||
try {
|
||||
state.availability = await api.get(
|
||||
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
|
||||
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
|
||||
);
|
||||
renderDateChoices();
|
||||
await loadTimeSlots(initialTimePreference(existing));
|
||||
setEditorValues(existing);
|
||||
renderSource();
|
||||
updateComparison();
|
||||
restoreButton.hidden = !existing;
|
||||
form.hidden = false;
|
||||
empty.hidden = true;
|
||||
} catch (error) {
|
||||
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceFrom(lesson, existing) {
|
||||
return {
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
|
||||
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
|
||||
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
|
||||
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
|
||||
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
|
||||
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
|
||||
startTime: existing?.sourceStartTime || lesson.startTime,
|
||||
endTime: existing?.sourceEndTime || lesson.endTime,
|
||||
teacherId: existing?.sourceTeacherId || lesson.teacherId,
|
||||
teacherName: existing?.sourceTeacherName || lesson.teacherName,
|
||||
classroomId: existing?.sourceClassroomId || lesson.classroomId,
|
||||
classroomName: existing?.sourceClassroomName || lesson.classroomName,
|
||||
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
|
||||
};
|
||||
}
|
||||
|
||||
function initialTimePreference(existing) {
|
||||
return {
|
||||
id: existing?.newTimeSlotId || state.source.timeSlotId,
|
||||
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
|
||||
endTime: existing?.newTimeSlotId ? null : state.source.endTime
|
||||
};
|
||||
}
|
||||
|
||||
function fillReferenceSelects() {
|
||||
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
|
||||
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
|
||||
).join('');
|
||||
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
|
||||
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function setEditorValues(existing) {
|
||||
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
|
||||
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
|
||||
ensureSelectOption(
|
||||
teacherSelect,
|
||||
effectiveTeacherId,
|
||||
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
|
||||
);
|
||||
ensureSelectOption(
|
||||
classroomSelect,
|
||||
effectiveClassroomId,
|
||||
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
|
||||
);
|
||||
teacherSelect.value = String(effectiveTeacherId);
|
||||
classroomSelect.value = String(effectiveClassroomId);
|
||||
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
|
||||
commentInput.value = existing?.comment || '';
|
||||
}
|
||||
|
||||
async function selectDateFromButton(event) {
|
||||
const button = event.target.closest('[data-override-date]');
|
||||
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
|
||||
state.targetDate = button.dataset.overrideDate;
|
||||
renderDateChoices();
|
||||
await loadTimeSlots({
|
||||
id: state.source.timeSlotId,
|
||||
startTime: state.source.startTime,
|
||||
endTime: state.source.endTime
|
||||
});
|
||||
updateComparison();
|
||||
}
|
||||
|
||||
function renderDateChoices() {
|
||||
const available = new Set(state.availability?.availableDates || []);
|
||||
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
|
||||
calendar.innerHTML = renderSemesterCalendar(
|
||||
state.availability.semesterStartDate,
|
||||
state.availability.semesterEndDate,
|
||||
available,
|
||||
state.targetDate
|
||||
);
|
||||
}
|
||||
|
||||
function dateButton(date, label, available) {
|
||||
const selected = date === state.targetDate ? ' active' : '';
|
||||
const disabled = available.has(date) ? '' : ' disabled';
|
||||
return `
|
||||
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(formatDateShort(date))}</strong>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadTimeSlots(preference) {
|
||||
timeSelect.disabled = true;
|
||||
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||||
state.timeSlots = await api.get(
|
||||
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
|
||||
);
|
||||
const selected = pickMatchingTimeSlot(
|
||||
state.timeSlots,
|
||||
preference?.id,
|
||||
preference?.startTime,
|
||||
preference?.endTime
|
||||
);
|
||||
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
|
||||
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
|
||||
).join('');
|
||||
timeSelect.value = selected ? String(selected.id) : '';
|
||||
timeSelect.disabled = false;
|
||||
}
|
||||
|
||||
function renderSource() {
|
||||
document.getElementById('schedule-override-before-title').textContent =
|
||||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||||
document.getElementById('schedule-override-before-meta').textContent = [
|
||||
formatDate(state.source.lessonDate),
|
||||
intervalLabel(state.source.startTime, state.source.endTime),
|
||||
state.source.teacherName,
|
||||
state.source.classroomName,
|
||||
state.source.lessonFormat,
|
||||
state.source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function updateComparison() {
|
||||
if (!state.source) return;
|
||||
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
|
||||
document.getElementById('schedule-override-after-title').textContent =
|
||||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||||
document.getElementById('schedule-override-after-meta').textContent = [
|
||||
formatDate(state.targetDate),
|
||||
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
|
||||
teacherName(teacherSelect.value),
|
||||
classroomName(classroomSelect.value),
|
||||
formatSelect.value,
|
||||
state.source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
if (state.busy) return;
|
||||
hideAlert('schedule-override-alert');
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
await mutate(async () => {
|
||||
if (state.selectedOverride) {
|
||||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||||
} else {
|
||||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
if (!timeSelect.value) {
|
||||
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
|
||||
return null;
|
||||
}
|
||||
const dateChanged = state.targetDate !== state.source.lessonDate;
|
||||
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
|
||||
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
|
||||
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
|
||||
const formatChanged = formatSelect.value !== state.source.lessonFormat;
|
||||
const action = overrideActionForChanges({
|
||||
dateChanged,
|
||||
timeChanged,
|
||||
teacherChanged,
|
||||
classroomChanged,
|
||||
formatChanged
|
||||
});
|
||||
if (!action) {
|
||||
showAlert(
|
||||
'schedule-override-alert',
|
||||
state.selectedOverride
|
||||
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
|
||||
: 'Измените дату, время, преподавателя, аудиторию или формат',
|
||||
'error'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||||
lessonDate: state.source.lessonDate,
|
||||
targetLessonDate: dateChanged ? state.targetDate : null,
|
||||
action,
|
||||
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
|
||||
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
|
||||
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
|
||||
newLessonFormat: formatChanged ? formatSelect.value : null,
|
||||
comment: commentInput.value.trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelLesson() {
|
||||
if (state.busy || !state.source) return;
|
||||
const payload = {
|
||||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||||
lessonDate: state.source.lessonDate,
|
||||
targetLessonDate: null,
|
||||
action: 'CANCEL',
|
||||
newTimeSlotId: null,
|
||||
newTeacherId: null,
|
||||
newClassroomId: null,
|
||||
newLessonFormat: null,
|
||||
comment: commentInput.value.trim() || null
|
||||
};
|
||||
await mutate(async () => {
|
||||
if (state.selectedOverride) {
|
||||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||||
} else {
|
||||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreRule() {
|
||||
if (state.busy || !state.selectedOverride) return;
|
||||
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
|
||||
}
|
||||
|
||||
async function mutate(operation) {
|
||||
setBusy(true);
|
||||
hideAlert('schedule-override-alert');
|
||||
try {
|
||||
await operation();
|
||||
await onChanged?.();
|
||||
state.selectedLesson = null;
|
||||
state.selectedOverride = null;
|
||||
state.source = null;
|
||||
form.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
|
||||
openRegistry();
|
||||
} catch (error) {
|
||||
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
state.busy = busy;
|
||||
saveButton.disabled = busy;
|
||||
cancelButton.disabled = busy;
|
||||
restoreButton.disabled = busy;
|
||||
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
|
||||
}
|
||||
|
||||
function renderRegistry() {
|
||||
if (!registry) return;
|
||||
if (!state.overrides.length) {
|
||||
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
|
||||
return;
|
||||
}
|
||||
registry.innerHTML = state.overrides.map(override => {
|
||||
const target = override.targetLessonDate
|
||||
? ` → ${formatDate(override.targetLessonDate)}`
|
||||
: '';
|
||||
const result = override.action === 'CANCEL'
|
||||
? 'Занятие отменено'
|
||||
: [
|
||||
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
|
||||
teacherName(override.newTeacherId),
|
||||
classroomName(override.newClassroomId),
|
||||
override.newLessonFormat
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<article class="schedule-override-registry-item">
|
||||
<div class="schedule-override-registry-topline">
|
||||
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
|
||||
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
|
||||
</div>
|
||||
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
|
||||
<p>${escapeHtml([
|
||||
override.lessonTypeName,
|
||||
(override.groupNames || []).join(', '),
|
||||
result || 'Параметры изменены'
|
||||
].filter(Boolean).join(' · '))}</p>
|
||||
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
|
||||
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function activateTab(tab) {
|
||||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.overrideTab === tab);
|
||||
});
|
||||
drawer.querySelectorAll('[data-override-section]').forEach(section => {
|
||||
section.hidden = section.dataset.overrideSection !== tab;
|
||||
});
|
||||
}
|
||||
|
||||
function open() {
|
||||
drawer.hidden = false;
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('schedule-override-open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
drawer.hidden = true;
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('schedule-override-open');
|
||||
}
|
||||
|
||||
function teacherName(id) {
|
||||
if (id == null || id === '') return '';
|
||||
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
|
||||
return teacher?.fullName || teacher?.username || '';
|
||||
}
|
||||
|
||||
function classroomName(id) {
|
||||
if (id == null || id === '') return '';
|
||||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||||
return classroom ? classroomOptionLabel(classroom) : '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
|
||||
if (!startDate || !endDate) return '';
|
||||
const start = parseIsoDate(startDate);
|
||||
const end = parseIsoDate(endDate);
|
||||
const months = [];
|
||||
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
cursor <= end;
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
|
||||
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
|
||||
}
|
||||
return months.join('');
|
||||
}
|
||||
|
||||
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
|
||||
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
|
||||
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
|
||||
const days = Array.from({ length: dayCount }, (_, index) => {
|
||||
const date = toIsoDate(new Date(year, monthIndex, index + 1));
|
||||
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
|
||||
const active = date === selectedDate ? ' active' : '';
|
||||
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
|
||||
}).join('');
|
||||
return `
|
||||
<section class="schedule-override-month">
|
||||
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
|
||||
<div class="schedule-override-calendar-grid">
|
||||
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
|
||||
${blanks}${days}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function timeSlotLabel(slot) {
|
||||
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
|
||||
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
|
||||
}
|
||||
|
||||
function intervalLabel(start, end) {
|
||||
return `${trimTime(start)}–${trimTime(end)}`;
|
||||
}
|
||||
|
||||
export function classroomOptionLabel(room) {
|
||||
return room?.name || '';
|
||||
}
|
||||
|
||||
function ensureSelectOption(select, value, label) {
|
||||
if (value == null || value === '') return;
|
||||
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
|
||||
if (exists) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = String(value);
|
||||
option.textContent = label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function formatDateShort(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
const TARGET_GROUPS = 'groups';
|
||||
@@ -59,6 +60,7 @@ export async function initScheduleView() {
|
||||
const tables = document.getElementById('schedule-view-tables');
|
||||
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
|
||||
const departmentSelect = document.getElementById('schedule-view-department');
|
||||
const semesterSelect = document.getElementById('schedule-view-semester');
|
||||
|
||||
const state = {
|
||||
dictionaries: {
|
||||
@@ -71,8 +73,11 @@ export async function initScheduleView() {
|
||||
activeSectionIndex: 0,
|
||||
mobileDay: 1,
|
||||
range: null,
|
||||
totalLessons: 0
|
||||
totalLessons: 0,
|
||||
lessons: [],
|
||||
overrides: []
|
||||
};
|
||||
let overridePanel = createEmptyOverridePanel();
|
||||
const mobileMedia = window.matchMedia(MOBILE_QUERY);
|
||||
|
||||
setDefaultDate(dateInput);
|
||||
@@ -97,8 +102,22 @@ export async function initScheduleView() {
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
});
|
||||
semesterSelect?.addEventListener('change', () => {
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
});
|
||||
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
|
||||
resultNextButton?.addEventListener('click', () => moveResult(1));
|
||||
tables?.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-schedule-override-edit]');
|
||||
if (!button) return;
|
||||
const lesson = state.lessons.find(item =>
|
||||
String(item.scheduleRuleSlotId) === button.dataset.baseRuleSlotId
|
||||
&& (item.originalLessonDate || item.date) === button.dataset.lessonDate
|
||||
);
|
||||
if (lesson) overridePanel.openLesson(lesson);
|
||||
});
|
||||
if (typeof mobileMedia.addEventListener === 'function') {
|
||||
mobileMedia.addEventListener('change', renderActiveSection);
|
||||
} else if (typeof mobileMedia.addListener === 'function') {
|
||||
@@ -107,6 +126,13 @@ export async function initScheduleView() {
|
||||
|
||||
try {
|
||||
state.dictionaries = await loadDictionaries(role, userDepartmentId);
|
||||
selectCurrentSemester();
|
||||
overridePanel = createScheduleOverridePanel({
|
||||
role,
|
||||
dictionaries: state.dictionaries,
|
||||
getRange: () => state.range,
|
||||
onChanged: loadData
|
||||
});
|
||||
configureDepartmentAccess();
|
||||
updateEntityFieldVisibility();
|
||||
await loadData();
|
||||
@@ -192,7 +218,7 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
const target = activeTarget();
|
||||
const range = twoWeekRange(dateInput.value);
|
||||
const range = scheduleRangeForSemester(dateInput.value, selectedSemester());
|
||||
const previousKey = state.sections[state.activeSectionIndex]?.key;
|
||||
state.range = range;
|
||||
updatePeriodLabel(range);
|
||||
@@ -213,7 +239,14 @@ export async function initScheduleView() {
|
||||
addOptional(params, 'parity', valueOf('schedule-view-parity'));
|
||||
|
||||
try {
|
||||
const lessons = await api.get(`/api/schedule/search?${params}`);
|
||||
const [lessons, overrides] = await Promise.all([
|
||||
api.get(`/api/schedule/search?${params}`),
|
||||
canEditScheduleOverrides(role)
|
||||
? api.get(`/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
state.overrides = overrides;
|
||||
overridePanel.setRegistry(overrides, range);
|
||||
renderLessons(lessons, range, target, previousKey);
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки расписания';
|
||||
@@ -245,6 +278,7 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
function renderLessons(lessons, range, target, previousKey) {
|
||||
state.lessons = lessons;
|
||||
state.totalLessons = lessons.length;
|
||||
if (!lessons.length) {
|
||||
state.sections = [];
|
||||
@@ -581,6 +615,9 @@ export async function initScheduleView() {
|
||||
lesson.classroomId || lesson.classroomName || '',
|
||||
lesson.lessonTypeId || lesson.lessonTypeName || '',
|
||||
lesson.lessonFormat || '',
|
||||
lesson.scheduleOverrideId || '',
|
||||
lesson.overrideAction || '',
|
||||
lesson.originalLessonDate || '',
|
||||
valueListSignature(lesson.groupIds, lesson.groupNames),
|
||||
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
|
||||
].join(':');
|
||||
@@ -672,19 +709,36 @@ export async function initScheduleView() {
|
||||
|
||||
const weeksText = getLessonWeeksText(lesson);
|
||||
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
|
||||
const sourceDate = lesson.originalLessonDate || lesson.date;
|
||||
const overrideBadge = lesson.scheduleOverrideId
|
||||
? '<span class="schedule-override-badge">Разовая правка</span>'
|
||||
: '';
|
||||
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
|
||||
? `
|
||||
<button type="button"
|
||||
class="schedule-override-edit"
|
||||
data-schedule-override-edit
|
||||
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
|
||||
data-lesson-date="${escapeHtml(sourceDate)}">
|
||||
Изменить
|
||||
</button>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="lesson-card schedule-view-lesson-card">
|
||||
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
|
||||
<div class="lesson-subject schedule-view-lesson-title" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
|
||||
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
|
||||
${weeksBadge}
|
||||
</div>
|
||||
${overrideBadge}
|
||||
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
||||
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
|
||||
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
||||
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
|
||||
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
|
||||
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
|
||||
${editButton}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -699,6 +753,7 @@ export async function initScheduleView() {
|
||||
state.activeSectionIndex = 0;
|
||||
state.mobileDay = 1;
|
||||
state.totalLessons = 0;
|
||||
state.lessons = [];
|
||||
renderResultNavigation();
|
||||
tableTitle.textContent = 'Расписание';
|
||||
tableCaption.textContent = message;
|
||||
@@ -714,7 +769,36 @@ export async function initScheduleView() {
|
||||
periodLabel.textContent = 'Период не выбран';
|
||||
return;
|
||||
}
|
||||
periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value));
|
||||
periodLabel.textContent = periodCaption(
|
||||
range || scheduleRangeForSemester(dateInput.value, selectedSemester()),
|
||||
selectedSemester()
|
||||
);
|
||||
}
|
||||
|
||||
function selectedSemester() {
|
||||
const semesterId = semesterSelect?.value;
|
||||
if (!semesterId) return null;
|
||||
return (state.dictionaries.semesters || [])
|
||||
.find(semester => String(semester.id) === String(semesterId)) || null;
|
||||
}
|
||||
|
||||
function selectCurrentSemester() {
|
||||
if (!semesterSelect) return;
|
||||
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
|
||||
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
}
|
||||
|
||||
function synchronizeDateWithSemester() {
|
||||
const semester = selectedSemester();
|
||||
if (!semester) {
|
||||
dateInput.value = toIsoDate(new Date());
|
||||
return;
|
||||
}
|
||||
if (!dateWithinSemester(dateInput.value, semester)) {
|
||||
dateInput.value = semester.startDate;
|
||||
}
|
||||
}
|
||||
|
||||
function groupNameById(id) {
|
||||
@@ -727,15 +811,23 @@ export async function initScheduleView() {
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyOverridePanel() {
|
||||
return {
|
||||
setRegistry() {},
|
||||
openLesson() {},
|
||||
openRegistry() {}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/admin/calendar/years').catch(() => [])
|
||||
api.get('/api/schedule/semesters')
|
||||
]);
|
||||
|
||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||
@@ -748,13 +840,14 @@ async function loadDictionaries(role, userDepartmentId) {
|
||||
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
|
||||
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
|
||||
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
|
||||
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
|
||||
|
||||
return {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
departments,
|
||||
semesters: (academicYears || []).flatMap(year => year.semesters || [])
|
||||
semesters: semesters || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,8 +1068,43 @@ function mobileDayCaption(datesByParity) {
|
||||
return 'Нечётная / Чётная';
|
||||
}
|
||||
|
||||
function periodCaption(range) {
|
||||
return 'Учебное расписание';
|
||||
function periodCaption(range, semester = null) {
|
||||
if (!range) return 'Учебное расписание';
|
||||
const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`;
|
||||
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
|
||||
}
|
||||
|
||||
export function semesterOptionLabel(semester) {
|
||||
const type = String(semester?.semesterType || '').toLowerCase();
|
||||
const typeLabel = type === 'autumn'
|
||||
? 'Осенний семестр'
|
||||
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
|
||||
const academicYear = semester?.academicYearTitle || 'Учебный год';
|
||||
const dates = semester?.startDate && semester?.endDate
|
||||
? ` · ${formatDate(semester.startDate)} — ${formatDate(semester.endDate)}`
|
||||
: '';
|
||||
return `${academicYear} · ${typeLabel}${dates}`;
|
||||
}
|
||||
|
||||
export function findSemesterForDate(semesters, date) {
|
||||
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
|
||||
}
|
||||
|
||||
export function scheduleRangeForSemester(selectedDate, semester) {
|
||||
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
|
||||
? semester.startDate
|
||||
: selectedDate;
|
||||
return twoWeekRange(anchorDate);
|
||||
}
|
||||
|
||||
function dateWithinSemester(date, semester) {
|
||||
return Boolean(
|
||||
date
|
||||
&& semester?.startDate
|
||||
&& semester?.endDate
|
||||
&& date >= semester.startDate
|
||||
&& date <= semester.endDate
|
||||
);
|
||||
}
|
||||
|
||||
function lessonSlotKey(lesson) {
|
||||
|
||||
@@ -47,12 +47,18 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="date" id="schedule-view-date" style="display: none;">
|
||||
<input type="date" id="schedule-view-date" hidden>
|
||||
</div>
|
||||
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<div class="schedule-view-extra-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-semester">Семестр</label>
|
||||
<select id="schedule-view-semester">
|
||||
<option value="">По текущей дате</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-subject">Дисциплина</label>
|
||||
<select id="schedule-view-subject">
|
||||
@@ -87,6 +93,10 @@
|
||||
<span class="muted" id="schedule-view-count">0 занятий</span>
|
||||
</div>
|
||||
<div class="schedule-view-result-actions">
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-overrides-open" hidden>
|
||||
Изменения за период
|
||||
<span class="schedule-override-count" id="schedule-view-overrides-count">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
@@ -116,3 +126,102 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="schedule-override-drawer" id="schedule-override-drawer" hidden aria-hidden="true">
|
||||
<button type="button" class="schedule-override-backdrop" data-override-close aria-label="Закрыть панель"></button>
|
||||
<aside class="schedule-override-panel" role="dialog" aria-modal="true" aria-labelledby="schedule-override-title">
|
||||
<header class="schedule-override-header">
|
||||
<div>
|
||||
<span class="schedule-override-eyebrow">Точечное изменение</span>
|
||||
<h2 id="schedule-override-title">Конкретное занятие</h2>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" data-override-close aria-label="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="schedule-override-tabs" aria-label="Разовые изменения">
|
||||
<button type="button" class="schedule-override-tab active" data-override-tab="edit">Редактирование</button>
|
||||
<button type="button" class="schedule-override-tab" data-override-tab="registry">Изменения за период</button>
|
||||
</nav>
|
||||
|
||||
<div class="schedule-override-body">
|
||||
<section data-override-section="edit">
|
||||
<div class="schedule-override-empty" id="schedule-override-empty">
|
||||
Выберите занятие в расписании или откройте его из реестра изменений.
|
||||
</div>
|
||||
|
||||
<form id="schedule-override-form" hidden>
|
||||
<div class="schedule-override-comparison">
|
||||
<article>
|
||||
<span>Было по правилу</span>
|
||||
<strong id="schedule-override-before-title">—</strong>
|
||||
<p id="schedule-override-before-meta">—</p>
|
||||
</article>
|
||||
<article class="schedule-override-after">
|
||||
<span>Станет</span>
|
||||
<strong id="schedule-override-after-title">—</strong>
|
||||
<p id="schedule-override-after-meta">—</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<fieldset class="schedule-override-fieldset">
|
||||
<legend>Дата занятия</legend>
|
||||
<div class="schedule-override-week" id="schedule-override-week"></div>
|
||||
<button type="button" class="btn btn-md btn-secondary schedule-override-calendar-toggle" id="schedule-override-calendar-toggle">
|
||||
Выбрать другую дату
|
||||
</button>
|
||||
<div class="schedule-override-calendar" id="schedule-override-calendar" hidden></div>
|
||||
</fieldset>
|
||||
|
||||
<div class="schedule-override-fields">
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-time">Время</label>
|
||||
<select id="schedule-override-time" data-native-select="true" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-teacher">Преподаватель</label>
|
||||
<select id="schedule-override-teacher" data-native-select="true"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-classroom">Аудитория</label>
|
||||
<select id="schedule-override-classroom" data-native-select="true"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-format">Формат</label>
|
||||
<select id="schedule-override-format" data-native-select="true">
|
||||
<option value="Очно">Очно</option>
|
||||
<option value="Онлайн">Онлайн</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-comment">Комментарий</label>
|
||||
<textarea id="schedule-override-comment" rows="3" maxlength="1000" placeholder="Например, замена аудитории из-за мероприятия"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="schedule-override-alert" role="alert"></div>
|
||||
|
||||
<footer class="schedule-override-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-override-save">Сохранить</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="schedule-override-cancel">Отменить занятие</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-override-restore" hidden>Вернуть по правилу</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section data-override-section="registry" hidden>
|
||||
<div class="schedule-override-registry-heading">
|
||||
<div>
|
||||
<h3>Изменения за выбранный период</h3>
|
||||
<span class="muted" id="schedule-override-registry-period">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-override-registry" id="schedule-override-registry"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
64
frontend/tests/schedule-overrides.test.mjs
Normal file
64
frontend/tests/schedule-overrides.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
canEditScheduleOverrides,
|
||||
classroomOptionLabel,
|
||||
overrideActionForChanges,
|
||||
pickMatchingTimeSlot,
|
||||
weekDates
|
||||
} from '../admin/js/views/schedule-override-panel.js';
|
||||
|
||||
test('редактировать разовые изменения могут только администратор и учебный отдел', () => {
|
||||
assert.equal(canEditScheduleOverrides('ADMIN'), true);
|
||||
assert.equal(canEditScheduleOverrides('EDUCATION_OFFICE'), true);
|
||||
assert.equal(canEditScheduleOverrides('DEPARTMENT'), false);
|
||||
assert.equal(canEditScheduleOverrides('SCHEDULE_VIEWER'), false);
|
||||
});
|
||||
|
||||
test('дата или время формируют MOVE, а замена ресурсов формирует REPLACE', () => {
|
||||
assert.equal(overrideActionForChanges({ dateChanged: true }), 'MOVE');
|
||||
assert.equal(overrideActionForChanges({ timeChanged: true, teacherChanged: true }), 'MOVE');
|
||||
assert.equal(overrideActionForChanges({ classroomChanged: true }), 'REPLACE');
|
||||
assert.equal(overrideActionForChanges({ teacherChanged: true, formatChanged: true }), 'REPLACE');
|
||||
assert.equal(overrideActionForChanges({}), null);
|
||||
});
|
||||
|
||||
test('недельный выбор всегда показывает понедельник-воскресенье исходной недели', () => {
|
||||
assert.deepEqual(weekDates('2026-09-03'), [
|
||||
'2026-08-31',
|
||||
'2026-09-01',
|
||||
'2026-09-02',
|
||||
'2026-09-03',
|
||||
'2026-09-04',
|
||||
'2026-09-05',
|
||||
'2026-09-06'
|
||||
]);
|
||||
});
|
||||
|
||||
test('время выбирается сначала по ID, затем по совпадающему интервалу', () => {
|
||||
const slots = [
|
||||
{ id: 10, startTime: '09:00:00', endTime: '10:30:00' },
|
||||
{ id: 20, startTime: '10:40:00', endTime: '12:10:00' }
|
||||
];
|
||||
|
||||
assert.equal(pickMatchingTimeSlot(slots, 20, '09:00', '10:30').id, 20);
|
||||
assert.equal(pickMatchingTimeSlot(slots, 99, '09:00', '10:30').id, 10);
|
||||
assert.equal(pickMatchingTimeSlot(slots, 99, '12:20', '13:50'), null);
|
||||
});
|
||||
|
||||
test('в селекте боковой панели аудитория показывается только своим названием', () => {
|
||||
assert.equal(classroomOptionLabel({
|
||||
name: '101',
|
||||
building: 'Главный корпус',
|
||||
floor: 1
|
||||
}), '101');
|
||||
});
|
||||
|
||||
test('боковая панель и слой под ней применяют backdrop blur', async () => {
|
||||
const css = await readFile(new URL('../admin/css/auditorium-workload.css', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(css, /\.schedule-override-backdrop\s*\{[^}]*backdrop-filter:\s*blur\(7px\)/s);
|
||||
assert.match(css, /\.schedule-override-panel\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--bg-card\) 88%, transparent\)[^}]*backdrop-filter:\s*blur\(24px\)/s);
|
||||
});
|
||||
53
frontend/tests/schedule-view-semesters.test.mjs
Normal file
53
frontend/tests/schedule-view-semesters.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
findSemesterForDate,
|
||||
scheduleRangeForSemester,
|
||||
semesterOptionLabel
|
||||
} from '../admin/js/views/schedule-view.js';
|
||||
|
||||
const autumnSemester = {
|
||||
id: 11,
|
||||
academicYearTitle: '2025/2026',
|
||||
semesterType: 'autumn',
|
||||
startDate: '2025-09-01',
|
||||
endDate: '2026-01-31'
|
||||
};
|
||||
|
||||
const springSemester = {
|
||||
id: 12,
|
||||
academicYearTitle: '2025/2026',
|
||||
semesterType: 'spring',
|
||||
startDate: '2026-02-09',
|
||||
endDate: '2026-06-30'
|
||||
};
|
||||
|
||||
test('семестр определяется по выбранной дате включительно с границами', () => {
|
||||
const semesters = [autumnSemester, springSemester];
|
||||
|
||||
assert.equal(findSemesterForDate(semesters, '2025-09-01')?.id, 11);
|
||||
assert.equal(findSemesterForDate(semesters, '2026-06-30')?.id, 12);
|
||||
assert.equal(findSemesterForDate(semesters, '2026-07-01'), null);
|
||||
});
|
||||
|
||||
test('при выборе другого семестра диапазон начинается с его первой учебной недели', () => {
|
||||
assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), {
|
||||
startDate: '2025-09-01',
|
||||
endDate: '2025-09-14'
|
||||
});
|
||||
});
|
||||
|
||||
test('внутри выбранного семестра сохраняется текущая двухнедельная точка просмотра', () => {
|
||||
assert.deepEqual(scheduleRangeForSemester('2026-03-18', springSemester), {
|
||||
startDate: '2026-03-16',
|
||||
endDate: '2026-03-29'
|
||||
});
|
||||
});
|
||||
|
||||
test('название семестра содержит учебный год, тип и границы', () => {
|
||||
assert.equal(
|
||||
semesterOptionLabel(springSemester),
|
||||
'2025/2026 · Весенний семестр · 09.02.2026 — 30.06.2026'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user