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:
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal file
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user