комит

This commit is contained in:
dipatrik10
2026-07-27 00:33:16 +03:00
parent 92ff87a917
commit 7e29c53ce9
21 changed files with 890 additions and 91 deletions

View File

@@ -0,0 +1,39 @@
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export function academicCalendarWeekNumber(academicYearStartIso, dateIso) {
const academicYearStart = parseIsoDate(academicYearStartIso);
const date = parseIsoDate(dateIso);
const daysFromYearStart = (date.timestamp - academicYearStart.timestamp) / MILLISECONDS_PER_DAY;
if (!Number.isInteger(daysFromYearStart) || daysFromYearStart < 0) {
throw new RangeError('Дата ячейки не может быть раньше начала учебного года');
}
const daysBeforeYearStartInFirstWeek = academicYearStart.dayOfWeek - 1;
return Math.floor((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7) + 1;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
if (!match) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const [, yearText, monthText, dayText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const timestamp = Date.UTC(year, month - 1, day);
const date = new Date(timestamp);
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const jsDayOfWeek = date.getUTCDay();
return {
timestamp,
dayOfWeek: jsDayOfWeek === 0 ? 7 : jsDayOfWeek
};
}

View File

@@ -0,0 +1,21 @@
const TITLE_SEPARATOR = ' — ';
function normalizeTitlePart(value) {
return String(value ?? '').trim();
}
export function buildAcademicCalendarTitle({
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
} = {}) {
const parts = [
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
].map(normalizeTitlePart);
return parts.every(Boolean) ? parts.join(TITLE_SEPARATOR) : '';
}

View File

@@ -1,5 +1,8 @@
import { api } from '../api.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -16,6 +19,41 @@ const SEMESTER_LABELS = {
spring: 'весенний'
};
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
@@ -39,7 +77,6 @@ export async function initAcademicCalendar() {
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
@@ -100,6 +137,7 @@ export async function initAcademicCalendar() {
let suppressCalendarDayClick = false;
let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null;
bindEvents();
@@ -119,6 +157,15 @@ export async function initAcademicCalendar() {
}
function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
});
@@ -271,12 +318,12 @@ export async function initAcademicCalendar() {
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try {
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
};
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
@@ -302,8 +349,8 @@ export async function initAcademicCalendar() {
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || '';
academicYearEndInput.value = year.endDate || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
@@ -316,8 +363,8 @@ export async function initAcademicCalendar() {
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || '';
semesterEndInput.value = semester.endDate || '';
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -348,12 +395,12 @@ export async function initAcademicCalendar() {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
@@ -420,7 +467,7 @@ export async function initAcademicCalendar() {
}
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
@@ -441,7 +488,7 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert');
const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
title: buildCalendarTitleFromForm(),
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
@@ -457,7 +504,8 @@ export async function initAcademicCalendar() {
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
const savedTitle = buildAcademicCalendarTitle(saved) || payload.title;
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm();
await loadCalendars();
} catch (error) {
@@ -477,7 +525,6 @@ export async function initAcademicCalendar() {
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
@@ -721,12 +768,19 @@ export async function initAcademicCalendar() {
function applyCalendarFill() {
hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value;
const start = fillStartInput.value;
const end = fillEndInput.value;
if (!activityId || !start || !end) {
if (!activityId || !fillStartInput.value || !fillEndInput.value) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return;
}
let start;
let end;
try {
start = readDateInputIso(fillStartInput, 'Дата начала диапазона');
end = readDateInputIso(fillEndInput, 'Дата окончания диапазона');
} catch (error) {
showAlert('calendar-editor-alert', error.message, 'error');
return;
}
if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
@@ -1051,12 +1105,33 @@ export async function initAcademicCalendar() {
const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
calendarDayTooltip?.classList.remove('visible');
}
function positionCalendarDayTooltip(tooltip, day, event) {
const dayRect = day.getBoundingClientRect();
const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right;
const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom;
const tooltipRect = tooltip.getBoundingClientRect();
const { left, top } = calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth: tooltipRect.width,
tooltipHeight: tooltipRect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
calendarDayTooltipPositionAnimation?.cancel();
calendarDayTooltipPositionAnimation = tooltip.animate(
[{ left: `${left}px`, top: `${top}px` }],
{ duration: 0, fill: 'forwards' }
);
}
function ensureCalendarDayTooltip() {
if (calendarDayTooltip) return calendarDayTooltip;
calendarDayTooltip = document.createElement('div');
@@ -1156,13 +1231,13 @@ export async function initAcademicCalendar() {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => {
dates.forEach(date => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: Math.floor(index / 7) + 1,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(),
activityCode: 'Т'
@@ -1285,7 +1360,9 @@ export async function initAcademicCalendar() {
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
@@ -1297,7 +1374,7 @@ export async function initAcademicCalendar() {
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
@@ -1425,11 +1502,40 @@ export async function initAcademicCalendar() {
}
function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
function buildCalendarTitleFromForm() {
const specialty = specialties.find(item =>
String(item.id) === String(calendarSpecialtySelect.value)
);
const profile = (profilesBySpecialty.get(String(calendarSpecialtySelect.value)) || [])
.find(item => String(item.id) === String(calendarProfileSelect.value));
const studyForm = studyForms.find(item =>
String(item.id) === String(calendarStudyFormSelect.value)
);
const academicYear = academicYears.find(item =>
String(item.id) === String(calendarYearSelect.value)
);
return buildAcademicCalendarTitle({
specialtyCode: specialityCode(specialty),
specialtyProfileName: profile?.name,
studyFormName: studyForm?.name,
academicYearTitle: academicYear?.title
});
}
function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
async function fetchEducationForms() {
try {
@@ -384,7 +385,7 @@ export async function initGroups() {
: [];
manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет подходящих графиков</option>';
syncSelects(manageCalendarSelect);
@@ -767,7 +768,7 @@ export async function initGroups() {
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
@@ -776,6 +777,13 @@ export async function initGroups() {
`).join('');
}
function assignmentCalendarTitle(assignment) {
const calendar = calendars.find(item =>
String(item.id) === String(assignment.calendarId)
);
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) {
if (!subjects.length) {
return '<span class="muted">Не привязаны</span>';