Files
magistr/frontend/react/admin/tabs/ClassroomsTab.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

633 lines
29 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, 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>
);
}