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,350 @@
import { useEffect, useState } from 'react';
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
import {
activeDatePreferences,
activeWeekPreferences,
createPreference,
deletePreference,
fetchPreferenceMeta,
fetchPreferences,
findCompactPreference,
findWeekPreference,
preferenceStatusLabel,
preferenceTypeClass,
preferenceTypeLabel,
preferenceTypeSymbol,
resolveCurrentSemester,
resolveSelectedSemester,
semesterLabel
} from './teacher-service.js';
const WEEK_DAY_LABELS = Object.freeze(['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']);
export function PreferencesTab() {
const [meta, setMeta] = useState({ semesters: [], timeSlots: [] });
const [metaError, setMetaError] = useState(false);
const [semesterId, setSemesterId] = useState('');
const [preferences, setPreferences] = useState([]);
const [brush, setBrush] = useState('SOFT_PREFERRED');
const [message, setMessage] = useState({ text: '', isError: false });
const [dateValue, setDateValue] = useState('');
const [dateComment, setDateComment] = useState('');
const [busyCell, setBusyCell] = useState('');
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchPreferenceMeta();
if (!active) return;
setMeta(loaded);
const semesters = loaded.semesters || [];
const current = resolveCurrentSemester(semesters, toIsoDate(new Date()));
if (current) setSemesterId(String(current.id));
} catch {
if (active) setMetaError(true);
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!semesterId) return undefined;
let active = true;
(async () => {
try {
const loaded = await fetchPreferences(semesterId);
if (!active) return;
setPreferences(loaded);
setMessage({ text: '', isError: false });
} catch {
if (active) setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
}
})();
return () => {
active = false;
};
}, [semesterId]);
const semester = resolveSelectedSemester(meta.semesters, semesterId);
useEffect(() => {
const min = semester?.startDate || '';
const max = semester?.endDate || '';
if (!dateValue || dateValue < min || dateValue > max) {
setDateValue(min);
}
}, [semester, dateValue]);
async function reloadPreferences() {
try {
setPreferences(await fetchPreferences(semesterId));
} catch {
setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
}
}
function showMessage(text, isError) {
setMessage({ text, isError });
}
async function handleCell(dayOfWeek, timeSlotId) {
const existing = findWeekPreference(preferences, dayOfWeek, timeSlotId);
if (existing) {
if (existing.status === 'APPROVED') {
showMessage('Согласованную отметку может снять кафедра или учебный отдел', true);
return;
}
try {
await deletePreference(existing.id);
showMessage('Ожидающее пожелание отменено', false);
await reloadPreferences();
} catch {
showMessage('Не удалось отменить пожелание', true);
}
return;
}
const cellKey = `${dayOfWeek}:${timeSlotId}`;
setBusyCell(cellKey);
try {
await createPreference({
dayOfWeek,
preferenceType: brush,
semesterId: Number(semesterId),
timeSlotId: Number(timeSlotId)
});
showMessage('Отметка отправлена на согласование', false);
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
} finally {
setBusyCell('');
}
}
async function submitDate(event) {
event.preventDefault();
if (!dateValue) return;
try {
await createPreference({
comment: dateComment.trim() || null,
preferenceDate: dateValue,
preferenceType: 'HARD_UNAVAILABLE',
semesterId: Number(semesterId)
});
setDateComment('');
showMessage('Дата недоступности отправлена на согласование', false);
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
}
}
async function cancelDatePreference(id) {
try {
await deletePreference(id);
showMessage('Ожидающее пожелание отменено', false);
await reloadPreferences();
} catch {
showMessage('Не удалось отменить пожелание', true);
}
}
async function toggleCompact(type, checked, existingId) {
try {
if (!checked && existingId) {
await deletePreference(existingId);
} else if (checked) {
await createPreference({
preferenceType: type,
semesterId: Number(semesterId)
});
}
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
await reloadPreferences();
}
}
const slots = meta.timeSlots || [];
const weekCells = activeWeekPreferences(preferences);
const dateItems = activeDatePreferences(preferences);
return (
<>
<section className="preference-hero">
<div>
<span className="preference-kicker">Контур доступности</span>
<h2>Соберите ритм семестра до составления расписания</h2>
<p>Строгая недоступность блокирует назначение. Мягкие отметки и пожелания к компактности помогают учебному отделу выбрать удобный вариант.</p>
</div>
<label className="preference-semester-field">
<span>Семестр</span>
<select value={semesterId} onChange={event => setSemesterId(event.target.value)}>
{(meta.semesters || []).map(item => (
<option key={item.id} value={item.id}>{semesterLabel(item)}</option>
))}
</select>
</label>
</section>
<section className="preference-board">
<header className="preference-board-head">
<div>
<span>Недельный шаблон</span>
<h3>Отмечайте интервалы одним касанием</h3>
</div>
<label>
<span>Режим отметки</span>
<select value={brush} onChange={event => setBrush(event.target.value)}>
<option value="SOFT_PREFERRED">Предпочтительно</option>
<option value="SOFT_UNWANTED">Нежелательно</option>
<option value="HARD_UNAVAILABLE">Строго недоступен</option>
</select>
</label>
</header>
<div className="preference-legend" aria-label="Легенда">
<span className="is-preferred">Предпочтительно</span>
<span className="is-unwanted">Нежелательно</span>
<span className="is-hard">Строгий запрет</span>
<span className="is-pending">Ожидает согласования</span>
</div>
<div className="preference-grid-wrap">
<div className="preference-grid" aria-live="polite">
{metaError ? (
<div className="preference-empty">Не удалось загрузить параметры семестра</div>
) : slots.length === 0 ? (
<div className="preference-empty">Базовая сетка времени не настроена</div>
) : (
<>
<div className="preference-grid-corner">Пара</div>
{WEEK_DAY_LABELS.map(day => (
<div key={day} className="preference-grid-day">{day}</div>
))}
{slots.flatMap(slot => [
<div key={`time-${slot.id}`} className="preference-grid-time">
<strong>{slot.orderNumber}</strong>
<span>{String(slot.startTime || '').slice(0, 5)}–{String(slot.endTime || '').slice(0, 5)}</span>
</div>,
...WEEK_DAY_LABELS.map((_, index) => {
const dayOfWeek = index + 1;
const item = findWeekPreference(weekCells, dayOfWeek, slot.id);
const title = item
? `${preferenceTypeLabel(item.preferenceType)} · ${preferenceStatusLabel(item.status)}`
: 'Добавить отметку';
return (
<button
key={`cell-${slot.id}-${dayOfWeek}`}
type="button"
className={`preference-cell ${preferenceTypeClass(item?.preferenceType)}${item?.status === 'PENDING' ? ' is-pending' : ''}`}
disabled={busyCell === `${dayOfWeek}:${slot.id}`}
title={title}
aria-label={title}
onClick={() => void handleCell(dayOfWeek, slot.id)}
>
<span>{item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
</button>
);
})
])}
</>
)}
</div>
</div>
<div
className={`preference-message${message.isError ? ' error' : ''}${message.text && !message.isError ? ' success' : ''}`}
role="status"
>
{message.text}
</div>
</section>
<div className="preference-lower-grid">
<section className="preference-card">
<span className="preference-card-number">01</span>
<h3>Полная недоступность</h3>
<p>Добавьте отдельную дату внутри семестра — все пары этого дня будут считаться строгим ограничением.</p>
<form className="preference-date-form" onSubmit={submitDate}>
<input
type="date"
required
min={semester?.startDate || ''}
max={semester?.endDate || ''}
value={dateValue}
onChange={event => setDateValue(event.target.value)}
/>
<input
type="text"
maxLength={2000}
placeholder="Комментарий (необязательно)"
value={dateComment}
onChange={event => setDateComment(event.target.value)}
/>
<button className="btn btn-md btn-primary" type="submit">Добавить дату</button>
</form>
<div className="preference-date-list">
{dateItems.length === 0 ? (
<div className="preference-empty">Даты полной недоступности не добавлены</div>
) : dateItems.map(item => (
<article key={item.id}>
<span className={`preference-date-mark ${item.status === 'APPROVED' ? 'approved' : ''}`} />
<div>
<strong>{formatIsoDate(item.preferenceDate)}</strong>
<small>{item.comment || 'Весь день'}</small>
</div>
<span className="preference-status">{preferenceStatusLabel(item.status)}</span>
{item.status === 'PENDING' && (
<button
type="button"
className="btn btn-sm btn-danger-subtle"
onClick={() => void cancelDatePreference(item.id)}
>
Отменить
</button>
)}
</article>
))}
</div>
</section>
<section className="preference-card">
<span className="preference-card-number">02</span>
<h3>Компактность недели</h3>
<p>Эти мягкие пожелания не отменяют занятия автоматически, но видны при составлении правил.</p>
<CompactToggle
type="CONSECUTIVE"
title="Пары подряд"
description="Собирать занятия последовательными блоками"
preference={findCompactPreference(preferences, 'CONSECUTIVE')}
onToggle={toggleCompact}
/>
<CompactToggle
type="NO_GAPS"
title="Без окон"
description="Не оставлять пустые интервалы между занятиями"
preference={findCompactPreference(preferences, 'NO_GAPS')}
onToggle={toggleCompact}
/>
</section>
</div>
</>
);
}
function CompactToggle({ type, title, description, preference, onToggle }) {
const approved = preference?.status === 'APPROVED';
return (
<label className={`preference-toggle-card${approved ? ' approved' : ''}`}>
<input
type="checkbox"
checked={Boolean(preference)}
disabled={approved}
onChange={event => void onToggle(type, event.target.checked, preference?.id)}
/>
<span><strong>{title}</strong><small>{description}</small></span>
</label>
);
}