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