Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
157 lines
5.5 KiB
JavaScript
157 lines
5.5 KiB
JavaScript
import { useEffect, useState } from 'react';
|
|
|
|
import { dayName, formatDate, toIsoDate, weekDates } from '../shared/dates.js';
|
|
import {
|
|
buildDesktopWeekModel,
|
|
buildTeacherLessonChips,
|
|
canRequestChange,
|
|
lessonsByDate
|
|
} from './teacher-service.js';
|
|
|
|
const MOBILE_BREAKPOINT = '(max-width: 980px)';
|
|
|
|
export function TeacherWeekGrid({ emptyMessage = 'Загрузка...', lessons = [], onRequestLesson, weekStart }) {
|
|
const mobile = useMobileLayout();
|
|
|
|
if (mobile) {
|
|
return <MobileWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
|
}
|
|
return <DesktopWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
|
}
|
|
|
|
function DesktopWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
|
const { days, slots, lessonsByKey } = buildDesktopWeekModel(weekStart, lessons);
|
|
const hasLessons = lessons.length > 0;
|
|
|
|
return (
|
|
<section className="schedule" aria-live="polite">
|
|
<div className="corner" />
|
|
{days.map(day => <DayHead key={day.isoDate} date={day.date} />)}
|
|
{hasLessons ? (
|
|
slots.map(slot => (
|
|
<SlotRow
|
|
key={slot.order}
|
|
days={days}
|
|
lessonsByKey={lessonsByKey}
|
|
onRequestLesson={onRequestLesson}
|
|
slot={slot}
|
|
/>
|
|
))
|
|
) : (
|
|
<>
|
|
<div className="time-cell">Неделя</div>
|
|
{days.map(day => (
|
|
<div key={day.isoDate} className="day-cell">
|
|
<div className="empty">{emptyMessage}</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SlotRow({ days, lessonsByKey, onRequestLesson, slot }) {
|
|
return (
|
|
<>
|
|
<div className="time-cell">{slot.label}</div>
|
|
{days.map(day => {
|
|
const lesson = lessonsByKey.get(`${day.isoDate}:${slot.order}`);
|
|
return (
|
|
<div key={`${day.isoDate}:${slot.order}`} className="day-cell">
|
|
{lesson
|
|
? <LessonCard lesson={lesson} onRequestLesson={onRequestLesson} />
|
|
: <div className="empty">Нет занятия</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MobileWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
|
const byDate = lessonsByDate(lessons);
|
|
|
|
return (
|
|
<section className="schedule" aria-live="polite">
|
|
{weekDates(weekStart).map(date => {
|
|
const isoDate = toIsoDate(date);
|
|
const dayLessons = byDate.get(isoDate) || [];
|
|
return (
|
|
<article key={isoDate} className="mobile-day">
|
|
<DayHead date={date} />
|
|
<div className="mobile-list">
|
|
{dayLessons.length > 0
|
|
? dayLessons.map((lesson, index) => (
|
|
<LessonCard
|
|
key={`${lesson.date}-${trimKey(lesson)}-${index}`}
|
|
lesson={lesson}
|
|
onRequestLesson={onRequestLesson}
|
|
/>
|
|
))
|
|
: <div className="empty">{emptyMessage}</div>}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DayHead({ date }) {
|
|
return (
|
|
<div className="day-head">
|
|
<strong>{dayName(date)}</strong>
|
|
<span>{formatDate(date)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LessonCard({ lesson, onRequestLesson }) {
|
|
return (
|
|
<div className="lesson">
|
|
<div className="lesson-title">{lesson.subjectName}</div>
|
|
<div className="lesson-meta">
|
|
{buildTeacherLessonChips(lesson).map((chip, index) => (
|
|
<span key={index} className="chip">{chip}</span>
|
|
))}
|
|
</div>
|
|
{canRequestChange(lesson) ? (
|
|
<button
|
|
className="btn btn-sm btn-secondary lesson-request-button"
|
|
type="button"
|
|
onClick={() => onRequestLesson(lesson)}
|
|
>
|
|
Запросить изменение
|
|
</button>
|
|
) : (
|
|
<span className="lesson-change-note">Занятие уже изменено учебным отделом</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function trimKey(lesson) {
|
|
return String(lesson.startTime || '').slice(0, 5);
|
|
}
|
|
|
|
function useMobileLayout() {
|
|
const [mobile, setMobile] = useState(() => matchesMobile());
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
|
const query = window.matchMedia(MOBILE_BREAKPOINT);
|
|
const update = () => setMobile(query.matches);
|
|
update();
|
|
query.addEventListener('change', update);
|
|
return () => query.removeEventListener('change', update);
|
|
}, []);
|
|
|
|
return mobile;
|
|
}
|
|
|
|
function matchesMobile() {
|
|
return typeof window !== 'undefined' && Boolean(window.matchMedia)
|
|
&& window.matchMedia(MOBILE_BREAKPOINT).matches;
|
|
}
|