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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react';
import {
CONFLICT_CHECK_STATE,
classroomOccupancyLabel,
conflictCheckPresentation,
currentSlotLabel,
currentWeekDateRange,
detectScheduleConflicts,
fetchDashboardReferenceData,
fetchEffectiveTimeSlots,
fetchTodayLessons,
findCurrentSlot,
formatLocalDate,
freeClassroomsForSlot,
lessonsForSlot,
loadWeekDepartmentSchedules,
sortTimeSlots,
totalStudents
} from '../dashboard-service.js';
const REALTIME_UPDATE_MS = 30_000;
export function DashboardTab() {
const [reference, setReference] = useState(null);
const [todayLessons, setTodayLessons] = useState([]);
const [timeSlots, setTimeSlots] = useState([]);
const [dataPhase, setDataPhase] = useState('loading');
const [conflictPhase, setConflictPhase] = useState('checking');
const [conflicts, setConflicts] = useState({ items: [], presentation: null });
const [recheckBusy, setRecheckBusy] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
void loadData();
}, []);
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), REALTIME_UPDATE_MS);
return () => clearInterval(timer);
}, []);
async function loadData() {
setDataPhase('loading');
setConflictPhase('checking');
try {
const loadedReference = await fetchDashboardReferenceData();
const todayStr = formatLocalDate(new Date());
const [lessons, slots] = await Promise.all([
fetchTodayLessons(loadedReference.departments, todayStr),
fetchEffectiveTimeSlots(todayStr)
]);
setReference(loadedReference);
setTodayLessons(lessons);
setTimeSlots(sortTimeSlots(slots));
setDataPhase('ready');
await checkConflicts(loadedReference);
} catch {
setDataPhase('error');
setConflictPhase('error');
console.error('Не удалось загрузить данные панели');
}
}
async function checkConflicts(referenceData) {
setConflictPhase('checking');
try {
const range = currentWeekDateRange(new Date());
const scheduleLoad = await loadWeekDepartmentSchedules(referenceData.departments, range);
const items = detectScheduleConflicts(scheduleLoad.lessons, referenceData.groups, referenceData.classrooms);
const presentation = scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN
? conflictCheckPresentation(scheduleLoad, 0, range)
: conflictCheckPresentation(scheduleLoad, items.length, range);
setConflicts({ items, presentation });
setConflictPhase('ready');
} catch {
setConflictPhase('error');
console.error('Не удалось выполнить проверку конфликтов');
}
}
async function recheck() {
setRecheckBusy(true);
try {
if (dataPhase === 'ready' && reference) {
await checkConflicts(reference);
} else {
await loadData();
}
} finally {
setRecheckBusy(false);
}
}
const ready = dataPhase === 'ready' && reference !== null;
const currentSlot = ready ? findCurrentSlot(timeSlots, now) : null;
const currentLessons = currentSlot ? lessonsForSlot(todayLessons, currentSlot.orderNumber) : [];
const freeRooms = ready ? freeClassroomsForSlot(reference.classrooms, todayLessons, currentSlot ? currentSlot.orderNumber : null) : [];
return (
<div className="dashboard-container">
<div className="dashboard-metrics-grid">
<MetricCard
tone="groups"
label="Активные группы"
value={ready ? String(reference.groups.length) : '—'}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="students"
label="Всего студентов"
value={ready ? String(totalStudents(reference.groups)) : '—'}
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="teachers"
label="Преподаватели"
value={ready ? String(reference.teachers.length) : '—'}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</MetricCard>
<MetricCard
tone="classrooms"
label="Загрузка аудиторий сегодня"
value={ready ? classroomOccupancyLabel(reference.classrooms, todayLessons) : '—'}
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</MetricCard>
</div>
<div className="dashboard-middle">
<div className="card dashboard-monitor-card">
<div className="dashboard-card-header">
<h2>Мониторинг пар (Идет сейчас)</h2>
<div className="dashboard-time-slot">
{ready ? currentSlotLabel(currentSlot) : '—'}
</div>
</div>
<div className="table-wrap dashboard-monitor-table">
<table>
<thead>
<tr>
<th>Аудитория</th>
<th>Дисциплина</th>
<th>Преподаватель</th>
<th>Группа</th>
</tr>
</thead>
<tbody>
{!ready ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row dashboard-state-row--wide">Определение текущего времени...</td></tr>
) : !currentSlot ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В данный момент учебные пары не проводятся</td></tr>
) : currentLessons.length === 0 ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В текущий слот занятий не запланировано</td></tr>
) : currentLessons.map(lesson => (
<tr key={`${lesson.classroomId}-${lesson.subjectId}-${lesson.teacherId}-${lesson.groupNames.join(',')}`}>
<td><strong>{lesson.classroomName}</strong></td>
<td>
{lesson.subjectName}{' '}
<span className="badge badge-ef badge--lesson-type">{lesson.lessonTypeName}</span>
</td>
<td>{lesson.teacherName}</td>
<td><span className="badge badge-available">{lesson.groupNames.join(', ')}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="dashboard-side-stack">
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div className="dashboard-badge-list" tabIndex="0" role="region" aria-label="Свободные аудитории">
{!ready ? (
<div className="dashboard-free-hint">Загрузка...</div>
) : freeRooms.length === 0 ? (
<span className="dashboard-free-busy">Все аудитории заняты</span>
) : freeRooms.map(classroom => (
<span className="badge badge-available dashboard-free-badge" key={classroom.id}>
{classroom.name} ({classroom.capacity} м.)
</span>
))}
</div>
</div>
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Быстрый переход</h3>
<div className="dashboard-actions">
<a href="#schedule" className="btn btn-md btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Конструктор расписания
</a>
<a href="#schedule-view" className="btn btn-md btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
Точечные изменения / Просмотр
</a>
</div>
</div>
</div>
</div>
<div className="card red-zone-card">
<div className="red-zone-header">
<div>
<h2 className="red-zone-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Центр предупреждений (Red Zone)
</h2>
<div className="red-zone-subtitle">Анализ накладок и коллизий расписания на текущую неделю</div>
</div>
<button
type="button"
className="btn btn-sm btn-secondary"
id="btn-recheck-conflicts"
disabled={recheckBusy}
aria-busy={recheckBusy ? 'true' : undefined}
onClick={() => void recheck()}
>
{recheckBusy ? 'Проверка…' : 'Перепроверить'}
</button>
</div>
<div
className="red-zone-conflicts"
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy={conflictPhase === 'checking' ? 'true' : undefined}
>
{conflictPhase === 'checking' ? (
<div className="dashboard-check-loading">Анализ расписания и выявление конфликтов...</div>
) : conflictPhase === 'error' ? (
<CheckStateCard
tone="error"
title="Проверка не выполнена"
description={dataPhase === 'error'
? 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
: 'Во время анализа произошла ошибка. Повторите проверку позже.'}
/>
) : (
<>
{conflicts.presentation && (
<CheckStateCard
tone={conflicts.presentation.tone}
title={conflicts.presentation.title}
description={conflicts.presentation.description}
meta={conflicts.presentation.meta}
/>
)}
{conflicts.items.map((conflict, index) => (
<ConflictCard conflict={conflict} key={`${conflict.type}-${index}`} />
))}
</>
)}
</div>
</div>
</div>
);
}
function MetricCard({ tone, label, value, children }) {
return (
<div className={`card dashboard-metric-card dashboard-metric-card--${tone}`}>
<div className="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</div>
<div>
<div className="dashboard-metric-label">{label}</div>
<h3 className="dashboard-metric-value">{value}</h3>
</div>
</div>
);
}
function CheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? <path d="M20 6 9 17l-5-5" />
: tone === 'warning'
? (
<>
<path d="M12 9v4m0 4h.01" />
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</>
)
: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5m0 3h.01" />
</>
);
return (
<section className={`dashboard-check-state dashboard-check-state--${tone}`}>
<span className="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
</span>
<span className="dashboard-check-state__body">
<strong>{title}</strong>
<span>{description}</span>
{meta ? <small>{meta}</small> : null}
</span>
</section>
);
}
function ConflictCard({ conflict }) {
const isError = conflict.severity === 'error';
return (
<div className={`dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}`}>
<div className="dashboard-conflict-card__icon">
{isError ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
)}
</div>
<div className="dashboard-conflict-card__body">
<h4>{conflict.title}</h4>
<p>
{(conflict.segments || []).map((segment, index) => (
typeof segment === 'string'
? segment
: <strong key={index}>{segment.strong}</strong>
))}
</p>
</div>
</div>
);
}