feat: улучшить учебные периоды и просмотр расписаний
This commit is contained in:
@@ -1014,9 +1014,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 15, 0.97);
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.schedule-visual-context-menu[hidden] {
|
||||
@@ -1325,6 +1325,152 @@
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.academic-year-builder-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.academic-year-builder-description {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.academic-year-period-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#academic-year-end[readonly] {
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--bg-input) 82%, var(--accent) 5%);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.academic-year-field-hint {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder {
|
||||
margin-top: 1rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-input) 78%, transparent);
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head,
|
||||
.academic-year-semester-card-title,
|
||||
.academic-year-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head h3,
|
||||
.academic-year-semester-builder-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head h3 {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head p {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.academic-year-required-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent-hover);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.academic-year-semester-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.academic-year-semester-card {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.academic-year-semester-card-title {
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.academic-year-semester-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.academic-year-semester-dot-autumn {
|
||||
color: #f59e0b;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.academic-year-semester-dot-spring {
|
||||
color: #22c55e;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.academic-year-semester-dates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.academic-year-form-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.academic-year-semester-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.academic-year-period-grid,
|
||||
.academic-year-semester-dates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.academic-year-semester-builder-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.academic-year-form-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-inline-table {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -2148,6 +2294,20 @@
|
||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
|
||||
}
|
||||
|
||||
.calendar-activity-color-10b981 { --activity-color: #10b981; }
|
||||
.calendar-activity-color-f59e0b { --activity-color: #f59e0b; }
|
||||
.calendar-activity-color-38bdf8 { --activity-color: #38bdf8; }
|
||||
.calendar-activity-color-a78bfa { --activity-color: #a78bfa; }
|
||||
.calendar-activity-color-fb7185 { --activity-color: #fb7185; }
|
||||
.calendar-activity-color-f97316 { --activity-color: #f97316; }
|
||||
.calendar-activity-color-22c55e { --activity-color: #22c55e; }
|
||||
.calendar-activity-color-ef4444 { --activity-color: #ef4444; }
|
||||
.calendar-activity-color-dc2626 { --activity-color: #dc2626; }
|
||||
.calendar-activity-color-eab308 { --activity-color: #eab308; }
|
||||
.calendar-activity-color-06b6d4 { --activity-color: #06b6d4; }
|
||||
.calendar-activity-color-64748b { --activity-color: #64748b; }
|
||||
.calendar-activity-color-475569 { --activity-color: #475569; }
|
||||
|
||||
.calendar-day:hover,
|
||||
.calendar-day:focus-visible {
|
||||
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
|
||||
@@ -2311,7 +2471,7 @@
|
||||
}
|
||||
|
||||
.calendar-total-chip {
|
||||
--chip-color: var(--accent);
|
||||
--chip-color: var(--activity-color, var(--accent));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
@@ -54,25 +54,107 @@ export function calculateCalendarTooltipPosition({
|
||||
};
|
||||
}
|
||||
|
||||
export function academicYearDefaults(startYearValue) {
|
||||
const value = String(startYearValue || '').trim();
|
||||
if (!/^\d{4}$/.test(value)) {
|
||||
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
|
||||
}
|
||||
const startYear = Number(value);
|
||||
if (startYear < 1000 || startYear > 9998) {
|
||||
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
|
||||
}
|
||||
const endYear = startYear + 1;
|
||||
|
||||
const start = new Date(Date.UTC(startYear, 8, 1));
|
||||
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
|
||||
const springStart = new Date(Date.UTC(endYear, 1, 1));
|
||||
const end = new Date(Date.UTC(endYear, 5, 30));
|
||||
|
||||
return {
|
||||
startYear,
|
||||
endYear,
|
||||
title: `${startYear}-${endYear}`,
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(end),
|
||||
semesters: [
|
||||
{
|
||||
semesterType: 'autumn',
|
||||
startDate: toIsoDateUtc(start),
|
||||
endDate: toIsoDateUtc(autumnEnd)
|
||||
},
|
||||
{
|
||||
semesterType: 'spring',
|
||||
startDate: toIsoDateUtc(springStart),
|
||||
endDate: toIsoDateUtc(end)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
|
||||
if (!Array.isArray(semesters) || semesters.length === 0) {
|
||||
throw new Error('Учебный год нельзя создать без семестров');
|
||||
}
|
||||
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||||
const types = new Set();
|
||||
sorted.forEach((semester, index) => {
|
||||
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
|
||||
throw new Error('Заполните тип и даты каждого семестра');
|
||||
}
|
||||
if (types.has(semester.semesterType)) {
|
||||
throw new Error('Типы семестров не должны повторяться');
|
||||
}
|
||||
types.add(semester.semesterType);
|
||||
if (semester.endDate < semester.startDate) {
|
||||
throw new Error('Дата окончания семестра не может быть раньше даты начала');
|
||||
}
|
||||
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
|
||||
throw new Error('Семестры должны находиться в границах учебного года');
|
||||
}
|
||||
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
|
||||
throw new Error('Периоды семестров не должны пересекаться');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toIsoDateUtc(date) {
|
||||
return [
|
||||
date.getUTCFullYear(),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
export function activityColorCode(activity) {
|
||||
const color = String(activity?.colorCode || '').trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
|
||||
}
|
||||
|
||||
export function activityColorClass(activity) {
|
||||
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export async function initAcademicCalendar() {
|
||||
const academicYearForm = document.getElementById('academic-year-form');
|
||||
const academicYearFormTitle = document.getElementById('academic-year-form-title');
|
||||
const academicYearIdInput = document.getElementById('academic-year-id');
|
||||
const academicYearTitleInput = document.getElementById('academic-year-title');
|
||||
const academicYearStartInput = document.getElementById('academic-year-start');
|
||||
const academicYearEndInput = document.getElementById('academic-year-end');
|
||||
const academicYearResetButton = document.getElementById('academic-year-reset');
|
||||
const academicYearsTbody = document.getElementById('academic-years-tbody');
|
||||
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
|
||||
|
||||
const semesterForm = document.getElementById('semester-form');
|
||||
const semesterFormTitle = document.getElementById('semester-form-title');
|
||||
const semesterIdInput = document.getElementById('semester-id');
|
||||
const semesterYearSelect = document.getElementById('semester-year');
|
||||
const semesterTypeSelect = document.getElementById('semester-type');
|
||||
const semesterStartInput = document.getElementById('semester-start');
|
||||
const semesterEndInput = document.getElementById('semester-end');
|
||||
const semesterResetButton = document.getElementById('semester-reset');
|
||||
const academicYearSemesterInputs = {
|
||||
autumn: {
|
||||
id: document.getElementById('academic-year-autumn-id'),
|
||||
start: document.getElementById('academic-year-autumn-start'),
|
||||
end: document.getElementById('academic-year-autumn-end')
|
||||
},
|
||||
spring: {
|
||||
id: document.getElementById('academic-year-spring-id'),
|
||||
start: document.getElementById('academic-year-spring-start'),
|
||||
end: document.getElementById('academic-year-spring-end')
|
||||
}
|
||||
};
|
||||
|
||||
const calendarForm = document.getElementById('academic-calendar-form');
|
||||
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
||||
@@ -119,7 +201,6 @@ export async function initAcademicCalendar() {
|
||||
const subjectCount = document.getElementById('calendar-subject-count');
|
||||
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let specialties = [];
|
||||
let profilesBySpecialty = new Map();
|
||||
let studyForms = [];
|
||||
@@ -158,10 +239,10 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function bindEvents() {
|
||||
[
|
||||
academicYearStartInput,
|
||||
academicYearEndInput,
|
||||
semesterStartInput,
|
||||
semesterEndInput,
|
||||
academicYearSemesterInputs.autumn.start,
|
||||
academicYearSemesterInputs.autumn.end,
|
||||
academicYearSemesterInputs.spring.start,
|
||||
academicYearSemesterInputs.spring.end,
|
||||
fillStartInput,
|
||||
fillEndInput
|
||||
].forEach(bindDateInputMask);
|
||||
@@ -172,7 +253,7 @@ export async function initAcademicCalendar() {
|
||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||||
semesterResetButton?.addEventListener('click', resetSemesterForm);
|
||||
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
|
||||
calendarResetButton?.addEventListener('click', resetCalendarForm);
|
||||
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
|
||||
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
||||
@@ -244,7 +325,6 @@ export async function initAcademicCalendar() {
|
||||
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
|
||||
|
||||
academicYearForm?.addEventListener('submit', saveAcademicYear);
|
||||
semesterForm?.addEventListener('submit', saveSemester);
|
||||
calendarForm?.addEventListener('submit', saveCalendar);
|
||||
|
||||
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
|
||||
@@ -269,7 +349,6 @@ export async function initAcademicCalendar() {
|
||||
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
renderAcademicYears();
|
||||
populateYearSelects();
|
||||
} catch (error) {
|
||||
@@ -319,17 +398,19 @@ export async function initAcademicCalendar() {
|
||||
event.preventDefault();
|
||||
hideAlert('academic-year-alert');
|
||||
try {
|
||||
const defaults = applyAcademicYearDateDefaults(true);
|
||||
const { startDate, endDate } = defaults;
|
||||
const payload = {
|
||||
title: academicYearTitleInput.value.trim(),
|
||||
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
|
||||
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
|
||||
startDate,
|
||||
endDate,
|
||||
semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
|
||||
};
|
||||
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
|
||||
validateAcademicYearSemesters(payload.semesters, startDate, endDate);
|
||||
const id = academicYearIdInput.value;
|
||||
await (id
|
||||
? api.put('/api/admin/calendar/years/' + id, payload)
|
||||
: api.post('/api/admin/calendar/years', payload));
|
||||
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
|
||||
showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
|
||||
resetAcademicYearForm();
|
||||
await loadYears();
|
||||
await loadCalendars();
|
||||
@@ -346,28 +427,16 @@ export async function initAcademicCalendar() {
|
||||
if (editYearButton) {
|
||||
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
|
||||
if (!year) return;
|
||||
academicYearFormTitle.textContent = 'Редактирование учебного года';
|
||||
academicYearIdInput.value = year.id;
|
||||
academicYearTitleInput.value = year.title || '';
|
||||
setDateInputIso(academicYearStartInput, year.startDate);
|
||||
setDateInputIso(academicYearEndInput, year.endDate);
|
||||
hideAlert('academic-year-alert');
|
||||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
fillAcademicYearForm(year);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editSemesterButton) {
|
||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||
if (!semester) return;
|
||||
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||||
semesterIdInput.value = semester.id;
|
||||
semesterYearSelect.value = semester.academicYearId || '';
|
||||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||||
setDateInputIso(semesterStartInput, semester.startDate);
|
||||
setDateInputIso(semesterEndInput, semester.endDate);
|
||||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||||
hideAlert('semester-alert');
|
||||
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const year = academicYears.find(item => (item.semesters || [])
|
||||
.some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
|
||||
if (!year) return;
|
||||
const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
|
||||
fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -386,42 +455,73 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function resetAcademicYearForm() {
|
||||
academicYearForm.reset();
|
||||
academicYearFormTitle.textContent = 'Учебный год';
|
||||
academicYearFormTitle.textContent = 'Учебный год и семестры';
|
||||
academicYearIdInput.value = '';
|
||||
Object.values(academicYearSemesterInputs).forEach(inputs => {
|
||||
inputs.id.value = '';
|
||||
});
|
||||
hideAlert('academic-year-alert');
|
||||
}
|
||||
|
||||
async function saveSemester(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('semester-alert');
|
||||
const yearId = semesterYearSelect.value;
|
||||
try {
|
||||
const payload = {
|
||||
semesterType: semesterTypeSelect.value,
|
||||
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
|
||||
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
|
||||
};
|
||||
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
|
||||
throw new Error('Заполните учебный год, тип и даты семестра');
|
||||
}
|
||||
const id = semesterIdInput.value;
|
||||
await (id
|
||||
? api.put('/api/admin/calendar/semesters/' + id, payload)
|
||||
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
|
||||
showAlert('semester-alert', 'Семестр сохранён', 'success');
|
||||
resetSemesterForm();
|
||||
await loadYears();
|
||||
} catch (error) {
|
||||
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
|
||||
function fillAcademicYearForm(year, focusedSemesterType = '') {
|
||||
academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
|
||||
academicYearIdInput.value = year.id;
|
||||
academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
|
||||
academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
|
||||
['autumn', 'spring'].forEach(type => {
|
||||
const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
|
||||
const inputs = academicYearSemesterInputs[type];
|
||||
inputs.id.value = semester?.id || '';
|
||||
setDateInputIso(inputs.start, semester?.startDate || '');
|
||||
setDateInputIso(inputs.end, semester?.endDate || '');
|
||||
});
|
||||
hideAlert('academic-year-alert');
|
||||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
|
||||
academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function resetSemesterForm() {
|
||||
semesterForm.reset();
|
||||
semesterFormTitle.textContent = 'Семестр';
|
||||
semesterIdInput.value = '';
|
||||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||||
hideAlert('semester-alert');
|
||||
function handleAcademicYearStartInput() {
|
||||
academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
|
||||
if (academicYearStartInput.value.length !== 4) {
|
||||
academicYearEndInput.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyAcademicYearDateDefaults();
|
||||
} catch (_) {
|
||||
academicYearEndInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
|
||||
const defaults = academicYearDefaults(academicYearStartInput.value);
|
||||
academicYearEndInput.value = String(defaults.endYear);
|
||||
const semesterDefaults = {
|
||||
autumn: defaults.semesters[0],
|
||||
spring: defaults.semesters[1]
|
||||
};
|
||||
['autumn', 'spring'].forEach(type => {
|
||||
const inputs = academicYearSemesterInputs[type];
|
||||
if (!preserveSemesterDates || !inputs.start.value) {
|
||||
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
|
||||
}
|
||||
if (!preserveSemesterDates || !inputs.end.value) {
|
||||
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function readAcademicYearSemester(type) {
|
||||
const inputs = academicYearSemesterInputs[type];
|
||||
return {
|
||||
id: inputs.id.value ? Number(inputs.id.value) : null,
|
||||
semesterType: type,
|
||||
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
|
||||
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
|
||||
};
|
||||
}
|
||||
|
||||
async function loadStudyForms() {
|
||||
@@ -895,7 +995,7 @@ export async function initAcademicCalendar() {
|
||||
const activity = activityById(selected) || activityTypes[0] || {};
|
||||
const code = activity.code || row.activityCode || 'Т';
|
||||
return `
|
||||
<td class="calendar-day"
|
||||
<td class="calendar-day ${activityColorClass(activity)}"
|
||||
data-course="${row.courseNumber}"
|
||||
data-date="${row.date}"
|
||||
data-week="${row.weekNumber}"
|
||||
@@ -967,9 +1067,11 @@ export async function initAcademicCalendar() {
|
||||
function updateCalendarDayDialogSummary(day) {
|
||||
const activity = activityById(day.querySelector('.calendar-day-activity').value);
|
||||
if (!activity) {
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, null);
|
||||
calendarDayDialogActivitySummary.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
|
||||
<div>
|
||||
@@ -987,6 +1089,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
const activity = activityById(commonCalendarDayActivityValue(days));
|
||||
if (!activity) {
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, null);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">...</span>
|
||||
<div>
|
||||
@@ -997,6 +1100,7 @@ export async function initAcademicCalendar() {
|
||||
return;
|
||||
}
|
||||
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
|
||||
<div>
|
||||
@@ -1091,6 +1195,7 @@ export async function initAcademicCalendar() {
|
||||
const select = day.querySelector('.calendar-day-activity');
|
||||
const activity = activityById(select.value);
|
||||
const code = activity?.code || '-';
|
||||
applyActivityColorClass(day, activity);
|
||||
day.querySelector('.calendar-day-code').textContent = code;
|
||||
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
|
||||
}
|
||||
@@ -1102,6 +1207,8 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
const tooltip = ensureCalendarDayTooltip();
|
||||
tooltip.innerHTML = renderCalendarDayTooltip(day);
|
||||
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
|
||||
applyActivityColorClass(tooltip, activity);
|
||||
tooltip.classList.add('visible');
|
||||
positionCalendarDayTooltip(tooltip, day, event);
|
||||
}
|
||||
@@ -1285,7 +1392,9 @@ export async function initAcademicCalendar() {
|
||||
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
|
||||
const activity = activityById(select.value);
|
||||
const key = activity ? activity.code : '-';
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
|
||||
current.count += 1;
|
||||
counts.set(key, current);
|
||||
});
|
||||
const selectedDays = selectedCalendarDays.length
|
||||
? selectedCalendarDays
|
||||
@@ -1296,19 +1405,18 @@ export async function initAcademicCalendar() {
|
||||
const selectedCode = selectedActivity?.code || null;
|
||||
calendarTotals.innerHTML = Array.from(counts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
||||
.map(([code, count]) => {
|
||||
.map(([code, entry]) => {
|
||||
const selectedClass = selectedCode === code ? ' is-selected' : '';
|
||||
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
|
||||
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`;
|
||||
return `<span class="calendar-total-chip ${entry.colorClass}${selectedClass}">${escapeHtml(code)}: ${entry.count}${suffix}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateYearSelects() {
|
||||
const yearOptions = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
semesterYearSelect.innerHTML = yearOptions;
|
||||
calendarYearSelect.innerHTML = yearOptions;
|
||||
syncSelects(semesterYearSelect, calendarYearSelect);
|
||||
syncSelects(calendarYearSelect);
|
||||
}
|
||||
|
||||
function populateSpecialtySelects() {
|
||||
@@ -1474,6 +1582,16 @@ export async function initAcademicCalendar() {
|
||||
return activityTypes.find(type => String(type.id) === String(id));
|
||||
}
|
||||
|
||||
function applyActivityColorClass(element, activity) {
|
||||
if (!element) return;
|
||||
Array.from(element.classList)
|
||||
.filter(className => className.startsWith('calendar-activity-color-'))
|
||||
.forEach(className => element.classList.remove(className));
|
||||
if (activity) {
|
||||
element.classList.add(activityColorClass(activity));
|
||||
}
|
||||
}
|
||||
|
||||
function timeAssignmentForDate(date) {
|
||||
return timeSlotDateAssignments.find(item => item.date === date);
|
||||
}
|
||||
|
||||
@@ -421,6 +421,11 @@ export async function initScheduleView() {
|
||||
|
||||
function renderDesktopSchedule(section, range) {
|
||||
const slots = collectSlots(section.lessons);
|
||||
const semester = selectedSemester();
|
||||
if (semester) {
|
||||
renderDesktopSemesterSchedule(section, slots, semester);
|
||||
return;
|
||||
}
|
||||
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
|
||||
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
|
||||
if (!blocks.length || !slots.length) {
|
||||
@@ -432,6 +437,50 @@ export async function initScheduleView() {
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderDesktopSemesterSchedule(section, slots, semester) {
|
||||
if (!slots.length) {
|
||||
setTableState('Занятия не найдены');
|
||||
return;
|
||||
}
|
||||
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
|
||||
const dayHeaders = DAY_ORDER.map(day => `
|
||||
<th class="room-day-cell">
|
||||
<strong>${escapeHtml(dayShort(day))}</strong>
|
||||
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
|
||||
</th>
|
||||
`).join('');
|
||||
const rows = slots.map(slot => `
|
||||
<tr>
|
||||
<td class="room-time-cell">
|
||||
<strong>${escapeHtml(slot.orderLabel)}</strong>
|
||||
<span>${escapeHtml(slot.timeLabel)}</span>
|
||||
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
|
||||
</td>
|
||||
${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
tables.innerHTML = `
|
||||
<section class="schedule-view-week-panel schedule-view-semester-panel">
|
||||
<div class="room-week-heading">
|
||||
<h3>${escapeHtml(section.title)}</h3>
|
||||
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
|
||||
</div>
|
||||
<div class="workload-grid-container schedule-view-grid-container">
|
||||
<table class="workload-table room-week-table schedule-view-matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="room-time-heading">Время</th>
|
||||
${dayHeaders}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMobileDayTabs() {
|
||||
dayTabs.hidden = false;
|
||||
dayTabs.innerHTML = DAY_ORDER.map(day => `
|
||||
@@ -451,6 +500,11 @@ export async function initScheduleView() {
|
||||
|
||||
function renderMobileSchedule(section, range) {
|
||||
const slots = collectSlots(section.lessons);
|
||||
const semester = selectedSemester();
|
||||
if (semester) {
|
||||
renderMobileSemesterSchedule(section, slots, semester);
|
||||
return;
|
||||
}
|
||||
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
|
||||
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
|
||||
if (!blocks.length || !slots.length) {
|
||||
@@ -460,6 +514,34 @@ export async function initScheduleView() {
|
||||
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
|
||||
}
|
||||
|
||||
function renderMobileSemesterSchedule(section, slots, semester) {
|
||||
if (!slots.length) {
|
||||
setTableState('Занятия не найдены');
|
||||
return;
|
||||
}
|
||||
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
|
||||
const rows = slots.map(slot => `
|
||||
<article class="schedule-view-mobile-slot">
|
||||
<div class="schedule-view-mobile-slot-time">
|
||||
<strong>${escapeHtml(slot.orderLabel)}</strong>
|
||||
<span>${escapeHtml(slot.timeLabel)}</span>
|
||||
</div>
|
||||
<div class="schedule-view-mobile-slot-content">
|
||||
${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))}
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
tables.innerHTML = `
|
||||
<section class="schedule-view-mobile-day schedule-view-mobile-semester">
|
||||
<div class="schedule-view-mobile-day-heading">
|
||||
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
|
||||
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
|
||||
</div>
|
||||
${rows}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
|
||||
const dayDates = block.datesByDay[state.mobileDay] || {};
|
||||
const rows = slots.map(slot => {
|
||||
@@ -537,6 +619,12 @@ export async function initScheduleView() {
|
||||
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
|
||||
}
|
||||
|
||||
function renderSemesterSlotCell(day, slot, lessonMatrix) {
|
||||
const states = slotStatesForSemester(day, slot, lessonMatrix);
|
||||
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
|
||||
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
|
||||
}
|
||||
|
||||
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
|
||||
parity,
|
||||
@@ -545,6 +633,54 @@ export async function initScheduleView() {
|
||||
}));
|
||||
}
|
||||
|
||||
function slotStatesForSemester(day, slot, lessonMatrix) {
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
|
||||
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
|
||||
return {
|
||||
parity,
|
||||
label,
|
||||
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSemesterLessonMatrix(lessons) {
|
||||
const weekRanges = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const signature = lessonContentSignature(lesson);
|
||||
const weekNumber = Number(lesson.weekNumber);
|
||||
if (!Number.isFinite(weekNumber)) return;
|
||||
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
|
||||
range.startWeek = Math.min(range.startWeek, weekNumber);
|
||||
range.endWeek = Math.max(range.endWeek, weekNumber);
|
||||
weekRanges.set(signature, range);
|
||||
});
|
||||
|
||||
const matrix = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
|
||||
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
|
||||
? lesson.parity
|
||||
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
|
||||
const signature = lessonContentSignature(lesson);
|
||||
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
|
||||
if (!matrix.has(key)) matrix.set(key, new Map());
|
||||
if (!matrix.get(key).has(signature)) {
|
||||
const range = weekRanges.get(signature);
|
||||
matrix.get(key).set(signature, {
|
||||
...lesson,
|
||||
displayStartWeek: range?.startWeek,
|
||||
displayEndWeek: range?.endWeek
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
|
||||
key,
|
||||
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
|
||||
]));
|
||||
}
|
||||
|
||||
function renderSlotContent(states) {
|
||||
if (slotStatesEqual(states[0].state, states[1].state)) {
|
||||
return renderUnifiedSlot(states[0].state);
|
||||
@@ -640,6 +776,9 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
function getLessonWeeksText(lesson) {
|
||||
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
|
||||
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
|
||||
}
|
||||
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
|
||||
return '';
|
||||
}
|
||||
@@ -667,42 +806,7 @@ export async function initScheduleView() {
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||
}
|
||||
|
||||
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
|
||||
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
|
||||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
|
||||
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
|
||||
return startWeek === endWeek
|
||||
? `(${startWeek} нед.)`
|
||||
: `(с ${startWeek} по ${endWeek} нед.)`;
|
||||
}
|
||||
|
||||
function semesterWeeksForLesson(lesson) {
|
||||
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
|
||||
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||
const start = parseIsoDate(semester.startDate);
|
||||
const end = parseIsoDate(semester.endDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
return Math.max(1, Math.ceil(days / 7));
|
||||
}
|
||||
|
||||
function dateInRange(date, startDate, endDate) {
|
||||
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
|
||||
}
|
||||
|
||||
function firstApplicableSemesterWeek(parity) {
|
||||
return parity === 'EVEN' ? 2 : 1;
|
||||
}
|
||||
|
||||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||
const totalWeeks = Number(maxWeeks || 0);
|
||||
if (!totalWeeks) return 0;
|
||||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||
return totalWeeks;
|
||||
return formatLessonWeeksText(startWeek, endWeek);
|
||||
}
|
||||
|
||||
function renderLessonCard(lesson) {
|
||||
@@ -801,8 +905,8 @@ export async function initScheduleView() {
|
||||
|
||||
function selectCurrentSemester() {
|
||||
if (!semesterSelect) return;
|
||||
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
|
||||
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
|
||||
const defaultSemester = findDefaultSemester(state.dictionaries.semesters, dateInput.value);
|
||||
semesterSelect.value = defaultSemester ? String(defaultSemester.id) : '';
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
}
|
||||
@@ -1107,11 +1211,42 @@ export function findSemesterForDate(semesters, date) {
|
||||
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
|
||||
}
|
||||
|
||||
export function findDefaultSemester(semesters, date) {
|
||||
const availableSemesters = semesters || [];
|
||||
return findSemesterForDate(availableSemesters, date) || availableSemesters[0] || null;
|
||||
}
|
||||
|
||||
export function scheduleRangeForSemester(selectedDate, semester) {
|
||||
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
|
||||
? semester.startDate
|
||||
: selectedDate;
|
||||
return twoWeekRange(anchorDate);
|
||||
if (semester?.startDate && semester?.endDate) {
|
||||
return {
|
||||
startDate: semester.startDate,
|
||||
endDate: semester.endDate
|
||||
};
|
||||
}
|
||||
return twoWeekRange(selectedDate);
|
||||
}
|
||||
|
||||
export function formatLessonWeeksText(startWeek, endWeek) {
|
||||
const start = Number(startWeek);
|
||||
const end = Number(endWeek);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
|
||||
return start === end
|
||||
? `(${start} нед.)`
|
||||
: `(с ${start} по ${end} нед.)`;
|
||||
}
|
||||
|
||||
function semesterScheduleCaption(semester) {
|
||||
const weeks = semesterWeekCount(semester);
|
||||
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
|
||||
return `Весь семестр · ${weekLabel}`;
|
||||
}
|
||||
|
||||
function semesterWeekCount(semester) {
|
||||
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||
const start = parseIsoDate(semester.startDate);
|
||||
const end = parseIsoDate(semester.endDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
return Math.max(1, Math.ceil(days / 7));
|
||||
}
|
||||
|
||||
function dateWithinSemester(date, semester) {
|
||||
|
||||
@@ -29,6 +29,33 @@ const SEMESTER_LABELS = {
|
||||
|
||||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
const ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
const VISUAL_MENU_GAP = 8;
|
||||
const VISUAL_MENU_VIEWPORT_PADDING = 8;
|
||||
|
||||
export function calculateVisualContextMenuPosition({
|
||||
anchorRect,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
}) {
|
||||
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
|
||||
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
|
||||
let left = anchorRect.right + VISUAL_MENU_GAP;
|
||||
let top = anchorRect.top;
|
||||
|
||||
if (left > maxLeft) {
|
||||
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
|
||||
}
|
||||
if (top > maxTop) {
|
||||
top = anchorRect.bottom - menuHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||||
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
@@ -160,6 +187,7 @@ export async function initSchedule() {
|
||||
let syncingVisualPeriod = false;
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let visualContextMenuPositionAnimation = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
|
||||
@@ -207,6 +235,7 @@ export async function initSchedule() {
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
|
||||
conflictCloseButton?.addEventListener('click', closeConflictModal);
|
||||
conflictCancelButton?.addEventListener('click', closeConflictModal);
|
||||
conflictModal?.addEventListener('click', (event) => {
|
||||
@@ -555,6 +584,7 @@ export async function initSchedule() {
|
||||
|
||||
function closeVisualDrawer() {
|
||||
if (!visualDrawer) return;
|
||||
closeVisualContextMenu();
|
||||
visualDrawer.classList.remove('open');
|
||||
visualDrawer.setAttribute('aria-hidden', 'true');
|
||||
visualToggleButton?.classList.remove('open');
|
||||
@@ -882,6 +912,23 @@ export async function initSchedule() {
|
||||
};
|
||||
if (!visualContextMenu) return;
|
||||
visualContextMenu.hidden = false;
|
||||
const anchorRect = button.getBoundingClientRect();
|
||||
const position = calculateVisualContextMenuPosition({
|
||||
anchorRect,
|
||||
menuWidth: visualContextMenu.offsetWidth,
|
||||
menuHeight: visualContextMenu.offsetHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight
|
||||
});
|
||||
visualContextMenuPositionAnimation?.cancel();
|
||||
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
|
||||
visualContextMenuPositionAnimation = visualContextMenu.animate(
|
||||
[
|
||||
{ transform: positionedTransform },
|
||||
{ transform: positionedTransform }
|
||||
],
|
||||
{ duration: 1, fill: 'forwards' }
|
||||
);
|
||||
visualContextMenu.querySelector('button')?.focus();
|
||||
}
|
||||
|
||||
@@ -889,6 +936,8 @@ export async function initSchedule() {
|
||||
visualContextTarget = null;
|
||||
if (!visualContextMenu) return;
|
||||
visualContextMenu.hidden = true;
|
||||
visualContextMenuPositionAnimation?.cancel();
|
||||
visualContextMenuPositionAnimation = null;
|
||||
}
|
||||
|
||||
async function handleVisualContextMenuClick(event) {
|
||||
|
||||
@@ -5,25 +5,77 @@
|
||||
</div>
|
||||
|
||||
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||
<div class="card create-card">
|
||||
<h2 id="academic-year-form-title">Учебный год</h2>
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
|
||||
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="form-row">
|
||||
<div class="academic-year-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-title">Название</label>
|
||||
<input type="text" id="academic-year-title" placeholder="2026-2027" required>
|
||||
<label for="academic-year-start">Год начала учебного года</label>
|
||||
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
|
||||
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Начало</label>
|
||||
<input type="text" id="academic-year-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
<label for="academic-year-end">Год окончания учебного года</label>
|
||||
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
|
||||
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Окончание</label>
|
||||
<input type="text" id="academic-year-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
|
||||
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
|
||||
<div class="academic-year-semester-builder-head">
|
||||
<div>
|
||||
<h3 id="academic-year-semesters-title">Семестры</h3>
|
||||
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
|
||||
</div>
|
||||
<span class="academic-year-required-badge">Обязательно</span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
<div class="academic-year-semester-grid">
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
|
||||
<input type="hidden" id="academic-year-autumn-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
|
||||
<strong>Осенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-start">Начало</label>
|
||||
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-end">Окончание</label>
|
||||
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="spring">
|
||||
<input type="hidden" id="academic-year-spring-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
|
||||
<strong>Весенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-start">Начало</label>
|
||||
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-end">Окончание</label>
|
||||
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="academic-year-form-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -53,39 +105,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2 id="semester-form-title">Семестр</h2>
|
||||
<form id="semester-form" novalidate>
|
||||
<input type="hidden" id="semester-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="semester-year">Учебный год</label>
|
||||
<select id="semester-year" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-type">Тип семестра</label>
|
||||
<select id="semester-type" required>
|
||||
<option value="autumn">Осенний</option>
|
||||
<option value="spring">Весенний</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-start">Начало</label>
|
||||
<input type="text" id="semester-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-end">Окончание</label>
|
||||
<input type="text" id="semester-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="semester-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
|
||||
|
||||
@@ -158,10 +158,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
|
||||
<button type="button" data-action="edit-rule">Открыть правило</button>
|
||||
<button type="button" data-action="move-slot">Изменить день и пару</button>
|
||||
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
|
||||
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" role="menu" hidden>
|
||||
<button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
|
||||
<button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
|
||||
<button type="button" role="menuitem" data-action="delete-rule" class="danger">Удалить правило</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
|
||||
Reference in New Issue
Block a user