Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
355 lines
17 KiB
JavaScript
355 lines
17 KiB
JavaScript
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>
|
||
);
|
||
}
|