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