Files
magistr/frontend/react/admin/tabs/DashboardTab.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

355 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}