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,632 @@
import { useEffect, useRef, useState } from 'react';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import {
archiveClassroom,
classroomStatusLabel,
createClassroom,
createEquipment,
deleteEquipment,
equipmentIdSelection,
equipmentNamesText,
equipmentSelectLabel,
fetchClassrooms,
fetchEquipments,
parseCapacity,
restoreClassroom,
toggleClassroomAvailability,
updateClassroom
} from '../classrooms-service.js';
export function ClassroomsTab() {
const [equipments, setEquipments] = useState([]);
const [classrooms, setClassrooms] = useState(null);
const [loadError, setLoadError] = useState(false);
const [createName, setCreateName] = useState('');
const [createCapacity, setCreateCapacity] = useState('');
const [createEquipmentIds, setCreateEquipmentIds] = useState(() => new Set());
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [editModal, setEditModal] = useState(null);
const [equipmentModal, setEquipmentModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
useEffect(() => {
let active = true;
(async () => {
try {
const [loadedEquipments, loadedClassrooms] = await Promise.all([
fetchEquipments(),
fetchClassrooms()
]);
if (!active) return;
setEquipments(loadedEquipments || []);
setClassrooms(loadedClassrooms || []);
setLoadError(false);
} catch {
if (active) setLoadError(true);
}
})();
return () => {
active = false;
};
}, []);
async function reloadClassrooms() {
try {
setClassrooms(await fetchClassrooms());
setLoadError(false);
} catch {
setLoadError(true);
}
}
async function reloadEquipments() {
const loaded = await fetchEquipments();
setEquipments(loaded || []);
return loaded || [];
}
function toggleCreateEquipment(equipmentId) {
setCreateEquipmentIds(current => {
const next = new Set(current);
if (next.has(equipmentId)) {
next.delete(equipmentId);
} else {
next.add(equipmentId);
}
return next;
});
}
async function submitCreate(event) {
event.preventDefault();
const name = createName.trim();
const capacity = parseCapacity(createCapacity);
if (!name || Number.isNaN(capacity)) {
setCreateAlert({ text: 'Заполните обязательные поля', isError: true });
return;
}
try {
const created = await createClassroom({
name,
capacity,
equipmentIds: [...createEquipmentIds].map(Number)
});
setCreateAlert({ text: `Аудитория «${created.name}» добавлена`, isError: false });
setCreateName('');
setCreateCapacity('');
setCreateEquipmentIds(new Set());
await reloadClassrooms();
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
}
}
function confirmArchive(classroom) {
setConfirmState({
title: 'Вывод аудитории из эксплуатации',
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
confirmLabel: 'Вывести из эксплуатации',
danger: true,
action: async () => {
try {
await archiveClassroom(classroom.id);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка архивирования',
message: error.message || 'Не удалось архивировать аудиторию'
});
}
}
});
}
async function restore(classroom) {
try {
await restoreClassroom(classroom.id);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка восстановления',
message: error.message || 'Не удалось восстановить аудиторию'
});
}
}
async function toggleAvailability(classroom) {
try {
await toggleClassroomAvailability(classroom.id, !classroom.isAvailable);
await reloadClassrooms();
} catch (error) {
setAlertState({
title: 'Ошибка изменения статуса',
message: error.message || 'Не удалось изменить доступность аудитории'
});
}
}
async function openEditModal(classroomId) {
try {
const fresh = await fetchClassrooms();
const classroom = (fresh || []).find(item => String(item.id) === String(classroomId));
if (!classroom) return;
setEditModal({
classroom,
name: classroom.name,
capacity: String(classroom.capacity),
equipmentIds: new Set((classroom.equipments || []).map(equipment => String(equipment.id))),
alert: { text: '', isError: false }
});
} catch (error) {
setAlertState({
title: 'Ошибка загрузки',
message: error.message || 'Не удалось загрузить данные аудитории'
});
}
}
function toggleEditEquipment(equipmentId) {
setEditModal(current => {
if (!current) return current;
const next = new Set(current.equipmentIds);
if (next.has(equipmentId)) {
next.delete(equipmentId);
} else {
next.add(equipmentId);
}
return { ...current, equipmentIds: next };
});
}
async function submitEdit(event) {
event.preventDefault();
const modal = editModal;
const name = modal.name.trim();
const capacity = parseCapacity(modal.capacity);
if (!name || Number.isNaN(capacity)) {
setEditModal({ ...modal, alert: { text: 'Заполните обязательные поля', isError: true } });
return;
}
try {
const updated = await updateClassroom(modal.classroom.id, {
name,
capacity,
equipmentIds: [...modal.equipmentIds].map(Number),
isAvailable: modal.classroom.isAvailable
});
setEditModal(null);
setCreateAlert({ text: `Аудитория «${updated.name}» обновлена`, isError: false });
await reloadClassrooms();
} catch (error) {
setEditModal({
...modal,
alert: { text: error.message || 'Ошибка обновления', isError: true }
});
}
}
async function openEquipmentModal() {
setEquipmentModal({
name: '',
list: null,
alert: { text: '', isError: false }
});
await reloadEquipmentModalList();
}
async function reloadEquipmentModalList() {
try {
const loaded = await reloadEquipments();
setEquipmentModal(current => (current ? { ...current, list: loaded } : current));
} catch {
setEquipmentModal(current => (current ? { ...current, list: [] } : current));
}
}
async function submitEquipment(event) {
event.preventDefault();
const modal = equipmentModal;
const name = modal.name.trim();
if (!name) {
setEquipmentModal({ ...modal, alert: { text: 'Введите название', isError: true } });
return;
}
try {
const created = await createEquipment(name);
setEquipmentModal(current => (
current ? { ...current, name: '', alert: { text: `Оборудование «${created.name}» добавлено`, isError: false } } : current
));
await reloadEquipmentModalList();
} catch (error) {
setEquipmentModal({
...modal,
alert: { text: error.message || 'Ошибка добавления', isError: true }
});
}
}
function confirmDeleteEquipment(equipment) {
setConfirmState({
title: 'Удаление оборудования',
message: 'Удалить оборудование из каталога?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteEquipment(equipment.id);
await reloadEquipmentModalList();
} catch (error) {
setAlertState({
title: 'Ошибка удаления',
message: error.message || 'Не удалось удалить оборудование'
});
}
}
});
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
return (
<>
<div className="card create-card">
<div className="card-toolbar">
<h2>Новая аудитория</h2>
<button type="button" className="btn btn-md btn-secondary" onClick={openEquipmentModal}>
Справочник оборудования
</button>
</div>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-classroom-name">Номер / Название</label>
<input
type="text"
id="new-classroom-name"
placeholder="101 Ленинская"
required
value={createName}
onChange={event => setCreateName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-classroom-capacity">Вместимость (чел.)</label>
<input
type="number"
id="new-classroom-capacity"
placeholder="30"
min="1"
required
value={createCapacity}
onChange={event => setCreateCapacity(event.target.value)}
/>
</div>
</div>
<div className="form-row form-row--spacing">
<div className="form-group form-group--wide">
<label>Оборудование</label>
<EquipmentMultiSelect
equipments={equipments}
selectedIds={createEquipmentIds}
onToggle={toggleCreateEquipment}
/>
</div>
<div className="form-group form-group--submit">
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
</div>
</div>
<div
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createAlert.text}
</div>
</form>
</div>
<div className="card">
<h2>Список аудиторий</h2>
<div className="table-wrap">
<table id="classrooms-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Вместимость</th>
<th>Оборудование</th>
<th>Статус</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{loadError ? (
<tr><td colSpan="6" className="loading-row">Ошибка загрузки данных</td></tr>
) : classrooms === null ? (
<tr><td colSpan="6" className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
) : classrooms.length === 0 ? (
<tr><td colSpan="6" className="loading-row">Нет аудиторий</td></tr>
) : classrooms.map(classroom => (
<tr key={classroom.id}>
<td>{classroom.id}</td>
<td><strong>{classroom.name}</strong></td>
<td>{classroom.capacity} чел.</td>
<td><small>{equipmentNamesText(classroom) || '—'}</small></td>
<td>
<div className="status-cell">
<span className={`badge ${classroom.isAvailable ? 'badge-available' : 'badge-unavailable'}`}>
{classroomStatusLabel(classroom)}
</span>
<button
type="button"
className="btn btn-icon-sm btn-secondary btn-icon-toggle"
title="Сменить статус"
aria-label="Сменить статус"
onClick={() => void toggleAvailability(classroom)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td className="table-cell-actions">
<div className="table-actions">
<button
type="button"
className="btn btn-sm btn-secondary classroom-edit-action"
onClick={() => void openEditModal(classroom.id)}
>
Изменить
</button>
{classroom.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-classroom"
onClick={() => void restore(classroom)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-classroom"
onClick={() => confirmArchive(classroom)}
>
Архивировать
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{editModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEditModal(null);
}}
>
<div className="modal-content card">
<h2>Редактировать аудиторию</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
aria-label="Закрыть окно"
onClick={() => setEditModal(null)}
>
×
</button>
<form onSubmit={submitEdit}>
<input type="hidden" value={editModal.classroom.id} />
<div className="form-row form-row--spacing">
<div className="form-group">
<label htmlFor="edit-classroom-name">Номер / Название</label>
<input
type="text"
id="edit-classroom-name"
required
value={editModal.name}
onChange={event => setEditModal({ ...editModal, name: event.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="edit-classroom-capacity">Вместимость (чел.)</label>
<input
type="number"
id="edit-classroom-capacity"
min="1"
required
value={editModal.capacity}
onChange={event => setEditModal({ ...editModal, capacity: event.target.value })}
/>
</div>
</div>
<div className="form-row form-row--spacing">
<div className="form-group form-group--wide">
<label>Оборудование</label>
<EquipmentMultiSelect
equipments={equipments}
selectedIds={editModal.equipmentIds}
onToggle={toggleEditEquipment}
/>
</div>
<div className="form-group form-group--submit">
<button type="submit" className="btn btn-md btn-primary btn-block">Сохранить</button>
</div>
</div>
<div
className={`form-alert${editModal.alert.text ? (editModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{editModal.alert.text}
</div>
</form>
</div>
</div>
)}
{equipmentModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEquipmentModal(null);
}}
>
<div className="modal-content card modal-content--medium">
<div className="modal-header-row">
<h2>Справочник оборудования</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setEquipmentModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Добавить оборудование</h3>
<form onSubmit={submitEquipment}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-equipment-name">Название оборудования</label>
<input
type="text"
id="manage-equipment-name"
placeholder="Проектор"
required
value={equipmentModal.name}
onChange={event => setEquipmentModal({ ...equipmentModal, name: event.target.value })}
/>
</div>
<button type="submit" className="btn btn-md btn-primary btn-inline">Добавить</button>
</div>
<div
className={`form-alert${equipmentModal.alert.text ? (equipmentModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{equipmentModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{equipmentModal.list === null ? (
<tr><td colSpan="3" className="loading-row">Загрузка...</td></tr>
) : equipmentModal.list.length === 0 ? (
<tr><td colSpan="3" className="loading-row">Нет оборудования</td></tr>
) : equipmentModal.list.map(equipment => (
<tr key={equipment.id}>
<td>{equipment.id}</td>
<td>{equipment.name}</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-equipment"
onClick={() => confirmDeleteEquipment(equipment)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{confirmState && (
<ConfirmDialog
title={confirmState.title}
message={confirmState.message}
confirmLabel={confirmState.confirmLabel}
danger={confirmState.danger}
onConfirm={() => void runConfirm()}
onClose={() => setConfirmState(null)}
/>
)}
{alertState && (
<AlertDialog
title={alertState.title}
message={alertState.message}
onClose={() => setAlertState(null)}
/>
)}
</>
);
}
function EquipmentMultiSelect({ equipments, selectedIds, onToggle }) {
const rootRef = useRef(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [open]);
const selection = equipmentIdSelection(equipments, [...selectedIds]);
const selectedNames = selection.filter(item => item.checked).map(item => item.name);
return (
<div className="custom-multi-select" ref={rootRef}>
<button
type="button"
className={`select-box${open ? ' active' : ''}`}
aria-expanded={open}
onClick={() => setOpen(current => !current)}
>
<span className="select-text">{equipmentSelectLabel(selectedNames)}</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`dropdown-menu${open ? ' open' : ''}`}>
<div className="checkbox-group-vertical">
{equipments.length === 0 ? (
<p className="text-muted"><small>Оборудование отсутствует</small></p>
) : selection.map(item => (
<label className="checkbox-item" key={item.id}>
<input
type="checkbox"
value={item.id}
checked={item.checked}
onChange={() => onToggle(String(item.id))}
/>
<span className="checkmark"></span>
<span>{item.name}</span>
</label>
))}
</div>
</div>
</div>
);
}