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 (

Загруженность

{showOverviewFilters && (
)} {showOverviewFilters && (
setMinCapacityText(event.target.value)} />
)} {showOverviewFilters && (
)}
handleDateChange(event.target.value)} />
{!roomMode && (
{summaryText}
{overview.timeSlots.map(slot => ( ))} {overview.loading ? ( ) : overview.error ? ( ) : gridRows.length === 0 ? ( ) : gridRows.map(entity => ( ))}
Время {targetConfig.rowLabel} {slot.orderNumber || ''} пара {timeSlotLabel(slot)} {slot.scopeApplyMode !== 'DEFAULT' && {scopeShort(slot)}}
{overview.message}
{overview.error}
{targetConfig.emptyLabel}
)} {roomMode && (
{detail.loading || detail.error || !detail.summary ? '' : ( <>
{detail.summary.name} {detail.summary.details && {detail.summary.details}}
{detail.summary.rangeLabel} {detail.summary.occupancyLabel} {detail.summary.warnings && ( {detail.summary.warnings} )} )}
{detail.loading ? (
{detail.message}
) : detail.error ? (
{detail.error}
) : !detail.weekTable ? (
За выбранный период нет временных слотов
) : ( )}
)}
); } function WorkloadRow({ target, entity, timeSlots, lessonsByEntitySlot }) { const axis = entityAxisParts(target, entity); const axisClass = `axis-cell${target === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`; return ( {axis.title} {axis.subtitle} {axis.note && {axis.note}} {timeSlots.map(slot => { const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []; return ( {lessons.length ? lessons.map((lesson, index) => ) : Свободно} ); })} ); } function LessonCard({ lesson }) { const subgroupText = (lesson.subgroupNames || []).join(', '); return (
{lesson.subjectName || 'Без дисциплины'}
{(lesson.groupNames || []).join(', ') || 'Группа не указана'}
{subgroupText &&
{subgroupText}
}
{lesson.teacherName || 'Преподаватель не указан'}
{[lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · ')}
); } function RoomWeekTable({ target, semesters, datesByDay, slotRows, slotsByDate, lessonsByDateSlot }) { const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS]; return (

{config.periodTitle}

{roomWeekCaption(datesByDay, semesters)}
{DAY_ORDER.map(day => ( ))} {slotRows.map(row => ( {DAY_ORDER.map(day => ( ))} ))}
Время {dayShort(day)} {DAY_FULL_LABELS[day] || ''}
{CELL_PARITY_LAYOUT.map(({ parity, label }) => ( {label}: {datesByDay[day]?.[parity] ? formatDateShort(datesByDay[day][parity]) : '-'} ))}
{row.orderLabel} {row.timeLabel}
); } 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 (
{state.kind === 'busy' ? state.lessons.map((lesson, index) => ) : state.kind === 'missing' ? Нет слота : state.kind === 'empty' ? Нет даты : Свободно}
); } return ( {states.map(({ parity, label, state }) => (
{label} {state.kind === 'busy' ? state.lessons.map((lesson, index) => ) : state.kind === 'missing' ? Нет слота : state.kind === 'empty' ? Нет даты : Свободно}
))} ); } 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 (
{options.length === 0 ? (
Нет данных
) : options.map(option => ( ))}
); }