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,775 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { toIsoDate } from '../../shared/dates.js';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import {
CELL_PARITY_LAYOUT,
DAY_FULL_LABELS,
DAY_ORDER,
DISPLAY_ALL,
TARGET_CLASSROOMS,
TARGET_DEPARTMENTS,
TARGET_TEACHERS,
WORKLOAD_TARGETS,
buildingOptions,
countOccupiedCells,
datesBetween,
entityAxisParts,
entityOptionLabel,
entitySlotKey,
equipmentOptions,
filteredClassrooms,
formatDateShort,
loadEntityLessons,
loadOverviewLessons,
loadSlotsForDates,
overviewSummaryText,
parseMinCapacity,
roomSlotState,
roomSlotStatesEqual,
roomSummaryParts,
roomWeekCaption,
sameId,
scopeShort,
semestersFromYears,
sortClassrooms,
sortDepartments,
sortTeachers,
timeSlotLabel,
twoWeekRange,
buildDateGridByDay,
buildSlotRows,
countAvailableRoomCells,
countOccupiedRoomCells,
dayShort,
fetchAcademicYears,
fetchEffectiveTimeSlots,
fetchWorkloadClassrooms,
fetchWorkloadDepartments,
fetchWorkloadEquipments,
fetchWorkloadGroups,
fetchWorkloadTeachers
} from '../auditorium-workload-service.js';
export function AuditoriumWorkloadTab() {
const [date, setDate] = useState(() => toIsoDate(new Date()));
const [currentTarget, setCurrentTarget] = useState(TARGET_CLASSROOMS);
const [displayValue, setDisplayValue] = useState(DISPLAY_ALL);
const [buildings, setBuildings] = useState([]);
const [equipment, setEquipment] = useState([]);
const [minCapacityText, setMinCapacityText] = useState('');
const [classrooms, setClassrooms] = useState([]);
const [teachers, setTeachers] = useState([]);
const [departments, setDepartments] = useState([]);
const [equipments, setEquipments] = useState([]);
const [groups, setGroups] = useState([]);
const [semesters, setSemesters] = useState([]);
const [overview, setOverview] = useState({
loading: true,
message: 'Загрузка справочников и расписания...',
error: '',
timeSlots: [],
lessonsByEntitySlot: new Map(),
failedGroupLoads: 0,
summaryReady: false
});
const [detail, setDetail] = useState({
loading: false,
message: '',
error: '',
summary: null,
weekTable: null
});
const [alertState, setAlertState] = useState(null);
const dateRef = useRef(date);
const targetRef = useRef(currentTarget);
const displayRef = useRef(displayValue);
const targetConfig = WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const roomMode = displayValue !== DISPLAY_ALL;
const showOverviewFilters = !roomMode && currentTarget === TARGET_CLASSROOMS;
function showWorkloadAlert(text) {
setAlertState({ message: text, type: 'error' });
}
function hideWorkloadAlert() {
setAlertState(null);
}
useEffect(() => {
let active = true;
(async () => {
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([
fetchWorkloadClassrooms(),
fetchWorkloadEquipments(),
fetchWorkloadGroups(),
fetchAcademicYears(),
fetchWorkloadTeachers(),
fetchWorkloadDepartments()
]);
if (!active) return;
const dictionaries = {
classrooms: sortClassrooms(loadedClassrooms),
teachers: sortTeachers(loadedTeachers),
departments: sortDepartments(loadedDepartments),
equipments: loadedEquipments || [],
groups: loadedGroups || [],
semesters: semestersFromYears(academicYears)
};
setClassrooms(dictionaries.classrooms);
setTeachers(dictionaries.teachers);
setDepartments(dictionaries.departments);
setEquipments(dictionaries.equipments);
setGroups(dictionaries.groups);
setSemesters(dictionaries.semesters);
await loadCurrentView({ dictionaries });
} catch (error) {
if (!active) return;
const message = error.message || 'Ошибка загрузки данных загруженности';
setOverview(current => ({ ...current, loading: false, error: message }));
setDetail(current => ({ ...current, loading: false, error: message }));
showWorkloadAlert(message);
}
})();
return () => {
active = false;
};
}, []);
async function loadCurrentView(context = {}) {
const targetValue = context.target ?? targetRef.current;
const displayVal = context.display ?? displayRef.current;
const dateValue = context.date ?? dateRef.current;
const source = context.dictionaries ?? { classrooms, teachers, departments, groups, semesters };
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const entities = targetValue === TARGET_TEACHERS
? source.teachers
: targetValue === TARGET_DEPARTMENTS ? source.departments : source.classrooms;
if (displayVal !== DISPLAY_ALL) {
await loadDetail({
dateValue,
targetValue,
displayVal,
entities,
groups: source.groups,
teachers: source.teachers,
departments: source.departments,
semesters: source.semesters
});
} else {
await loadOverview({
dateValue,
targetValue,
groups: source.groups,
teachers: source.teachers,
departments: source.departments
});
}
}
async function loadOverview({ dateValue, targetValue, groups: groupList, teachers: teacherList, departments: departmentList }) {
if (!dateValue) {
showWorkloadAlert('Выберите дату');
return;
}
hideWorkloadAlert();
setOverview(current => ({
...current,
loading: true,
message: 'Загрузка расписания на выбранную дату...',
error: ''
}));
try {
const slots = await fetchEffectiveTimeSlots(dateValue);
if (!(groupList || []).length) {
setOverview({
loading: false,
error: '',
timeSlots: slots || [],
lessonsByEntitySlot: new Map(),
failedGroupLoads: 0,
summaryReady: true
});
showWorkloadAlert('Группы не созданы, загруженность пуста');
return;
}
const { lessonsByEntitySlot, failedLoads } = await loadOverviewLessons({
groups: groupList,
date: dateValue,
target: targetValue,
teachers: teacherList,
departments: departmentList
});
setOverview({
loading: false,
error: '',
timeSlots: slots || [],
lessonsByEntitySlot,
failedGroupLoads: failedLoads,
summaryReady: true
});
if (failedLoads > 0) {
showWorkloadAlert(`Часть групп не загрузилась: ${failedLoads}. Показаны доступные данные.`);
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности';
setOverview(current => ({ ...current, loading: false, error: message }));
showWorkloadAlert(message);
}
}
async function loadDetail({ dateValue, targetValue, displayVal, entities, groups: groupList, teachers: teacherList, departments: departmentList, semesters: semesterList }) {
const config = WORKLOAD_TARGETS[targetValue] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
const entityId = displayVal !== DISPLAY_ALL ? displayVal.slice(config.prefix.length) : null;
const entity = entityId
? (entities || []).find(item => sameId(item.id, entityId)) || null
: null;
if (!dateValue) {
showWorkloadAlert('Выберите дату');
setDetail({
loading: false,
message: '',
error: 'Выберите дату для расчёта двухнедельного периода',
summary: null,
weekTable: null
});
return;
}
if (!entity) {
showWorkloadAlert(config.selectError);
setDetail({
loading: false,
message: '',
error: `${config.selectError} в поле «Отображение»`,
summary: null,
weekTable: null
});
return;
}
hideWorkloadAlert();
setDetail({
loading: true,
message: 'Загрузка расписания на две недели...',
error: '',
summary: null,
weekTable: null
});
try {
const range = twoWeekRange(dateValue);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadEntityLessons({
groups: groupList,
startDate: range.startDate,
endDate: range.endDate,
entityId: entity.id,
target: targetValue,
teachers: teacherList,
departments: departmentList
})
]);
const slotRows = buildSlotRows(dates, slotLoad.slotsByDate, lessonLoad.lessons);
const dateGrid = buildDateGridByDay(dates, lessonLoad.parityLessons, semesterList);
setDetail({
loading: false,
message: '',
error: '',
summary: roomSummaryParts({
target: targetValue,
entity,
range,
totalCells: countAvailableRoomCells(dates, slotLoad.slotsByDate, lessonLoad.lessonsByDateSlot),
occupiedCells: countOccupiedRoomCells(lessonLoad.lessonsByDateSlot),
failedGroupLoads: lessonLoad.failedLoads,
failedSlotLoads: slotLoad.failedLoads,
unknownParityDates: dateGrid.unknownParityDates
}),
weekTable: slotRows.length ? {
datesByDay: dateGrid.datesByDay,
slotRows,
slotsByDate: slotLoad.slotsByDate,
lessonsByDateSlot: lessonLoad.lessonsByDateSlot
} : null
});
const warnings = [];
if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`);
if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`);
if (warnings.length) {
showWorkloadAlert(`Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`);
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности';
setDetail({
loading: false,
message: '',
error: message,
summary: null,
weekTable: null
});
showWorkloadAlert(message);
}
}
function handleDateChange(value) {
dateRef.current = value;
setDate(value);
void loadCurrentView();
}
function handleTargetChange(value) {
const nextTarget = value || TARGET_CLASSROOMS;
targetRef.current = nextTarget;
displayRef.current = DISPLAY_ALL;
setCurrentTarget(nextTarget);
setDisplayValue(DISPLAY_ALL);
void loadCurrentView({ target: nextTarget, display: DISPLAY_ALL });
}
function handleDisplayChange(value) {
displayRef.current = value;
setDisplayValue(value);
void loadCurrentView({ display: value });
}
function toggleBuilding(value) {
setBuildings(current => (
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
));
}
function toggleEquipment(value) {
setEquipment(current => (
current.includes(value) ? current.filter(item => item !== value) : [...current, value]
));
}
const buildingOptionItems = useMemo(() => buildingOptions(classrooms), [classrooms]);
const equipmentOptionItems = useMemo(
() => equipmentOptions(equipments, classrooms),
[equipments, classrooms]
);
const minCapacity = parseMinCapacity(minCapacityText);
const entities = currentTarget === TARGET_TEACHERS
? teachers
: currentTarget === TARGET_DEPARTMENTS ? departments : classrooms;
const gridRows = useMemo(() => {
if (currentTarget === TARGET_CLASSROOMS) {
return filteredClassrooms(classrooms, { buildings, minCapacity, equipment });
}
return currentTarget === TARGET_TEACHERS ? teachers : departments;
}, [currentTarget, classrooms, teachers, departments, buildings, minCapacity, equipment]);
const displayOptions = useMemo(() => ([
{ value: DISPLAY_ALL, label: targetConfig.allLabel },
...entities.map(entity => ({
value: `${targetConfig.prefix}${entity.id}`,
label: entityOptionLabel(currentTarget, entity)
}))
]), [currentTarget, entities, targetConfig]);
const summaryText = !overview.loading && overview.summaryReady
? overviewSummaryText({
target: currentTarget,
dateValue: date,
entityCount: gridRows.length,
occupiedCells: countOccupiedCells(gridRows, overview.timeSlots, overview.lessonsByEntitySlot),
totalCells: gridRows.length * overview.timeSlots.length,
failedGroupLoads: overview.failedGroupLoads,
selectedEntityId: null
})
: '';
const stateColSpan = Math.max(overview.timeSlots.length, 1) + 1;
return (
<div className="card">
<div className="card-header-row workload-card-header">
<h2>Загруженность</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="workload-refresh"
onClick={() => void loadCurrentView()}
>
Обновить
</button>
</div>
<div className="filter-row workload-filter-row">
<div className="form-group workload-target-control">
<label htmlFor="workload-target-select">Что смотреть</label>
<select
id="workload-target-select"
className="workload-display-select"
value={currentTarget}
onChange={event => handleTargetChange(event.target.value)}
>
<option value={TARGET_CLASSROOMS}>Аудитории</option>
<option value={TARGET_TEACHERS}>Преподаватели</option>
<option value={TARGET_DEPARTMENTS}>Кафедры</option>
</select>
</div>
<div className="form-group workload-display-control">
<label htmlFor="workload-display-select">Отображение</label>
<select
id="workload-display-select"
className="workload-display-select"
value={displayValue}
onChange={event => handleDisplayChange(event.target.value)}
>
{displayOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label>Корпус</label>
<WorkloadMultiSelect
options={buildingOptionItems}
selectedValues={buildings}
emptyText="Все корпуса"
onToggle={toggleBuilding}
/>
</div>
)}
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label htmlFor="workload-min-capacity">Мин. вместимость</label>
<input
type="number"
id="workload-min-capacity"
className="workload-min-capacity"
placeholder="0"
min="0"
value={minCapacityText}
onChange={event => setMinCapacityText(event.target.value)}
/>
</div>
)}
{showOverviewFilters && (
<div className="form-group workload-overview-filter">
<label>Оборудование</label>
<WorkloadMultiSelect
options={equipmentOptionItems}
selectedValues={equipment}
emptyText="Любое оборудование"
onToggle={toggleEquipment}
/>
</div>
)}
<div className="form-group workload-date-field">
<label htmlFor="workload-date">{roomMode ? 'Дата в периоде' : 'Дата'}</label>
<input
type="date"
id="workload-date"
value={date}
onChange={event => handleDateChange(event.target.value)}
/>
</div>
</div>
<FormAlert alert={alertState} id="workload-alert" />
{!roomMode && (
<div id="workload-overview-block">
<div className="workload-summary" id="workload-summary">{summaryText}</div>
<div className="workload-grid-container">
<table className="workload-table" id="workload-table">
<thead>
<tr id="workload-header-row">
<th className="top-left-cell">
<span className="top-label">Время</span>
<span className="bottom-label">{targetConfig.rowLabel}</span>
</th>
{overview.timeSlots.map(slot => (
<th key={slot.id ?? slot.orderNumber}>
<strong>{slot.orderNumber || ''} пара</strong>
<span>{timeSlotLabel(slot)}</span>
{slot.scopeApplyMode !== 'DEFAULT' && <small>{scopeShort(slot)}</small>}
</th>
))}
</tr>
</thead>
<tbody id="workload-tbody">
{overview.loading ? (
<tr>
<td colSpan={stateColSpan} className="loading-row">{overview.message}</td>
</tr>
) : overview.error ? (
<tr>
<td colSpan={stateColSpan} className="loading-row">{overview.error}</td>
</tr>
) : gridRows.length === 0 ? (
<tr>
<td colSpan={overview.timeSlots.length + 1} className="loading-row">
{targetConfig.emptyLabel}
</td>
</tr>
) : gridRows.map(entity => (
<WorkloadRow
key={entity.id}
target={currentTarget}
entity={entity}
timeSlots={overview.timeSlots}
lessonsByEntitySlot={overview.lessonsByEntitySlot}
/>
))}
</tbody>
</table>
</div>
</div>
)}
{roomMode && (
<div className="room-workload-block" id="room-workload-block">
<div className="room-workload-summary" id="room-workload-summary">
{detail.loading || detail.error || !detail.summary ? '' : (
<>
<div className="room-summary-main">
<strong>{detail.summary.name}</strong>
{detail.summary.details && <span>{detail.summary.details}</span>}
</div>
<span className="room-summary-chip">{detail.summary.rangeLabel}</span>
<span className="room-summary-chip">{detail.summary.occupancyLabel}</span>
{detail.summary.warnings && (
<span className="room-summary-warning">{detail.summary.warnings}</span>
)}
</>
)}
</div>
<div className="room-workload-tables" id="room-workload-tables">
{detail.loading ? (
<div className="loading-row room-workload-state">{detail.message}</div>
) : detail.error ? (
<div className="loading-row room-workload-state">{detail.error}</div>
) : !detail.weekTable ? (
<div className="loading-row room-workload-state">
За выбранный период нет временных слотов
</div>
) : (
<RoomWeekTable
target={currentTarget}
semesters={semesters}
datesByDay={detail.weekTable.datesByDay}
slotRows={detail.weekTable.slotRows}
slotsByDate={detail.weekTable.slotsByDate}
lessonsByDateSlot={detail.weekTable.lessonsByDateSlot}
/>
)}
</div>
</div>
)}
</div>
);
}
function WorkloadRow({ target, entity, timeSlots, lessonsByEntitySlot }) {
const axis = entityAxisParts(target, entity);
const axisClass = `axis-cell${target === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
return (
<tr>
<td className={axisClass}>
<strong>{axis.title}</strong>
<span>{axis.subtitle}</span>
{axis.note && <small>{axis.note}</small>}
</td>
{timeSlots.map(slot => {
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || [];
return (
<td
key={slot.id ?? slot.orderNumber}
className={lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}
>
{lessons.length
? lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: <span className="free-slot">Свободно</span>}
</td>
);
})}
</tr>
);
}
function LessonCard({ lesson }) {
const subgroupText = (lesson.subgroupNames || []).join(', ');
return (
<div className="lesson-card">
<div className="lesson-subject">{lesson.subjectName || 'Без дисциплины'}</div>
<div className="lesson-group">{(lesson.groupNames || []).join(', ') || 'Группа не указана'}</div>
{subgroupText && <div className="lesson-subgroup">{subgroupText}</div>}
<div className="lesson-teacher">{lesson.teacherName || 'Преподаватель не указан'}</div>
<div className="lesson-meta">
{[lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · ')}
</div>
</div>
);
}
function RoomWeekTable({ target, semesters, datesByDay, slotRows, slotsByDate, lessonsByDateSlot }) {
const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
return (
<section className="room-week-panel">
<div className="room-week-heading">
<h3>{config.periodTitle}</h3>
<span>{roomWeekCaption(datesByDay, semesters)}</span>
</div>
<div className="workload-grid-container room-grid-container">
<table className="workload-table room-week-table">
<thead>
<tr>
<th className="room-time-heading">Время</th>
{DAY_ORDER.map(day => (
<th className="room-day-cell" key={day}>
<strong>{dayShort(day)}</strong>
<span>{DAY_FULL_LABELS[day] || ''}</span>
<div className="room-day-parity-dates">
{CELL_PARITY_LAYOUT.map(({ parity, label }) => (
<span key={parity}>
{label}: {datesByDay[day]?.[parity]
? formatDateShort(datesByDay[day][parity])
: '-'}
</span>
))}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{slotRows.map(row => (
<tr key={row.key}>
<td className="room-time-cell">
<strong>{row.orderLabel}</strong>
<span>{row.timeLabel}</span>
</td>
{DAY_ORDER.map(day => (
<RoomCombinedCell
key={day}
datesByParity={datesByDay[day] || {}}
row={row}
slotsByDate={slotsByDate}
lessonsByDateSlot={lessonsByDateSlot}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function RoomCombinedCell({ datesByParity, row, slotsByDate, lessonsByDateSlot }) {
const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
label,
state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot)
}));
if (roomSlotStatesEqual(states[0].state, states[1].state)) {
const state = states[0].state;
return (
<td className="room-combined-cell room-single-cell">
<div className={`room-week-unified room-week-unified-${state.kind === 'busy' ? 'busy' : state.kind}`}>
{state.kind === 'busy'
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
: <span className="free-slot">Свободно</span>}
</div>
</td>
);
}
return (
<td className="room-combined-cell">
{states.map(({ parity, label, state }) => (
<div
key={parity}
className={`room-week-half room-week-half-${parity.toLowerCase()} room-week-half-${state.kind === 'busy' ? 'busy' : state.kind}`}
>
<span className="room-half-marker">{label}</span>
{state.kind === 'busy'
? state.lessons.map((lesson, index) => <LessonCard key={index} lesson={lesson} />)
: state.kind === 'missing' ? <span className="slot-unavailable">Нет слота</span>
: state.kind === 'empty' ? <span className="slot-unavailable">Нет даты</span>
: <span className="free-slot">Свободно</span>}
</div>
))}
</td>
);
}
function WorkloadMultiSelect({ options, selectedValues, emptyText, 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 selectedLabels = options
.filter(option => selectedValues.includes(option.value))
.map(option => option.label);
const label = selectedLabels.length === 0
? emptyText
: selectedLabels.length === 1 ? selectedLabels[0] : `Выбрано: ${selectedLabels.length}`;
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">{label}</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">
{options.length === 0 ? (
<div className="workload-filter-empty">Нет данных</div>
) : options.map(option => (
<label className="checkbox-item" key={option.value}>
<input
type="checkbox"
value={option.value}
checked={selectedValues.includes(option.value)}
onChange={() => onToggle(option.value)}
/>
<span className="checkmark"></span>
<span className="checkbox-label">{option.label}</span>
</label>
))}
</div>
</div>
</div>
);
}