import { apiClient } from '../shared/api-client.js'; import { dateInputValueToIso, isoDateToInputValue } from '../../admin/js/date-input.js'; import { academicCalendarWeekNumber } from '../../admin/js/views/academic-calendar-grid.js'; import { buildAcademicCalendarTitle } from '../../admin/js/views/academic-calendar-title.js'; export const DAY_OPTIONS = Object.freeze([ Object.freeze({ value: 1, label: 'Понедельник', shortLabel: 'Пн' }), Object.freeze({ value: 2, label: 'Вторник', shortLabel: 'Вт' }), Object.freeze({ value: 3, label: 'Среда', shortLabel: 'Ср' }), Object.freeze({ value: 4, label: 'Четверг', shortLabel: 'Чт' }), Object.freeze({ value: 5, label: 'Пятница', shortLabel: 'Пт' }), Object.freeze({ value: 6, label: 'Суббота', shortLabel: 'Сб' }), Object.freeze({ value: 7, label: 'Воскресенье', shortLabel: 'Вс' }) ]); export const SEMESTER_LABELS = Object.freeze({ autumn: 'осенний', 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 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('-'); } // Ввод года начала: нормализует значение и пересчитывает год окончания // вместе с датами семестров. Как в legacy, ручной ввод года сбрасывает даты // семестров к рассчитанным значениям (semesterDates === null — не трогать). export function startYearInputResult(rawValue) { const startYear = String(rawValue || '').replace(/\D/g, '').slice(0, 4); if (startYear.length !== 4) { return { startYear, endYear: '', semesterDates: null }; } try { const defaults = academicYearDefaults(startYear); return { startYear, endYear: String(defaults.endYear), semesterDates: { autumn: { start: isoDateToInputValue(defaults.semesters[0].startDate), end: isoDateToInputValue(defaults.semesters[0].endDate) }, spring: { start: isoDateToInputValue(defaults.semesters[1].startDate), end: isoDateToInputValue(defaults.semesters[1].endDate) } } }; } catch { return { startYear, endYear: '', semesterDates: null }; } } // Чтение даты из поля ДД.ММ.ГГГГ с подписью поля в сообщении об ошибке // (реплика legacy readDateInputIso). export function readDisplayDateIso(displayValue, label = 'Дата') { try { return dateInputValueToIso(displayValue); } catch (error) { throw new Error(`${label}: ${lowercaseFirst(error.message)}`); } } // --- Типы активности --- 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 function activityById(id, activityTypes) { return (activityTypes || []).find(type => String(type.id) === String(id)); } export function activityIdByCode(code, activityTypes) { return (activityTypes || []).find(type => type.code === code)?.id || null; } export function theoryActivityId(activityTypes) { return (activityTypes || []).find(type => type.code === 'Т')?.id || activityTypes?.[0]?.id || ''; } // Эффективный код активности ячейки: сохранённое значение, затем код по // справочнику, затем теория (реплика legacy renderCalendarDay). export function effectiveActivityTypeId(row, activityTypes) { return row.activityTypeId || activityIdByCode(row.activityCode, activityTypes) || theoryActivityId(activityTypes); } // --- Контракты API --- export function fetchSpecialties(client = apiClient) { return client.get('/api/specialties', { errorMessage: 'Ошибка загрузки специальностей' }); } export function specialtyProfilesEndpoint(specialtyId) { return `/api/specialties/${specialtyId}/profiles`; } export function fetchSpecialtyProfiles(specialtyId, client = apiClient) { return client.get(specialtyProfilesEndpoint(specialtyId), { errorMessage: 'Ошибка загрузки профилей специальности' }); } export function fetchAcademicYearsList(client = apiClient) { return client.get('/api/admin/calendar/years', { errorMessage: 'Ошибка загрузки учебных годов' }); } export function fetchSubjectsList(client = apiClient) { return client.get('/api/subjects', { errorMessage: 'Ошибка загрузки дисциплин' }); } export function fetchEducationForms(client = apiClient) { return client.get('/api/education-forms', { errorMessage: 'Ошибка загрузки форм обучения' }); } export function fetchActivityTypes(client = apiClient) { return client.get('/api/admin/calendar/activity-types', { errorMessage: 'Ошибка загрузки типов активности' }); } export function fetchTimeSlotScopes(client = apiClient) { return client.get('/api/admin/time-slots/scopes', { errorMessage: 'Ошибка загрузки сеток времени' }); } export function fetchTimeSlotDateAssignments(client = apiClient) { return client.get('/api/admin/time-slots/date-assignments', { errorMessage: 'Ошибка загрузки ручных сеток' }); } export function calendarsPageEndpoint({ page, size, query, academicYearId } = {}) { const params = new URLSearchParams({ page: String(page ?? 0), size: String(size ?? 25), query: String(query || '').trim() }); if (academicYearId) params.set('academicYearId', String(academicYearId)); return `/api/admin/academic-calendars/page?${params}`; } export function fetchCalendarsPage({ page, size, query, academicYearId }, client = apiClient) { return client.get(calendarsPageEndpoint({ page, size, query, academicYearId }), { errorMessage: 'Ошибка загрузки календарных графиков' }); } export function academicCalendarsOptionsEndpoint(query, selectedId) { const params = new URLSearchParams({ query: String(query || ''), limit: '20' }); if (selectedId) params.set('selectedId', String(selectedId)); return `/api/admin/academic-calendars/options?${params}`; } export function fetchCalendarOptions(query, selectedId, client = apiClient) { return client.get(academicCalendarsOptionsEndpoint(query, selectedId), { errorMessage: 'Ошибка поиска календарных графиков' }); } export function saveAcademicYearRequest(payload, id, client = apiClient) { return id ? client.put(`/api/admin/calendar/years/${id}`, payload, { errorMessage: 'Ошибка сохранения учебного года' }) : client.post('/api/admin/calendar/years', payload, { errorMessage: 'Ошибка сохранения учебного года' }); } export function deleteAcademicYearRequest(id, client = apiClient) { return client.delete(`/api/admin/calendar/years/${id}`, { errorMessage: 'Ошибка удаления учебного года' }); } export function saveCalendarRequest(payload, id, client = apiClient) { return id ? client.put(`/api/admin/academic-calendars/${id}`, payload, { errorMessage: 'Ошибка сохранения календарного графика' }) : client.post('/api/admin/academic-calendars', payload, { errorMessage: 'Ошибка сохранения календарного графика' }); } export function deleteCalendarRequest(id, client = apiClient) { return client.delete(`/api/admin/academic-calendars/${id}`, { errorMessage: 'Ошибка удаления календарного графика' }); } export function calendarSubjectsEndpoint(calendarId) { return `/api/admin/academic-calendars/${calendarId}/subjects`; } export function fetchCalendarSubjects(calendarId, client = apiClient) { return client.get(calendarSubjectsEndpoint(calendarId), { errorMessage: 'Ошибка загрузки дисциплин графика' }); } export function saveCalendarSubjectsRequest(calendarId, payload, client = apiClient) { return client.put(calendarSubjectsEndpoint(calendarId), payload, { errorMessage: 'Ошибка сохранения дисциплин графика' }); } export function calendarGridEndpoint(calendarId) { return `/api/admin/academic-calendars/${calendarId}/grid`; } export function fetchCalendarGrid(calendarId, client = apiClient) { return client.get(calendarGridEndpoint(calendarId), { errorMessage: 'Ошибка загрузки сетки' }); } export function saveCalendarGridRequest(calendarId, rows, client = apiClient) { return client.put(calendarGridEndpoint(calendarId), rows, { errorMessage: 'Ошибка сохранения сетки' }); } export function deleteTimeSlotDateAssignmentRequest(id, client = apiClient) { return client.delete(`/api/admin/time-slots/date-assignments/${id}`, { errorMessage: 'Ошибка удаления ручной сетки' }); } // --- Подписи --- export function calendarOptionLabel(calendar) { return buildAcademicCalendarTitle(calendar) || calendar.title; } export function calendarOptionDescription(calendar) { return calendar.specialtyName || ''; } export function specialityLabel(speciality) { const code = specialityCode(speciality) || speciality.id; const name = speciality.specialityName || speciality.name || ''; return name ? `${code} - ${name}` : String(code); } export function specialityCode(speciality) { return speciality?.specialityCode || speciality?.specialtyCode || speciality?.speciality_code || speciality?.specialty_code || ''; } export function dayLabel(value) { return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; } export function shortDayLabel(value) { return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-'; } export function formatShortDate(value) { const [, month, day] = String(value).split('-'); return `${day}.${month}`; } export function formatLongDate(value) { const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } export function formatDisplayDate(value) { const [year, month, day] = String(value || '').split('-'); if (!year || !month || !day) return String(value || ''); return `${day}-${month}-${year}`; } export function formatDisplayDateRange(startDate, endDate) { return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`; } export function dateRangeCaption(rows) { if (!rows.length) return ''; const dates = rows.map(row => row.date).sort(); return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; } export function semesterTypeTitle(semester) { const type = normalizeSemesterType(semester.semesterType); const label = SEMESTER_LABELS[type] || type || 'семестр'; return label.charAt(0).toUpperCase() + label.slice(1); } export function normalizeSemesterType(type) { return String(type || '').toLowerCase(); } export function semesterOptionLabel(semesterNumber) { const course = Math.ceil(Number(semesterNumber) / 2); const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний'; return `${semesterNumber} семестр · ${course} курс, ${type}`; } export function subjectLabel(subject) { const code = subject.code ? `${subject.code} · ` : ''; return `${code}${subject.name || 'Без названия'}`; } export function subjectRowLabel(row) { const code = row.subjectCode ? `${row.subjectCode} · ` : ''; return `${code}${row.subjectName || row.subjectId || 'Без названия'}`; } export function sortCalendarSubjectRows(rows) { return [...(rows || [])].sort((a, b) => { const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber); if (semesterDiff !== 0) return semesterDiff; return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru'); }); } export function calendarSemesterCount(calendar) { return Math.max(1, Number(calendar?.courseCount || 0) * 2); } // --- Сетка годового графика --- export function groupBy(items, keyFn) { const grouped = new Map(); (items || []).forEach(item => { const key = keyFn(item); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(item); }); return grouped; } export function calendarRowKey(row) { return `${row.courseNumber}:${row.date}`; } export function uniqueWeekNumbers(rows) { return Array.from(new Set((rows || []).map(row => row.weekNumber))); } export function minIso(a, b) { return String(a) <= String(b) ? a : b; } export function maxIso(a, b) { return String(a) >= String(b) ? a : b; } export function previousIsoDate(value) { const date = parseLocalIsoDate(value); date.setDate(date.getDate() - 1); return formatLocalDate(date); } export function enumerateDates(startIso, endIso) { const result = []; const current = parseLocalIsoDate(startIso); const end = parseLocalIsoDate(endIso); while (current <= end) { const iso = formatLocalDate(current); const jsDay = current.getDay(); result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); current.setDate(current.getDate() + 1); } return result; } function parseLocalIsoDate(value) { const [year, month, day] = String(value).split('-').map(Number); const date = new Date(year, month - 1, day, 12); if (!year || !month || !day || Number.isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД'); } return date; } function formatLocalDate(date) { return [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0') ].join('-'); } export function buildDefaultGrid(calendar, activityTypes) { const rows = []; const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); for (let course = 1; course <= calendar.courseCount; course += 1) { dates.forEach(date => { rows.push({ id: null, calendarId: calendar.id, courseNumber: course, date: date.iso, weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso), dayOfWeek: date.dayOfWeek, activityTypeId: theoryActivityId(activityTypes), activityCode: 'Т' }); }); } return rows; } export function semesterBlocksForCourse(calendar, course, courseRows, academicYears) { const sortedRows = [...(courseRows || [])].sort((a, b) => String(a.date).localeCompare(String(b.date))); const year = (academicYears || []).find(item => String(item.id) === String(calendar?.academicYearId)); const yearSemesters = [...(year?.semesters || [])] .sort((a, b) => String(a.startDate).localeCompare(String(b.startDate))); if (!yearSemesters.length) { return [{ semesterNumber: `${course * 2 - 1}-${course * 2}`, caption: dateRangeCaption(sortedRows), rows: sortedRows }]; } return yearSemesters.map((semester, index) => { const nextSemester = yearSemesters[index + 1]; const startDate = index === 0 ? minIso(calendar.academicYearStartDate, semester.startDate) : semester.startDate; const endDate = nextSemester ? previousIsoDate(nextSemester.startDate) : maxIso(calendar.academicYearEndDate, semester.endDate); const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate); return { semesterNumber: course * 2 - 1 + index, caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`, rows }; }); } // Итоговые чипы сетки: счётчики по кодам активности, сортировка по коду. export function gridTotalsEntries(rows, activityTypes) { const counts = new Map(); (rows || []).forEach(row => { const activity = activityById(row.activityTypeId, activityTypes); const key = activity ? activity.code : '-'; const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) }; current.count += 1; counts.set(key, current); }); return Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, entry]) => ({ code, count: entry.count, colorClass: entry.colorClass })); } // Общий код активности набора ячеек: одно значение — оно само, иначе пусто. export function commonActivityValue(rows) { const values = Array.from(new Set((rows || []).map(row => String(row.activityTypeId ?? '')))); return values.length === 1 ? values[0] : ''; } // --- Ручные сетки дат --- export function timeAssignmentForDate(assignments, date) { return (assignments || []).find(item => item.date === date); } export function automaticTimeScopeLabel(date, scopes) { const jsDate = new Date(`${date}T00:00:00`); const jsDay = jsDate.getDay(); const isoDay = jsDay === 0 ? 7 : jsDay; const weekdayScope = (scopes || []).find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); const defaultScope = (scopes || []).find(scope => scope.applyMode === 'DEFAULT'); const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; return `По правилу даты (${scopeName})`; } // --- Тексты диалога ячейки --- export function dayDialogTexts(rows) { const dates = rows.map(row => row.date).sort(); const courses = new Set(rows.map(row => String(row.courseNumber))); const weeks = new Set(rows.map(row => String(row.weekNumber))); const weekDays = new Set(rows.map(row => String(row.dayOfWeek))); const isSingle = rows.length === 1; const first = rows[0]; return { date: isSingle ? `${formatLongDate(first.date)} (${shortDayLabel(first.dayOfWeek)})` : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`, course: courses.size === 1 ? `${first.courseNumber} курс` : `${courses.size} курса`, week: weeks.size === 1 ? `${first.weekNumber} неделя` : `${weeks.size} недель`, day: weekDays.size === 1 ? dayLabel(first.dayOfWeek) : `${rows.length} ячеек` }; } export function dayDialogSummaryParts(rows, activityTypes) { if (rows.length <= 1) { const activity = rows[0] ? activityById(rows[0].activityTypeId, activityTypes) : null; return activity ? { kind: 'single', activity } : { kind: 'empty' }; } const common = commonActivityValue(rows); const activity = common ? activityById(common, activityTypes) : null; return activity ? { kind: 'multi', activity, count: rows.length } : { kind: 'multi-empty', count: rows.length }; } export function dayTooltipParts(row, activityTypes, scopes, assignments) { const activity = activityById(row.activityTypeId, activityTypes); // Код ячейки берётся с учётом запасного типа активности (реплика legacy: // код ячейки — activityById(...) || activityTypes[0] || {}). const cellActivity = activity || activityTypes[0] || {}; const code = activity?.code || cellActivity.code || row.activityCode || '-'; const assignment = timeAssignmentForDate(assignments, row.date); const timeLabel = assignment ? ((scopes || []).find(scope => String(scope.id) === String(assignment.scopeId))?.name || 'ручная сетка') : automaticTimeScopeLabel(row.date, scopes).replace('По правилу даты ', ''); return { code, longDate: formatLongDate(row.date), shortDay: shortDayLabel(row.dayOfWeek), week: row.weekNumber, course: row.courseNumber, activityName: activity?.name || 'Не задана', timeLabel, colorClass: activity ? activityColorClass(activity) : '' }; } function lowercaseFirst(value) { const text = String(value || ''); return text ? text[0].toLowerCase() + text.slice(1) : text; }