feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
This commit is contained in:
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user