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:
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal file
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user