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

File diff suppressed because it is too large Load Diff

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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react';
import {
CONFLICT_CHECK_STATE,
classroomOccupancyLabel,
conflictCheckPresentation,
currentSlotLabel,
currentWeekDateRange,
detectScheduleConflicts,
fetchDashboardReferenceData,
fetchEffectiveTimeSlots,
fetchTodayLessons,
findCurrentSlot,
formatLocalDate,
freeClassroomsForSlot,
lessonsForSlot,
loadWeekDepartmentSchedules,
sortTimeSlots,
totalStudents
} from '../dashboard-service.js';
const REALTIME_UPDATE_MS = 30_000;
export function DashboardTab() {
const [reference, setReference] = useState(null);
const [todayLessons, setTodayLessons] = useState([]);
const [timeSlots, setTimeSlots] = useState([]);
const [dataPhase, setDataPhase] = useState('loading');
const [conflictPhase, setConflictPhase] = useState('checking');
const [conflicts, setConflicts] = useState({ items: [], presentation: null });
const [recheckBusy, setRecheckBusy] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
void loadData();
}, []);
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), REALTIME_UPDATE_MS);
return () => clearInterval(timer);
}, []);
async function loadData() {
setDataPhase('loading');
setConflictPhase('checking');
try {
const loadedReference = await fetchDashboardReferenceData();
const todayStr = formatLocalDate(new Date());
const [lessons, slots] = await Promise.all([
fetchTodayLessons(loadedReference.departments, todayStr),
fetchEffectiveTimeSlots(todayStr)
]);
setReference(loadedReference);
setTodayLessons(lessons);
setTimeSlots(sortTimeSlots(slots));
setDataPhase('ready');
await checkConflicts(loadedReference);
} catch {
setDataPhase('error');
setConflictPhase('error');
console.error('Не удалось загрузить данные панели');
}
}
async function checkConflicts(referenceData) {
setConflictPhase('checking');
try {
const range = currentWeekDateRange(new Date());
const scheduleLoad = await loadWeekDepartmentSchedules(referenceData.departments, range);
const items = detectScheduleConflicts(scheduleLoad.lessons, referenceData.groups, referenceData.classrooms);
const presentation = scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN
? conflictCheckPresentation(scheduleLoad, 0, range)
: conflictCheckPresentation(scheduleLoad, items.length, range);
setConflicts({ items, presentation });
setConflictPhase('ready');
} catch {
setConflictPhase('error');
console.error('Не удалось выполнить проверку конфликтов');
}
}
async function recheck() {
setRecheckBusy(true);
try {
if (dataPhase === 'ready' && reference) {
await checkConflicts(reference);
} else {
await loadData();
}
} finally {
setRecheckBusy(false);
}
}
const ready = dataPhase === 'ready' && reference !== null;
const currentSlot = ready ? findCurrentSlot(timeSlots, now) : null;
const currentLessons = currentSlot ? lessonsForSlot(todayLessons, currentSlot.orderNumber) : [];
const freeRooms = ready ? freeClassroomsForSlot(reference.classrooms, todayLessons, currentSlot ? currentSlot.orderNumber : null) : [];
return (
<div className="dashboard-container">
<div className="dashboard-metrics-grid">
<MetricCard
tone="groups"
label="Активные группы"
value={ready ? String(reference.groups.length) : '—'}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="students"
label="Всего студентов"
value={ready ? String(totalStudents(reference.groups)) : '—'}
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</MetricCard>
<MetricCard
tone="teachers"
label="Преподаватели"
value={ready ? String(reference.teachers.length) : '—'}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</MetricCard>
<MetricCard
tone="classrooms"
label="Загрузка аудиторий сегодня"
value={ready ? classroomOccupancyLabel(reference.classrooms, todayLessons) : '—'}
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</MetricCard>
</div>
<div className="dashboard-middle">
<div className="card dashboard-monitor-card">
<div className="dashboard-card-header">
<h2>Мониторинг пар (Идет сейчас)</h2>
<div className="dashboard-time-slot">
{ready ? currentSlotLabel(currentSlot) : '—'}
</div>
</div>
<div className="table-wrap dashboard-monitor-table">
<table>
<thead>
<tr>
<th>Аудитория</th>
<th>Дисциплина</th>
<th>Преподаватель</th>
<th>Группа</th>
</tr>
</thead>
<tbody>
{!ready ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row dashboard-state-row--wide">Определение текущего времени...</td></tr>
) : !currentSlot ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В данный момент учебные пары не проводятся</td></tr>
) : currentLessons.length === 0 ? (
<tr><td colSpan="4" className="loading-row dashboard-state-row">В текущий слот занятий не запланировано</td></tr>
) : currentLessons.map(lesson => (
<tr key={`${lesson.classroomId}-${lesson.subjectId}-${lesson.teacherId}-${lesson.groupNames.join(',')}`}>
<td><strong>{lesson.classroomName}</strong></td>
<td>
{lesson.subjectName}{' '}
<span className="badge badge-ef badge--lesson-type">{lesson.lessonTypeName}</span>
</td>
<td>{lesson.teacherName}</td>
<td><span className="badge badge-available">{lesson.groupNames.join(', ')}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="dashboard-side-stack">
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div className="dashboard-badge-list" tabIndex="0" role="region" aria-label="Свободные аудитории">
{!ready ? (
<div className="dashboard-free-hint">Загрузка...</div>
) : freeRooms.length === 0 ? (
<span className="dashboard-free-busy">Все аудитории заняты</span>
) : freeRooms.map(classroom => (
<span className="badge badge-available dashboard-free-badge" key={classroom.id}>
{classroom.name} ({classroom.capacity} м.)
</span>
))}
</div>
</div>
<div className="card dashboard-compact-card">
<h3 className="dashboard-card-title">Быстрый переход</h3>
<div className="dashboard-actions">
<a href="#schedule" className="btn btn-md btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Конструктор расписания
</a>
<a href="#schedule-view" className="btn btn-md btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
Точечные изменения / Просмотр
</a>
</div>
</div>
</div>
</div>
<div className="card red-zone-card">
<div className="red-zone-header">
<div>
<h2 className="red-zone-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Центр предупреждений (Red Zone)
</h2>
<div className="red-zone-subtitle">Анализ накладок и коллизий расписания на текущую неделю</div>
</div>
<button
type="button"
className="btn btn-sm btn-secondary"
id="btn-recheck-conflicts"
disabled={recheckBusy}
aria-busy={recheckBusy ? 'true' : undefined}
onClick={() => void recheck()}
>
{recheckBusy ? 'Проверка…' : 'Перепроверить'}
</button>
</div>
<div
className="red-zone-conflicts"
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy={conflictPhase === 'checking' ? 'true' : undefined}
>
{conflictPhase === 'checking' ? (
<div className="dashboard-check-loading">Анализ расписания и выявление конфликтов...</div>
) : conflictPhase === 'error' ? (
<CheckStateCard
tone="error"
title="Проверка не выполнена"
description={dataPhase === 'error'
? 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
: 'Во время анализа произошла ошибка. Повторите проверку позже.'}
/>
) : (
<>
{conflicts.presentation && (
<CheckStateCard
tone={conflicts.presentation.tone}
title={conflicts.presentation.title}
description={conflicts.presentation.description}
meta={conflicts.presentation.meta}
/>
)}
{conflicts.items.map((conflict, index) => (
<ConflictCard conflict={conflict} key={`${conflict.type}-${index}`} />
))}
</>
)}
</div>
</div>
</div>
);
}
function MetricCard({ tone, label, value, children }) {
return (
<div className={`card dashboard-metric-card dashboard-metric-card--${tone}`}>
<div className="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</div>
<div>
<div className="dashboard-metric-label">{label}</div>
<h3 className="dashboard-metric-value">{value}</h3>
</div>
</div>
);
}
function CheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? <path d="M20 6 9 17l-5-5" />
: tone === 'warning'
? (
<>
<path d="M12 9v4m0 4h.01" />
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</>
)
: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5m0 3h.01" />
</>
);
return (
<section className={`dashboard-check-state dashboard-check-state--${tone}`}>
<span className="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
</span>
<span className="dashboard-check-state__body">
<strong>{title}</strong>
<span>{description}</span>
{meta ? <small>{meta}</small> : null}
</span>
</section>
);
}
function ConflictCard({ conflict }) {
const isError = conflict.severity === 'error';
return (
<div className={`dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}`}>
<div className="dashboard-conflict-card__icon">
{isError ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
)}
</div>
<div className="dashboard-conflict-card__body">
<h4>{conflict.title}</h4>
<p>
{(conflict.segments || []).map((segment, index) => (
typeof segment === 'string'
? segment
: <strong key={index}>{segment.strong}</strong>
))}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,774 @@
import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { addDays, toIsoDate } from '../../shared/dates.js';
import { requestCountLabel } from '../admin-service.js';
import {
DEPARTMENT_REQUEST_TABLE_COLUMNS,
SUBJECT_TABLE_COLUMNS,
assignTeacher,
assignTeacherError,
availableTeachers,
defaultWorkloadPeriod,
departmentOptionLabel,
fetchAllTeachers,
fetchDepartmentSubjects,
fetchDepartmentTeacherRequests,
fetchDepartmentTeachers,
fetchDepartments,
fetchSemesters,
fetchSubjectComments,
fetchTeacherWorkload,
formatDateTime,
importSubjects,
importSubjectsPayload,
requestStatusClass,
requestStatusLabel,
saveSubjectComment,
subjectCountLabel,
submitTeacherRequest,
teacherMetrics,
teacherOptionLabel,
teacherRequestPayload,
workloadLabel
} from '../department-workspace-service.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export function DepartmentWorkspaceTab() {
const [session] = useState(() => getSession());
const role = session?.role;
const userDepartmentId = session?.departmentId;
const isDepartmentRole = role === ROLE_DEPARTMENT;
const departmentIdRef = useRef('');
const startDateRef = useRef(toIsoDate(new Date()));
const endDateRef = useRef(toIsoDate(addDays(new Date(), 7)));
const [departments, setDepartments] = useState([]);
const [departmentId, setDepartmentId] = useState('');
const [startDate, setStartDate] = useState(startDateRef.current);
const [endDate, setEndDate] = useState(endDateRef.current);
const [workspaceAlert, setWorkspaceAlert] = useState({ text: '', isError: false });
const [subjects, setSubjects] = useState({ loading: true, items: [], error: '' });
const [teachers, setTeachers] = useState({ items: null, error: '' });
const [workload, setWorkload] = useState({ items: null, error: '' });
const [requests, setRequests] = useState({ items: null, error: '' });
const [allTeachers, setAllTeachers] = useState([]);
const [allTeachersError, setAllTeachersError] = useState('');
const [importRows, setImportRows] = useState([{ code: '', name: '' }]);
const [importAlert, setImportAlert] = useState({ text: '', isError: false });
const [assignTeacherId, setAssignTeacherId] = useState('');
const [assignComment, setAssignComment] = useState('');
const [assignAlert, setAssignAlert] = useState({ text: '', isError: false });
const [requestForm, setRequestForm] = useState({ username: '', fullName: '', jobTitle: '', comment: '' });
const [requestAlert, setRequestAlert] = useState({ text: '', isError: false });
const [comments, setComments] = useState(null);
const [commentText, setCommentText] = useState('');
const [commentAlert, setCommentAlert] = useState({ text: '', isError: false });
function currentDepartmentId() {
return isDepartmentRole ? userDepartmentId : departmentIdRef.current;
}
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchDepartments();
if (!active) return;
const list = loaded || [];
setDepartments(list);
if (isDepartmentRole) {
departmentIdRef.current = userDepartmentId || '';
setDepartmentId(departmentIdRef.current);
} else if (list.length) {
departmentIdRef.current = String(list[0].id);
setDepartmentId(departmentIdRef.current);
}
try {
const semesters = await fetchSemesters();
const period = defaultWorkloadPeriod(semesters, new Date());
startDateRef.current = period.startDate;
endDateRef.current = period.endDate;
if (active) {
setStartDate(period.startDate);
setEndDate(period.endDate);
}
} catch {
// Текущий диапазон остаётся безопасным fallback, если справочник
// семестров недоступен.
}
try {
const loadedTeachers = await fetchAllTeachers();
if (active) setAllTeachers(Array.isArray(loadedTeachers) ? loadedTeachers : []);
} catch (error) {
if (active) setAllTeachersError(error.message || 'Ошибка загрузки преподавателей');
}
if (!active) return;
await loadWorkspace();
} catch (error) {
if (!active) return;
setWorkspaceAlert({ text: error.message || 'Ошибка загрузки кабинета кафедры', isError: true });
}
})();
return () => {
active = false;
};
}, []);
async function loadWorkspace() {
setWorkspaceAlert({ text: '', isError: false });
setTeachers({ items: null, error: '' });
setWorkload({ items: null, error: '' });
setRequests({ items: null, error: '' });
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
setSubjects({ loading: true, items: [], error: '' });
try {
const items = await fetchDepartmentSubjects(currentDepartmentId());
setSubjects({ loading: false, items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setSubjects({ loading: false, items: [], error: error.message || 'Ошибка загрузки дисциплин' });
}
}
async function loadTeachers() {
setTeachers({ items: null, error: '' });
try {
const items = await fetchDepartmentTeachers(currentDepartmentId());
setTeachers({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setTeachers({ items: [], error: error.message || 'Ошибка загрузки преподавателей' });
}
}
async function loadWorkload() {
if (!startDateRef.current || !endDateRef.current) {
setWorkload({ items: [], error: 'Выберите начало и окончание периода' });
return;
}
setWorkload({ items: null, error: '' });
try {
const items = await fetchTeacherWorkload({
startDate: startDateRef.current,
endDate: endDateRef.current,
departmentId: currentDepartmentId()
});
setWorkload({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setWorkload({ items: [], error: error.message || 'Ошибка расчёта нагрузки' });
}
}
async function loadTeacherRequests() {
setRequests({ items: null, error: '' });
try {
const items = await fetchDepartmentTeacherRequests(currentDepartmentId());
setRequests({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setRequests({ items: [], error: error.message || 'Ошибка загрузки заявок' });
}
}
function handleDepartmentChange(value) {
departmentIdRef.current = value;
setDepartmentId(value);
void loadWorkspace();
}
function handleStartDateChange(value) {
startDateRef.current = value;
setStartDate(value);
if (value && endDateRef.current && value > endDateRef.current) {
endDateRef.current = '';
setEndDate('');
}
void loadWorkload();
}
function handleEndDateChange(value) {
endDateRef.current = value;
setEndDate(value);
void loadWorkload();
}
// --- Загрузка дисциплин ---
function addImportRow() {
setImportRows(current => [...current, { code: '', name: '' }]);
}
function updateImportRow(index, patch) {
setImportRows(current => current.map((row, rowIndex) => (
rowIndex === index ? { ...row, ...patch } : row
)));
}
function removeImportRow(index) {
setImportRows(current => {
const rows = current.filter((_, rowIndex) => rowIndex !== index);
return rows.length ? rows : [{ code: '', name: '' }];
});
}
async function submitImport(event) {
event.preventDefault();
setImportAlert({ text: '', isError: false });
const rows = importRows.map(row => ({
code: (row.code || '').trim(),
name: (row.name || '').trim()
}));
const { payload, error } = importSubjectsPayload(rows, currentDepartmentId());
if (error) {
setImportAlert({ text: error, isError: true });
return;
}
try {
const result = await importSubjects(payload);
setImportAlert({ text: result?.message || 'Дисциплины загружены', isError: false });
setImportRows([{ code: '', name: '' }]);
await loadSubjects();
} catch (err) {
setImportAlert({ text: err.message || 'Ошибка загрузки дисциплин', isError: true });
}
}
// --- Привязка преподавателя ---
async function submitAssign(event) {
event.preventDefault();
setAssignAlert({ text: '', isError: false });
const error = assignTeacherError(currentDepartmentId(), assignTeacherId);
if (error) {
setAssignAlert({ text: error, isError: true });
return;
}
try {
await assignTeacher(assignTeacherId, currentDepartmentId(), assignComment.trim());
setAssignAlert({ text: 'Преподаватель привязан к кафедре', isError: false });
setAssignComment('');
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (err) {
setAssignAlert({ text: err.message || 'Ошибка привязки преподавателя', isError: true });
}
}
// --- Заявка на преподавателя ---
async function submitRequest(event) {
event.preventDefault();
setRequestAlert({ text: '', isError: false });
const { payload, error } = teacherRequestPayload(requestForm, currentDepartmentId());
if (error) {
setRequestAlert({ text: error, isError: true });
return;
}
try {
await submitTeacherRequest(payload);
setRequestAlert({ text: 'Заявка отправлена администратору', isError: false });
setRequestForm({ username: '', fullName: '', jobTitle: '', comment: '' });
await loadTeacherRequests();
} catch (err) {
setRequestAlert({ text: err.message || 'Ошибка отправки заявки', isError: true });
}
}
// --- Комментарии к дисциплинам ---
async function openComments(subjectId, subjectName) {
setComments({ subjectId, subjectName, items: null, error: '' });
setCommentText('');
setCommentAlert({ text: '', isError: false });
try {
const items = await fetchSubjectComments(subjectId);
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: Array.isArray(items) ? items : [], error: '' }
: current
));
} catch (error) {
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: [], error: error.message || 'Ошибка загрузки комментариев' }
: current
));
}
}
async function submitComment(event) {
event.preventDefault();
const subjectId = comments?.subjectId;
const comment = commentText.trim();
if (!subjectId) {
setCommentAlert({ text: 'Выберите дисциплину', isError: true });
return;
}
if (!comment) {
setCommentAlert({ text: 'Комментарий не должен быть пустым', isError: true });
return;
}
try {
await saveSubjectComment(subjectId, comment);
setCommentAlert({ text: 'Комментарий сохранён', isError: false });
await openComments(subjectId, comments.subjectName);
} catch (error) {
setCommentAlert({ text: error.message || 'Ошибка сохранения комментария', isError: true });
}
}
const metricRows = teachers.items === null || workload.items === null
? null
: teacherMetrics(teachers.items, workload.items);
const freeTeachers = teachers.items === null
? null
: availableTeachers(allTeachers, teachers.items);
const requestsCountLabel = requests.error
? '0 заявок'
: (requests.items === null ? 'Загрузка...' : requestCountLabel(requests.items.length));
const subjectsCountLabel = subjects.error
? '0 дисциплин'
: (subjects.loading ? 'Загрузка...' : subjectCountLabel(subjects.items.length));
return (
<>
<div className="card department-workspace-control-card">
<div className="card-header-row">
<h2>Рабочая область кафедры</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="department-workspace-refresh"
onClick={() => void loadWorkspace()}
>
Обновить
</button>
</div>
<div className="department-workspace-filter-grid">
{!isDepartmentRole && (
<div className="form-group" id="department-workspace-department-field">
<label htmlFor="department-workspace-department">Кафедра</label>
<select
id="department-workspace-department"
value={departmentId}
onChange={event => handleDepartmentChange(event.target.value)}
>
<option value="">Выберите кафедру</option>
{departments.map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label htmlFor="department-workspace-start">Начало периода</label>
<input
type="date"
id="department-workspace-start"
value={startDate}
onChange={event => handleStartDateChange(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="department-workspace-end">Окончание периода</label>
<input
type="date"
id="department-workspace-end"
value={endDate}
onChange={event => handleEndDateChange(event.target.value)}
/>
</div>
</div>
<div
className={`form-alert${workspaceAlert.text ? (workspaceAlert.isError ? ' error' : ' success') : ''}`}
id="department-workspace-alert"
role="alert"
>
{workspaceAlert.text}
</div>
</div>
<div className="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div className="card create-card department-subject-import-card">
<div className="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" className="stack-form" onSubmit={submitImport}>
<div className="subject-import-slots-panel">
<div className="card-header-row">
<label className="schedule-section-label schedule-section-label--flush">
Дисциплины для загрузки
</label>
<button
type="button"
className="btn btn-sm btn-secondary"
id="subject-import-add"
onClick={addImportRow}
>
Добавить строку
</button>
</div>
<div id="subject-import-rows" className="subject-import-rows-list">
{importRows.map((row, index) => (
<div className="subject-import-row" key={index}>
<div className="form-group">
<label>Код</label>
<input
type="text"
className="subject-import-code"
placeholder="DB201"
value={row.code}
onChange={event => updateImportRow(index, { code: event.target.value })}
/>
</div>
<div className="form-group">
<label>Название</label>
<input
type="text"
className="subject-import-name"
placeholder="Базы данных"
value={row.name}
onChange={event => updateImportRow(index, { name: event.target.value })}
/>
</div>
<button
type="button"
className="btn btn-sm btn-danger subject-import-remove"
onClick={() => removeImportRow(index)}
>
Удалить
</button>
</div>
))}
</div>
</div>
<button type="submit" className="btn btn-md btn-secondary">Загрузить</button>
<div
className={`form-alert${importAlert.text ? (importAlert.isError ? ' error' : ' success') : ''}`}
id="department-subject-import-alert"
role="alert"
>
{importAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-assign-card">
<div className="card-header-row">
<h2>Добавить преподавателя</h2>
</div>
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
<div className="form-group">
<label htmlFor="department-teacher-select">Преподаватель</label>
<select
id="department-teacher-select"
value={assignTeacherId}
onChange={event => setAssignTeacherId(event.target.value)}
>
{freeTeachers === null ? (
<option value="">Загрузка...</option>
) : allTeachersError ? (
<option value="">{allTeachersError}</option>
) : freeTeachers.length === 0 ? (
<option value="">Нет доступных преподавателей</option>
) : (
<>
<option value="">Выберите преподавателя</option>
{freeTeachers.map(teacher => (
<option key={teacher.id} value={teacher.id}>
{teacherOptionLabel(teacher)}
</option>
))}
</>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
<input
type="text"
id="department-teacher-assign-comment"
placeholder="Например, ведет лабораторные работы"
value={assignComment}
onChange={event => setAssignComment(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-secondary">Привязать к кафедре</button>
<div
className={`form-alert${assignAlert.text ? (assignAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-assign-alert"
role="alert"
>
{assignAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-request-card">
<div className="card-header-row">
<h2>Заявка на преподавателя</h2>
</div>
<form id="department-teacher-request-form" className="stack-form" onSubmit={submitRequest}>
<div className="form-group">
<label htmlFor="teacher-request-username">Логин</label>
<input
type="text"
id="teacher-request-username"
placeholder="ivanov_teacher"
maxLength={50}
required
value={requestForm.username}
onChange={event => setRequestForm(current => (
{ ...current, username: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-fullname">ФИО</label>
<input
type="text"
id="teacher-request-fullname"
placeholder="Иванов Иван Иванович"
maxLength={255}
required
value={requestForm.fullName}
onChange={event => setRequestForm(current => (
{ ...current, fullName: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-jobtitle">Должность</label>
<input
type="text"
id="teacher-request-jobtitle"
placeholder="Доцент"
maxLength={255}
value={requestForm.jobTitle}
onChange={event => setRequestForm(current => (
{ ...current, jobTitle: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-comment">Комментарий</label>
<input
type="text"
id="teacher-request-comment"
placeholder="Что важно проверить администратору"
value={requestForm.comment}
onChange={event => setRequestForm(current => (
{ ...current, comment: event.target.value }
))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Отправить заявку</button>
<div
className={`form-alert${requestAlert.text ? (requestAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-request-alert"
role="alert"
>
{requestAlert.text}
</div>
</form>
</div>
<div className="card department-teachers-card">
<div className="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div className="metric-grid" id="department-teachers-list">
{metricRows === null ? (
'Загрузка...'
) : metricRows.length === 0 ? (
<div className="loading-row">
{teachers.error || 'Преподаватели не найдены'}
</div>
) : metricRows.map((row, index) => (
<article className="metric-card" key={index}>
<strong>{row.name || '-'}</strong>
<span>{row.meta || 'Должность не указана'}</span>
<small>{workloadLabel(row.workload, workload.error)}</small>
</article>
))}
</div>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Заявки кафедры</h2>
<span className="muted" id="department-teacher-request-count">{requestsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Статус</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Комментарий</th>
<th>Рассмотрение</th>
</tr>
</thead>
<tbody id="department-teacher-requests-tbody">
{requests.items === null && !requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
{requests.error}
</td>
</tr>
) : requests.items.length === 0 ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Заявок пока нет
</td>
</tr>
) : requests.items.map(request => (
<tr key={request.id}>
<td>
<span className={`badge ${requestStatusClass(request.status)}`}>
{requestStatusLabel(request.status)}
</span>
</td>
<td>{request.username || '-'}</td>
<td>{request.fullName || '-'}</td>
<td>{request.jobTitle || '-'}</td>
<td>{request.comment || '-'}</td>
<td>{request.reviewComment || formatDateTime(request.reviewedAt) || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Дисциплины кафедры</h2>
<span className="muted" id="department-subject-count">{subjectsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
{subjects.loading ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : subjects.error ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
{subjects.error}
</td>
</tr>
) : subjects.items.length === 0 ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Дисциплины не найдены
</td>
</tr>
) : subjects.items.map(subject => (
<tr key={subject.id}>
<td>{subject.code || '-'}</td>
<td>{subject.name || '-'}</td>
<td>{subject.description || '-'}</td>
<td>
<button
type="button"
className="btn btn-sm btn-secondary department-comments-open"
onClick={() => void openComments(subject.id, subject.name || '')}
>
Открыть
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{comments && (
<div className="card department-workspace-table-card" id="department-comments-card">
<div className="card-header-row">
<h2 id="department-comments-title">
Комментарии: {comments.subjectName || 'дисциплина'}
</h2>
<button
type="button"
className="btn btn-sm btn-ghost"
id="department-comments-close"
onClick={() => setComments(null)}
>
Скрыть
</button>
</div>
<form id="department-comment-form" className="form-row" onSubmit={submitComment}>
<div className="form-group">
<label htmlFor="department-comment-text">Новый комментарий</label>
<input
type="text"
id="department-comment-text"
placeholder="Добавьте примечание по дисциплине"
value={commentText}
onChange={event => setCommentText(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</form>
<div
className={`form-alert${commentAlert.text ? (commentAlert.isError ? ' error' : ' success') : ''}`}
id="department-comment-alert"
role="alert"
>
{commentAlert.text}
</div>
<div className="department-comment-list" id="department-comment-list">
{comments.items === null ? (
'Загрузка...'
) : comments.error ? (
<div className="loading-row">{comments.error}</div>
) : comments.items.length === 0 ? (
<div className="loading-row">Комментариев пока нет</div>
) : comments.items.map((comment, index) => (
<article className="department-comment-item" key={index}>
<strong>{comment.authorName || 'Автор не указан'}</strong>
<span>{formatDateTime(comment.createdAt)}</span>
<p>{comment.comment}</p>
</article>
))}
</div>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
QUALITY_YEAR_STORAGE_KEY,
SCHEDULE_OPEN_RULE_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY
} from '../schedule-versions-service.js';
import {
fetchScheduleQualityAnalysis,
fetchScheduleSemesters,
fetchScheduleVersions,
filterQualityProblems,
formatLocalDate,
formatMetricValue,
formatQualityDate,
lessonWord,
problemTotalLabel,
qualityBand,
qualitySemesterLabel,
severityLabel,
versionStatusLabel
} from '../schedule-quality-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Выберите семестр, чтобы получить объяснимую оценку.', tone: '' });
export function ScheduleQualityTab() {
const [semesters, setSemesters] = useState([]);
const [semestersPhase, setSemestersPhase] = useState('loading');
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [versionPhase, setVersionPhase] = useState('idle');
const [versionId, setVersionId] = useState('');
const [analysis, setAnalysis] = useState(null);
const [resultsHidden, setResultsHidden] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [severityFilter, setSeverityFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedProblemId, setSelectedProblemId] = useState(null);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, versionId };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const visibleProblems = useMemo(
() => analysis ? filterQualityProblems(analysis.problems || [], severityFilter, categoryFilter) : [],
[analysis, severityFilter, categoryFilter]
);
const band = analysis ? qualityBand(analysis.score) : null;
const selectedProblem = analysis
? (analysis.problems || []).find(problem => problem.id === selectedProblemId)
: null;
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
if (!loadedSemesters.length) {
setSemestersPhase('empty');
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'warning' });
return;
}
setSemestersPhase('ready');
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(QUALITY_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(QUALITY_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, selected.academicYearId);
await loadVersions(selected.semesterId);
} catch (error) {
setSemestersPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
async function loadVersions(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId) return;
setVersionPhase('loading');
try {
const loadedVersions = await fetchScheduleVersions(nextSemesterId);
setVersions(loadedVersions);
if (!loadedVersions.length) {
setVersionPhase('empty');
return;
}
setVersionPhase('ready');
const savedVersionId = window.localStorage.getItem(QUALITY_VERSION_STORAGE_KEY);
const saved = loadedVersions.find(version => String(version.id) === savedVersionId);
const published = loadedVersions.find(version => version.status === 'PUBLISHED');
setVersionId(String((saved || published || loadedVersions[0]).id));
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, nextSemesterId);
} catch (error) {
setVersionPhase('error');
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(QUALITY_YEAR_STORAGE_KEY, nextYearId);
void loadVersions(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadVersions(nextSemesterId);
}
async function runAnalysis() {
const currentSemesterId = latestRef.current.semesterId;
const currentVersionId = latestRef.current.versionId;
if (!currentSemesterId) {
setStatus({ message: 'Выберите семестр для анализа.', tone: 'warning' });
return;
}
if (!currentVersionId) {
setStatus({ message: 'Выберите версию расписания для анализа.', tone: 'warning' });
return;
}
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, currentSemesterId);
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, currentVersionId);
setAnalyzing(true);
setResultsHidden(true);
setStatus({ message: 'Строим фактическое расписание и рассчитываем показатели…', tone: 'loading' });
try {
const nextAnalysis = await fetchScheduleQualityAnalysis(currentSemesterId, currentVersionId);
setAnalysis(nextAnalysis);
setSelectedProblemId(null);
setResultsHidden(false);
trackUxEvent('качество_расписания_проверено', {
semesterId: currentSemesterId,
versionId: currentVersionId,
score: Number(nextAnalysis.score || 0)
});
setStatus({
message: nextAnalysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
tone: 'success'
});
} catch (error) {
setStatus({ message: error.message || 'Не удалось выполнить анализ качества.', tone: 'error' });
} finally {
setAnalyzing(false);
}
}
function handleProblemClick(problem, action) {
if (action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
setSelectedProblemId(problem.id);
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || analysis?.scheduleVersionStatus === 'ARCHIVED') return;
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(analysis.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(analysis.scheduleVersionId));
window.localStorage.setItem(SCHEDULE_OPEN_RULE_STORAGE_KEY, String(problem.scheduleRuleId));
window.location.hash = 'schedule';
}
const yearOptions = semestersPhase === 'ready' || semestersPhase === 'empty'
? (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>)
: <option value="">Загрузка годов…</option>;
const semesterOptions = semestersPhase === 'error'
? <option value="">Ошибка загрузки</option>
: (semestersPhase === 'ready' || semestersPhase === 'empty'
? (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{qualitySemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>)
: <option value="">Сначала выберите год</option>);
const versionPlaceholder = {
idle: 'Сначала выберите семестр',
loading: 'Загрузка версий…',
empty: 'Версии не созданы',
error: 'Ошибка загрузки'
}[versionPhase];
const versionOptions = versionPhase === 'ready'
? versions.map(version => (
<option key={String(version.id)} value={String(version.id)}>
v{version.versionNumber} · {version.name} · {versionStatusLabel(version.status)}
</option>
))
: <option value="">{versionPlaceholder}</option>;
const archived = analysis?.scheduleVersionStatus === 'ARCHIVED';
return (
<section className="quality-lab" aria-labelledby="quality-lab-title">
<header className="quality-hero">
<div className="quality-hero-copy">
<span className="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
суммируются. Черновик можно оценить до публикации.
</p>
</div>
<div className="quality-run-panel">
<label htmlFor="quality-year">Учебный год</label>
<select id="quality-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
<label htmlFor="quality-semester">Семестр</label>
<select id="quality-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
<label htmlFor="quality-version">Версия</label>
<select id="quality-version" value={versionId} onChange={event => setVersionId(event.target.value)}>
{versionOptions}
</select>
<button
type="button"
className="btn btn-md btn-primary"
id="quality-run"
disabled={analyzing}
onClick={() => void runAnalysis()}
>
{analyzing ? 'Анализируем…' : 'Выполнить анализ'}
</button>
</div>
</header>
<div
className="quality-status"
id="quality-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<div className="quality-results" id="quality-results" hidden={resultsHidden}>
{analysis ? (
<>
<section className="quality-scoreboard" aria-label="Итог анализа">
<div className="quality-score-orbit" id="quality-score-orbit" data-band={band.code}>
<svg className="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle className="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle
className="quality-score-ring-value"
id="quality-score-ring-value"
cx="18"
cy="18"
r="15.9155"
pathLength="100"
strokeDasharray={`${Math.max(0, Math.min(100, analysis.score))} 100`}
></circle>
</svg>
<div className="quality-score-inner">
<strong id="quality-score">{analysis.score}</strong>
<span>из 100</span>
</div>
</div>
<div className="quality-score-copy">
<span className="quality-score-band" id="quality-score-band" data-band={band.code}>
{band.label}
</span>
<h3 id="quality-semester-label">{analysis.semesterLabel}</h3>
<p id="quality-score-summary">{band.summary}</p>
<div className="quality-score-meta" id="quality-score-meta">
<span>{versionStatusLabel(analysis.scheduleVersionStatus)} · v{analysis.scheduleVersionNumber || '—'}</span>
<span>{analysis.lessonCount} занятий</span>
<span>правил с проблемами: {analysis.totalProblemCount}</span>
<span>штраф {analysis.rawPenalty}</span>
</div>
</div>
</section>
<section className="quality-metrics" id="quality-metrics" aria-label="Метрики качества">
{(analysis.metrics || []).map((metric, index) => (
<article className={`quality-metric quality-metric-${index + 1}`} key={index}>
<span className="quality-metric-index">{String(index + 1).padStart(2, '0')}</span>
<strong>{formatMetricValue(metric)}</strong>
<h4>{metric.label}</h4>
<p>{metric.explanation}</p>
<span className="quality-metric-penalty">штраф {metric.penalty}</span>
</article>
))}
</section>
<section className="quality-workbench">
<div className="quality-problems-column">
<div className="quality-section-heading">
<div>
<span className="quality-kicker">Карта правил</span>
<h3>Правила, снижающие оценку</h3>
</div>
<span className="quality-problem-total" id="quality-problem-total">
{problemTotalLabel(analysis)}
</span>
</div>
<div className="quality-filters">
<label>
Важность
<select
id="quality-severity-filter"
value={severityFilter}
onChange={event => setSeverityFilter(event.target.value)}
>
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select
id="quality-type-filter"
value={categoryFilter}
onChange={event => setCategoryFilter(event.target.value)}
>
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div className="quality-problems" id="quality-problems">
{visibleProblems.length ? visibleProblems.map(problem => {
const selected = problem.id === selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return (
<article
className={`quality-problem${selected ? ' is-selected' : ''}`}
key={String(problem.id)}
data-severity={problem.severity}
>
<button
type="button"
className="quality-problem-main"
onClick={() => handleProblemClick(problem)}
>
<span className="quality-severity">{severityLabel(problem.severity)}</span>
<span className="quality-problem-copy">
<strong>{problem.title}</strong>
<span>{problem.description}</span>
<span className="quality-rule-issues">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</span>
<small>
Правило #{problem.scheduleRuleId || '—'}
· затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}
· суммарный штраф
</small>
</span>
<span className="quality-penalty">−{problem.penalty}</span>
</button>
<div className="quality-problem-action">
{canEdit ? (
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleProblemClick(problem, 'edit-rule')}
>
Изменить правило
</button>
) : <span>Только просмотр</span>}
</div>
</article>
);
}) : (
<div className="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
)}
</div>
</div>
<aside className="quality-recommendations" id="quality-recommendations" aria-live="polite">
{selectedProblem ? (
<RuleDetails problem={selectedProblem} archived={archived} onEdit={openRuleInConstructor} />
) : (
<div className="quality-recommendations-empty">
<span className="quality-recommendations-mark">↗</span>
<h3>{archived ? 'Архивная версия' : 'Работа с правилом'}</h3>
<p>
{archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть его в конструкторе.'}
</p>
</div>
)}
</aside>
</section>
</>
) : null}
</div>
</section>
);
}
function RuleDetails({ problem, archived, onEdit }) {
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = !archived && problem.scheduleRuleId;
return (
<>
<div className="quality-recommendations-head">
<span className="quality-kicker">Правило #{problem.scheduleRuleId || '—'}</span>
<h3>{problem.subjectName || problem.title}</h3>
<p>Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.</p>
</div>
<div className="quality-rule-detail-list">
{issueLabels.map((label, index) => <span key={index}>{label}</span>)}
</div>
{canEdit ? (
<button
type="button"
className="btn btn-md btn-primary quality-edit-rule"
onClick={() => onEdit(problem)}
>
Открыть правило в конструкторе
</button>
) : <p className="quality-recommendations-note">Архивное расписание нельзя изменить.</p>}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,622 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../../../admin/js/academic-period.js';
import { trackUxEvent } from '../../../telemetry.js';
import {
QUALITY_SEMESTER_STORAGE_KEY,
QUALITY_VERSION_STORAGE_KEY,
SCHEDULE_OPEN_VERSION_STORAGE_KEY,
SCHEDULE_SEMESTER_STORAGE_KEY,
SCHEDULE_VERSION_STORAGE_KEY,
SCHEDULE_YEAR_STORAGE_KEY,
createScheduleDraftVersion,
draftBasisText,
fetchScheduleSemesters,
fetchScheduleVersionDiff,
fetchScheduleVersionHistory,
fetchScheduleVersionValidation,
fetchScheduleVersions,
formatLocalDate,
formatVersionDateTime,
historyActionLabel,
publishValidationSummary,
ruleChangeTypeLabel,
runScheduleVersionAction,
splitScheduleVersions,
versionSemesterLabel
} from '../schedule-versions-service.js';
const INITIAL_STATUS = Object.freeze({ message: 'Загружаем контур версий…', tone: '' });
export function ScheduleVersionsTab() {
const [semesters, setSemesters] = useState([]);
const [baseLoaded, setBaseLoaded] = useState(false);
const [academicYearId, setAcademicYearId] = useState('');
const [semesterId, setSemesterId] = useState('');
const [versions, setVersions] = useState([]);
const [history, setHistory] = useState([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(INITIAL_STATUS);
const [diffState, setDiffState] = useState(null);
const [modal, setModal] = useState(null);
const [modalValue, setModalValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const latestRef = useRef(null);
latestRef.current = { academicYearId, semesterId, loading };
const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]);
const yearSemesters = useMemo(
() => semestersForAcademicYear(semesters, academicYearId),
[semesters, academicYearId]
);
const { published, drafts, archive } = useMemo(() => splitScheduleVersions(versions), [versions]);
useEffect(() => {
async function initialize() {
try {
const loadedSemesters = await fetchScheduleSemesters();
setSemesters(loadedSemesters);
setBaseLoaded(true);
if (!loadedSemesters.length) {
setStatus({ message: 'Сначала создайте учебный год и семестр.', tone: 'error' });
return;
}
const selected = resolveAcademicPeriod(loadedSemesters, {
academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY),
semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY),
date: formatLocalDate(new Date())
});
setAcademicYearId(selected.academicYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, selected.academicYearId);
await loadWorkspace(selected.semesterId);
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить семестры.', tone: 'error' });
}
}
void initialize();
}, []);
// Escape закрывает окно операции (в legacy — слушатель на document)
useEffect(() => {
if (!modal) return;
function handleKeydown(event) {
if (event.key === 'Escape') closeModal();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [modal]);
async function loadWorkspace(overrideSemesterId) {
const nextSemesterId = overrideSemesterId ?? latestRef.current.semesterId;
if (!nextSemesterId || latestRef.current.loading) return;
setLoading(true);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, latestRef.current.academicYearId);
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, nextSemesterId);
setStatus({ message: 'Собираем версии, правила и журнал публикаций…', tone: 'loading' });
try {
const [nextVersions, nextHistory] = await Promise.all([
fetchScheduleVersions(nextSemesterId),
fetchScheduleVersionHistory(nextSemesterId)
]);
setVersions(nextVersions);
setHistory(nextHistory);
setLoaded(true);
trackUxEvent('версии_расписания_загружены', { semesterId: nextSemesterId, total: nextVersions.length });
setStatus({ message: 'Контур версий синхронизирован.', tone: 'success' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось загрузить версии расписания.', tone: 'error' });
} finally {
setLoading(false);
}
}
function handleYearChange(event) {
const nextYearId = event.target.value || '';
const selected = resolveAcademicPeriod(semesters, {
academicYearId: nextYearId,
date: formatLocalDate(new Date())
});
setAcademicYearId(nextYearId);
setSemesterId(selected.semesterId);
window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, nextYearId);
void loadWorkspace(selected.semesterId);
}
function handleSemesterChange(event) {
const nextSemesterId = event.target.value || '';
setSemesterId(nextSemesterId);
void loadWorkspace(nextSemesterId);
}
function handleVersionAction(version, action) {
if (action === 'edit') {
window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule';
return;
}
if (action === 'quality') {
window.localStorage.setItem(QUALITY_SEMESTER_STORAGE_KEY, String(version.semesterId));
window.localStorage.setItem(QUALITY_VERSION_STORAGE_KEY, String(version.id));
window.location.hash = 'schedule-quality';
return;
}
if (action === 'diff') {
void loadDiff(version);
return;
}
if (action === 'publish') {
void openPublishModal(version);
return;
}
if (action === 'restore') {
openRestoreModal(version);
}
}
async function loadDiff(version) {
setDiffState({ phase: 'loading' });
try {
const diff = await fetchScheduleVersionDiff(version.id);
setDiffState({ phase: 'ready', diff });
} catch (error) {
setDiffState({ phase: 'error', message: error.message });
}
}
function openCreateModal() {
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus({ message: 'Проверяем целостность черновика перед публикацией…', tone: 'loading' });
try {
const [validation, diff] = await Promise.all([
fetchScheduleVersionValidation(version.id),
fetchScheduleVersionDiff(version.id)
]);
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
submit: 'Опубликовать',
summary: publishValidationSummary(validation, diff)
});
setStatus(validation.valid
? { message: 'Черновик готов к публикации.', tone: 'success' }
: { message: 'Черновик требует исправлений.', tone: 'error' });
} catch (error) {
setStatus({ message: error.message || 'Не удалось проверить черновик.', tone: 'error' });
}
}
function openRestoreModal(version) {
openModal({
mode: 'restore',
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
submit: 'Восстановить'
});
}
function openModal(config) {
setModal(config);
setModalValue(config.value || '');
setSubmitting(false);
}
function closeModal() {
setModal(null);
setModalValue('');
setSubmitting(false);
}
async function handleSubmit(event) {
event.preventDefault();
if (!modal || modal.valid === false || submitting) return;
const value = modalValue.trim();
if (!value) {
setStatus({
message: modal.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.',
tone: 'error'
});
return;
}
setSubmitting(true);
try {
if (modal.mode === 'create') {
const created = await createScheduleDraftVersion({
semesterId: Number(latestRef.current.semesterId),
name: value,
basedOnVersionId: modal.version?.id || null
});
window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(created.id));
setStatus({ message: `Черновик «${created.name}» создан.`, tone: 'success' });
} else {
await runScheduleVersionAction(modal.version.id, modal.mode, value);
setStatus({
message: modal.mode === 'publish'
? 'Версия опубликована атомарно.'
: 'Предыдущая версия восстановлена.',
tone: 'success'
});
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus({ message: error.message || 'Операцию не удалось выполнить.', tone: 'error' });
setSubmitting(false);
}
}
const yearOptions = !baseLoaded
? <option value="">Загрузка годов…</option>
: (years.length
? years.map(year => <option key={String(year.id)} value={String(year.id)}>{year.title}</option>)
: <option value="">Учебные годы не настроены</option>);
const semesterOptions = !baseLoaded
? <option value="">Сначала выберите год</option>
: (yearSemesters.length
? yearSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{versionSemesterLabel(semester)}
</option>
))
: <option value="">Семестры не настроены</option>);
return (
<section className="version-studio" aria-labelledby="version-studio-title">
<header className="version-hero">
<div>
<span className="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div className="version-hero-controls">
<div className="version-hero-period-grid">
<div>
<label htmlFor="version-year">Учебный год</label>
<select id="version-year" value={academicYearId} onChange={handleYearChange}>
{yearOptions}
</select>
</div>
<div>
<label htmlFor="version-semester">Рабочий семестр</label>
<select id="version-semester" value={semesterId} onChange={handleSemesterChange}>
{semesterOptions}
</select>
</div>
</div>
<button type="button" className="btn btn-md btn-primary" id="version-create" onClick={openCreateModal}>
Новый черновик
</button>
</div>
</header>
<div
className="version-status"
id="version-status"
role="status"
aria-live="polite"
data-tone={status.tone || undefined}
>
{status.message}
</div>
<section className="version-current" id="version-current" aria-label="Опубликованная версия">
{loaded ? (published ? (
<>
<div className="version-live-mark">LIVE</div>
<div>
<span className="version-eyebrow">Сейчас видят пользователи</span>
<h3>{published.name} · версия {published.versionNumber}</h3>
<p>{published.changeReason || 'Причина первой публикации не указана'}</p>
<div className="version-stats">
<span>{published.ruleCount} правил</span>
<span>{published.slotCount} слотов</span>
<span>{formatVersionDateTime(published.publishedAt)}</span>
</div>
</div>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(published, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(published, 'quality')}
>
Проверить качество
</button>
</div>
</>
) : (
<>
<div className="version-live-mark">ПУСТО</div>
<div>
<h3>Опубликованной версии нет</h3>
<p>Создайте первый черновик и опубликуйте его после проверки.</p>
</div>
</>
)) : null}
</section>
<div className="version-workbench">
<section className="version-drafts-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">{drafts.length}</span>
</div>
<div className="version-draft-list" id="version-drafts">
{loaded ? (drafts.length ? drafts.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<div className="version-card-top">
<div>
<span className="version-status-chip">Черновик · v{version.versionNumber}</span>
<h4>{version.name}</h4>
</div>
</div>
<p>{draftBasisText(version)}</p>
<small>
{version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)}
</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => handleVersionAction(version, 'edit')}
>
Редактировать
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'publish')}
>
Опубликовать
</button>
</div>
</article>
)) : (
<div className="version-empty">
<span>+</span>
<h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
)) : null}
</div>
</section>
<aside className="version-diff-panel" id="version-diff">
{diffState === null ? (
<div className="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
) : diffState.phase === 'loading' ? (
<div className="version-empty">
<span>…</span>
<h3>Сравниваем расписания</h3>
<p>Генерируем занятия обеих версий по всему семестру.</p>
</div>
) : diffState.phase === 'error' ? (
<div className="version-empty">
<span>!</span>
<h3>Сравнение недоступно</h3>
<p>{diffState.message}</p>
</div>
) : (
<VersionDiff diff={diffState.diff} />
)}
</aside>
</div>
<section className="version-archive-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div className="version-archive-list" id="version-archive">
{loaded ? (archive.length ? archive.map(version => (
<article
className="version-card"
key={String(version.id)}
data-number={String(version.versionNumber).padStart(2, '0')}
>
<span className="version-status-chip">Архив · v{version.versionNumber}</span>
<h4>{version.name}</h4>
<p>{version.changeReason || 'Причина не указана'}</p>
<small>{version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)}</small>
<div className="version-card-actions">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'diff')}
>
Сравнить
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => handleVersionAction(version, 'quality')}
>
Качество
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => handleVersionAction(version, 'restore')}
>
Восстановить
</button>
</div>
</article>
)) : <p className="version-empty-note">Предыдущих публикаций пока нет.</p>) : null}
</div>
</section>
<section className="version-history-panel">
<div className="version-section-head">
<div>
<span className="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div className="version-history" id="version-history">
{loaded ? (history.length ? history.map((item, index) => (
<article className="version-history-row" key={index}>
<time>{formatVersionDateTime(item.createdAt)}</time>
<div>
<strong>
{historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName}
</strong>
<p>{item.reason || 'Без комментария'}</p>
</div>
<span className="version-status-chip">{item.actorName || 'Система'}</span>
</article>
)) : <p className="version-empty-note">Действия ещё не зафиксированы.</p>) : null}
</div>
</section>
{modal ? (
<div
className="version-modal"
id="version-modal"
onClick={event => {
if (event.target === event.currentTarget) closeModal();
}}
>
<div className="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button
type="button"
className="btn btn-icon-md btn-ghost version-modal-close"
id="version-modal-close"
aria-label="Закрыть окно"
onClick={closeModal}
>
&times;
</button>
<span className="version-eyebrow" id="version-modal-kicker">{modal.kicker}</span>
<h3 id="version-modal-title">{modal.title}</h3>
<p id="version-modal-copy">{modal.copy}</p>
<form id="version-modal-form" onSubmit={handleSubmit}>
<label htmlFor="version-modal-value" id="version-modal-label">{modal.label}</label>
<textarea
id="version-modal-value"
rows="3"
maxLength="2000"
required
autoFocus
value={modalValue}
onChange={event => setModalValue(event.target.value)}
></textarea>
<div className="version-modal-summary" id="version-modal-summary" hidden={!modal.summary}>
{modal.summary || ''}
</div>
<div className="version-modal-actions">
<button type="button" className="btn btn-md btn-ghost" id="version-modal-cancel" onClick={closeModal}>
Отмена
</button>
<button
type="submit"
className="btn btn-md btn-primary"
id="version-modal-submit"
disabled={modal.valid === false || submitting}
>
{submitting ? 'Выполняем…' : modal.submit}
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
function VersionDiff({ diff }) {
return (
<>
<span className="version-eyebrow">{diff.baseVersionLabel} → {diff.targetVersionLabel}</span>
<h3>Что изменится после публикации</h3>
<div className="version-diff-score">
<div><strong>+{diff.addedLessonCount}</strong><span>занятий</span></div>
<div><strong>{diff.changedLessonCount}</strong><span>изменено</span></div>
<div><strong>−{diff.removedLessonCount}</strong><span>удалено</span></div>
</div>
<p className="version-diff-caption">
Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount}
</p>
<div className="version-change-list">
{(diff.ruleChanges || []).length
? diff.ruleChanges.map((change, index) => (
<article className="version-change" key={index} data-type={change.type}>
<strong>{ruleChangeTypeLabel(change.type)} · {change.subjectName}</strong>
<span>{(change.groupNames || []).join(', ') || 'Группы не указаны'}</span>
{change.beforeSummary ? <span>Было: {change.beforeSummary}</span> : null}
{change.afterSummary ? <span>Стало: {change.afterSummary}</span> : null}
</article>
))
: (
<div className="version-empty">
<h3>Правила совпадают</h3>
<p>Структурных изменений между версиями нет.</p>
</div>
)}
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,535 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
SUBJECT_SORT_OPTIONS,
SUBJECT_TABLE_COLUMNS,
archiveSubject,
assignTeacher,
createSubject,
departmentLabel,
departmentOptionLabel,
fetchDepartments,
fetchSubjectsPage,
fetchTeacherAssignments,
groupTeachersBySubject,
initialSubjectsState,
removeTeacherAssignment,
subjectsEmptyMessage,
subjectsUrlState,
teachersSearchEndpoint
} from '../subjects-service.js';
import { apiClient } from '../../shared/api-client.js';
const SEARCH_DEBOUNCE_MS = 320;
export function SubjectsTab() {
const [departments, setDepartments] = useState(null);
const [referenceError, setReferenceError] = useState('');
const [state, setState] = useState(() => initialSubjectsState(
typeof window === 'undefined' ? {} : readUrlState({
subjectQuery: '',
subjectDepartment: '',
subjectPage: '0',
subjectSize: '25',
subjectSort: 'name,asc'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [pageData, setPageData] = useState(null);
const [assignments, setAssignments] = useState([]);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [createName, setCreateName] = useState('');
const [createCode, setCreateCode] = useState('');
const [createDepartmentId, setCreateDepartmentId] = useState('');
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [teachersModal, setTeachersModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchDepartments();
if (!active) return;
setDepartments(loaded || []);
await loadSubjects(stateRef.current);
} catch (error) {
if (active) setReferenceError(error.message || 'Не удалось загрузить справочники');
}
})();
return () => {
active = false;
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadSubjects(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
try {
let page = await fetchSubjectsPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchSubjectsPage(nextState);
}
const loadedAssignments = await fetchTeacherAssignments((page.items || []).map(subject => subject.id));
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setAssignments(loadedAssignments || []);
setTableLoading(false);
writeUrlState(subjectsUrlState(nextState));
trackUxEvent('реестр_дисциплин_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch (error) {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setTableLoading(false);
setTableError(error.message || 'Не удалось загрузить дисциплины');
}
}
function applyState(patch) {
const nextState = { ...stateRef.current, ...patch };
void loadSubjects(nextState);
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function submitCreate(event) {
event.preventDefault();
const name = createName.trim();
const code = createCode.trim();
if (!name || !code || !createDepartmentId) {
setCreateAlert({ text: 'Заполните все поля', isError: true });
return;
}
try {
const created = await createSubject({ name, code, departmentId: createDepartmentId });
setCreateAlert({ text: `Дисциплина «${created.name || name}» создана`, isError: false });
setCreateName('');
setCreateCode('');
await loadSubjects(stateRef.current);
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
}
}
function confirmArchiveSubject(subject) {
setConfirmState({
title: 'Архивировать дисциплину?',
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
confirmLabel: 'Архивировать',
danger: true,
action: async () => {
try {
await archiveSubject(subject.id);
await loadSubjects(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
}
}
});
}
function openTeachersModal(subject) {
setTeachersModal({
subject,
alert: { text: '', isError: false },
selectedTeacher: null
});
}
async function reloadAssignments() {
const subjectIds = (pageData?.items || []).map(subject => subject.id);
const loaded = await fetchTeacherAssignments(subjectIds);
setAssignments(loaded || []);
return loaded || [];
}
async function searchTeachers(query, selectedId) {
const teachers = await apiClient.get(teachersSearchEndpoint(query, selectedId));
const modalSubjectId = teachersModal?.subject?.id;
const assigned = new Set(
(assignments || [])
.filter(item => String(item.subjectId) === String(modalSubjectId))
.map(item => String(item.userId))
);
return (teachers || []).filter(teacher =>
!assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
}
async function submitTeacherAssignment(event) {
event.preventDefault();
const modal = teachersModal;
if (!modal?.selectedTeacher) {
setTeachersModal({ ...modal, alert: { text: 'Выберите преподавателя', isError: true } });
return;
}
try {
await assignTeacher({ userId: modal.selectedTeacher.id, subjectId: modal.subject.id });
setTeachersModal(current => (
current ? { ...current, alert: { text: 'Преподаватель успешно привязан', isError: false }, selectedTeacher: null } : current
));
await reloadAssignments();
} catch (error) {
setTeachersModal({ ...modal, alert: { text: error.message || 'Ошибка привязки', isError: true } });
}
}
function confirmDeleteAssignment(assignment) {
setConfirmState({
title: 'Удалить привязку преподавателя?',
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
confirmLabel: 'Удалить привязку',
danger: true,
action: async () => {
try {
await removeTeacherAssignment({
userId: assignment.userId,
subjectId: assignment.subjectId
});
await reloadAssignments();
} catch (error) {
setTeachersModal(current => (
current ? { ...current, alert: { text: error.message || 'Ошибка удаления привязки', isError: true } } : current
));
}
}
});
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const teachersBySubject = groupTeachersBySubject(assignments);
const subjects = pageData?.items || [];
return (
<>
<div className="card create-card">
<h2>Новая дисциплина</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-subject-name">Название дисциплины</label>
<input
type="text"
id="new-subject-name"
placeholder="Высшая математика"
required
value={createName}
onChange={event => setCreateName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-subject-code">Код предмета</label>
<input
type="text"
id="new-subject-code"
placeholder="Например: MATH101"
required
value={createCode}
onChange={event => setCreateCode(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-subject-department">Кафедра</label>
<select
id="new-subject-department"
required
value={createDepartmentId}
onChange={event => setCreateDepartmentId(event.target.value)}
>
<option value="">Выберите кафедру</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
</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="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="subjects-search">Поиск</label>
<input
type="search"
id="subjects-search"
placeholder="Название или код дисциплины"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="subjects-department-filter">Кафедра</label>
<select
id="subjects-department-filter"
value={state.departmentId}
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
>
<option value="">Все кафедры</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{department.departmentName || department.name || department.id}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="subjects-sort">Сортировка</label>
<select
id="subjects-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{SUBJECT_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код предмета</th>
<th>Кафедра</th>
<th>Преподаватели</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{referenceError ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">Ошибка загрузки справочников: {referenceError}</td></tr>
) : tableError ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadSubjects(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка дисциплин...</td></tr>
) : subjects.length === 0 ? (
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-empty">{subjectsEmptyMessage(state)}</td></tr>
) : subjects.map(subject => {
const teachers = teachersBySubject.get(String(subject.id)) || [];
return (
<tr key={subject.id}>
<td>{subject.id}</td>
<td><strong>{subject.name}</strong></td>
<td>{subject.code || '-'}</td>
<td>{departmentLabel(departments || [], subject.departmentId)}</td>
<td>
<div className="badge-list">
{teachers.length === 0 ? (
<span className="text-muted"><small>Нет преподавателей</small></span>
) : teachers.map(teacher => (
<span className="badge badge-ef badge--tight" key={teacher.userId}>
{teacher.fullName || teacher.username}
</span>
))}
</div>
</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-manage-teachers"
onClick={() => openTeachersModal(subject)}
>
Преподаватели
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-subject"
onClick={() => confirmArchiveSubject(subject)}
>
Удалить
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="дисциплин"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
</div>
{teachersModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setTeachersModal(null);
}}
>
<div className="modal-content card modal-content--medium">
<div className="modal-header-row">
<div>
<h2>Преподаватели дисциплины</h2>
<div className="modal-subtitle">
{teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name}
</div>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setTeachersModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Добавить привязку</h3>
<form onSubmit={submitTeacherAssignment}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-ts-teacher-select">Преподаватель</label>
<AsyncCombobox
ariaLabel="Поиск преподавателя"
placeholder="Найдите преподавателя по ФИО"
emptyLabel="Преподаватели не найдены"
load={(query, selectedId) => searchTeachers(query, selectedId)}
optionLabel={teacher => teacher.fullName || teacher.username}
optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')}
onSelect={teacher => setTeachersModal(current => (
current ? { ...current, selectedTeacher: teacher } : current
))}
selectedLabel={teachersModal.selectedTeacher
? (teachersModal.selectedTeacher.fullName || teachersModal.selectedTeacher.username)
: ''}
selectedValue={teachersModal.selectedTeacher
? String(teachersModal.selectedTeacher.id)
: ''}
/>
</div>
<button type="submit" className="btn btn-md btn-primary btn-inline">Привязать</button>
</div>
<div
className={`form-alert${teachersModal.alert.text ? (teachersModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{teachersModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-ts-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? (
<tr><td colSpan="2" className="loading-row">Преподаватели не привязаны</td></tr>
) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => (
<tr key={assignment.userId}>
<td>
<strong>{assignment.fullName || assignment.username}</strong>{' '}
<span className="text-muted">({assignment.username})</span>
</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-ts"
onClick={() => confirmDeleteAssignment(assignment)}
>
Удалить
</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)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,940 @@
import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import {
absenceSemesterLabel,
absenceStats,
changeReviewTarget,
changeTypeLabel,
createTeacherAbsence,
decisionCountLabel,
decisionOptions,
decisionStatusLabel,
deleteTeacherAbsence,
fetchAbsenceTeacherOptions,
fetchTeacherAbsenceWizard,
fetchTeacherAbsences,
fetchTeacherChangeRequests,
fetchTeacherPreferenceMeta,
fetchTeacherPreferences,
formatAbsenceDate,
formatLocalDate,
dayOfMonth,
genericStatusLabel,
monthShort,
parseDecisionOption,
preferenceDescription,
resolutionLabel,
resolveTeacherAbsence,
reviewTeacherAbsence,
reviewTeacherChangeRequest,
reviewTeacherPreference,
statusClass,
statusLabel,
trimTime
} from '../teacher-absences-service.js';
function teacherOptionLabel(teacher) {
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
}
export function TeacherAbsencesTab() {
const session = getSession();
const role = session?.role;
const [teacherSelection, setTeacherSelection] = useState({ id: '', label: '' });
const [startDate, setStartDate] = useState(() => formatLocalDate(new Date()));
const [endDate, setEndDate] = useState(() => formatLocalDate(new Date()));
const [reason, setReason] = useState('');
const [formAlert, setFormAlert] = useState(null);
const [absences, setAbsences] = useState([]);
const [absencesPhase, setAbsencesPhase] = useState('loading');
const [absencesError, setAbsencesError] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [registryBusy, setRegistryBusy] = useState(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardData, setWizardData] = useState(null);
const [wizardLoading, setWizardLoading] = useState(false);
const [wizardAlert, setWizardAlert] = useState(null);
const [wizardApplying, setWizardApplying] = useState(false);
const [decisions, setDecisions] = useState({});
const [preferenceSemesters, setPreferenceSemesters] = useState([]);
const [preferenceSemesterId, setPreferenceSemesterId] = useState('');
const [preferenceStatusFilter, setPreferenceStatusFilter] = useState('PENDING');
const [preferenceItems, setPreferenceItems] = useState([]);
const [preferencePhase, setPreferencePhase] = useState('idle');
const [preferenceError, setPreferenceError] = useState('');
const [preferenceComments, setPreferenceComments] = useState({});
const [preferenceBusy, setPreferenceBusy] = useState(null);
const [changeStatusFilter, setChangeStatusFilter] = useState('PENDING');
const [changeItems, setChangeItems] = useState([]);
const [changePhase, setChangePhase] = useState('idle');
const [changeError, setChangeError] = useState('');
const [changeComments, setChangeComments] = useState({});
const [changeBusy, setChangeBusy] = useState(null);
const [alert, setAlert] = useState(null);
const latestRef = useRef(null);
latestRef.current = { statusFilter, preferenceSemesterId, preferenceStatusFilter, changeStatusFilter };
const stats = absenceStats(absences);
const canApplyChanges = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
useEffect(() => {
void loadAbsences();
void loadPreferenceMeta();
void loadChangeReviews();
}, []);
// Мастер замены блокирует прокрутку страницы (в legacy — класс на body)
useEffect(() => {
if (!wizardOpen) return;
document.body.classList.add('absence-wizard-open');
return () => document.body.classList.remove('absence-wizard-open');
}, [wizardOpen]);
// Escape закрывает системное окно сообщения (в legacy — диалог из dialog.js)
useEffect(() => {
if (!alert) return;
function handleKeydown(event) {
if (event.key === 'Escape') setAlert(null);
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [alert]);
async function loadAbsences(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.statusFilter;
setAbsencesPhase('loading');
try {
const items = await fetchTeacherAbsences(nextStatus);
setAbsences(items);
setAbsencesPhase('ready');
} catch (error) {
setAbsencesError(error.message || 'Не удалось загрузить отсутствия');
setAbsencesPhase('error');
}
}
async function handleSubmit(event) {
event.preventDefault();
setFormAlert(null);
if (!teacherSelection.id || !startDate || !endDate || !reason.trim()) {
setFormAlert({ type: 'error', message: 'Заполните преподавателя, период и причину' });
return;
}
if (endDate < startDate) {
setFormAlert({ type: 'error', message: 'Дата окончания не может быть раньше даты начала' });
return;
}
try {
await createTeacherAbsence({
teacherId: Number(teacherSelection.id),
startDate,
endDate,
reason: reason.trim()
});
setFormAlert({ type: 'success', message: 'Отсутствие зарегистрировано' });
setReason('');
await loadAbsences();
} catch (error) {
setFormAlert({ type: 'error', message: error.message || 'Не удалось зарегистрировать отсутствие' });
}
}
async function handleRegistryAction(item, action) {
setRegistryBusy({ id: item.id, action });
try {
if (action === 'approve' || action === 'reject') {
await reviewTeacherAbsence(item.id, {
approved: action === 'approve',
comment: action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (action === 'cancel') {
await deleteTeacherAbsence(item.id);
await loadAbsences();
} else if (action === 'wizard') {
await openWizard(item.id);
}
} catch (error) {
setAlert({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
setRegistryBusy(null);
}
}
async function openWizard(id) {
setWizardOpen(true);
setWizardLoading(true);
setWizardData(null);
setWizardAlert(null);
setDecisions({});
try {
const data = await fetchTeacherAbsenceWizard(id);
setWizardData(data);
setWizardLoading(false);
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось открыть мастер замены' });
}
}
function closeWizard() {
setWizardOpen(false);
setWizardData(null);
setWizardLoading(false);
setWizardAlert(null);
setWizardApplying(false);
setDecisions({});
}
async function applyWizard() {
if (!wizardData) return;
const nextDecisions = (wizardData.lessons || []).map((lesson, index) => {
const value = decisions[index] || '';
if (!value) return null;
return {
baseRuleSlotId: Number(lesson.baseRuleSlotId),
lessonDate: lesson.lessonDate,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!nextDecisions.length && (wizardData.lessons || []).length) {
setWizardAlert({ type: 'error', message: 'Выберите хотя бы одно решение' });
return;
}
setWizardApplying(true);
setWizardAlert(null);
try {
const data = await resolveTeacherAbsence(wizardData.absence.id, nextDecisions);
setWizardData(data);
setDecisions({});
setWizardAlert({ type: 'success', message: 'Выбранные решения применены к расписанию' });
await loadAbsences();
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось применить решения' });
} finally {
setWizardApplying(false);
}
}
async function loadPreferenceMeta() {
setPreferencePhase('meta-loading');
try {
const meta = await fetchTeacherPreferenceMeta();
setPreferenceSemesters(meta.semesters || []);
const today = formatLocalDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
const nextSemesterId = current ? String(current.id) : '';
setPreferenceSemesterId(nextSemesterId);
await loadPreferenceReviews({ semesterId: nextSemesterId });
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить семестры');
setPreferencePhase('error');
}
}
async function loadPreferenceReviews(overrides = {}) {
const semesterId = overrides.semesterId ?? latestRef.current.preferenceSemesterId;
const status = overrides.status ?? latestRef.current.preferenceStatusFilter;
setPreferencePhase('loading');
try {
const items = await fetchTeacherPreferences({ semesterId, status });
setPreferenceItems(items);
setPreferencePhase('ready');
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить пожелания');
setPreferencePhase('error');
}
}
async function handlePreferenceReview(item, approved) {
const comment = (preferenceComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setPreferenceBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherPreference(item.id, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
setAlert({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
} finally {
setPreferenceBusy(null);
}
}
async function loadChangeReviews(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.changeStatusFilter;
setChangePhase('loading');
try {
const items = await fetchTeacherChangeRequests(nextStatus);
setChangeItems(items);
setChangePhase('ready');
} catch (error) {
setChangeError(error.message || 'Не удалось загрузить заявки');
setChangePhase('error');
}
}
async function handleChangeReview(item, approved) {
const comment = (changeComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setChangeBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherChangeRequest(item.id, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
setAlert({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
} finally {
setChangeBusy(null);
}
}
const registryContent = absencesPhase === 'loading'
? <div className="loading-row">Загрузка...</div>
: absencesPhase === 'error'
? <div className="loading-row">{absencesError}</div>
: (absences.length ? absences.map(item => (
<article className="absence-registry-item" key={String(item.id)} data-status={item.status}>
<div className="absence-date-rail">
<strong>{dayOfMonth(item.startDate)}</strong>
<span>{monthShort(item.startDate)}</span>
<i></i>
<strong>{dayOfMonth(item.endDate)}</strong>
<span>{monthShort(item.endDate)}</span>
</div>
<div className="absence-registry-main">
<div className="absence-registry-title">
<div>
<h3>{item.teacherName || 'Преподаватель не указан'}</h3>
<p>{item.reason || 'Причина не указана'}</p>
</div>
<span className={`absence-status ${statusClass(item.status)}`}>{statusLabel(item.status)}</span>
</div>
<div className="absence-meta-row">
<span>{formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)}</span>
<span>{decisionCountLabel(item.decisionCount || 0)}</span>
{item.reviewComment ? <span>{item.reviewComment}</span> : null}
</div>
</div>
<div className="absence-registry-buttons">
{item.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'approve'}
onClick={() => void handleRegistryAction(item, 'approve')}
>
Подтвердить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'reject'}
onClick={() => void handleRegistryAction(item, 'reject')}
>
Отклонить
</button>
</>
) : null}
{['APPROVED', 'RESOLVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-secondary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'wizard'}
onClick={() => void handleRegistryAction(item, 'wizard')}
>
{item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
) : null}
{['PENDING', 'APPROVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-ghost"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'cancel'}
onClick={() => void handleRegistryAction(item, 'cancel')}
>
Отменить
</button>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Инцидентов не найдено</strong>
<span>Измените фильтр или зарегистрируйте отсутствие.</span>
</div>
));
const preferenceContent = preferencePhase === 'idle'
? null
: preferencePhase === 'meta-loading'
? <div className="loading-row">Загрузка семестров...</div>
: preferencePhase === 'loading'
? <div className="loading-row">Загрузка пожеланий...</div>
: preferencePhase === 'error'
? <div className="loading-row">{preferenceError}</div>
: (preferenceItems.length ? preferenceItems.map(item => (
<article className="teacher-dialogue-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark preference-${item.preferenceType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'}
</small>
<h3>{item.teacherName}</h3>
<p>{preferenceDescription(item)}</p>
{item.comment ? <blockquote>{item.comment}</blockquote> : null}
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={preferenceComments[item.id] || ''}
onChange={event => setPreferenceComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, true)}
>
Согласовать
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Пожеланий не найдено</strong>
<span>Измените фильтры или дождитесь новых отметок преподавателей.</span>
</div>
));
const changeContent = changePhase === 'idle'
? null
: changePhase === 'loading'
? <div className="loading-row">Загрузка заявок...</div>
: changePhase === 'error'
? <div className="loading-row">{changeError}</div>
: (changeItems.length ? changeItems.map(item => (
<article className="teacher-dialogue-item change-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark change-${item.requestType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName}
</small>
<h3>{item.teacherName} · {item.subjectName}</h3>
<p>{changeTypeLabel(item.requestType)} → <strong>{changeReviewTarget(item)}</strong></p>
<blockquote>{item.reason}</blockquote>
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
<details>
<summary>История · {(item.history || []).length}</summary>
<ol>
{(item.history || []).map((entry, index) => (
<li key={index}>
<b>{genericStatusLabel(entry.status)}</b> · {entry.comment || 'Без комментария'}
</li>
))}
</ol>
</details>
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' && canApplyChanges ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={changeComments[item.id] || ''}
onChange={event => setChangeComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, true)}
>
Применить
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : item.status === 'PENDING'
? <small>Применение доступно учебному отделу</small>
: null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Заявок не найдено</strong>
<span>Очередь изменений сейчас пуста.</span>
</div>
));
return (
<>
<section className="absence-command-card">
<div className="absence-command-copy">
<span className="absence-kicker">Оперативный контур расписания</span>
<h2>От регистрации инцидента до готовой замены</h2>
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
</div>
<div className="absence-command-stats" aria-label="Сводка отсутствий">
<article><strong id="absence-stat-active">{stats.active}</strong><span>требуют внимания</span></article>
<article><strong id="absence-stat-pending">{stats.pending}</strong><span>ждут подтверждения</span></article>
<article><strong id="absence-stat-resolved">{stats.resolved}</strong><span>разрешены</span></article>
</div>
</section>
<section className="card absence-create-card">
<div className="card-header-row">
<div>
<span className="absence-section-number">01</span>
<h2>Зарегистрировать отсутствие</h2>
</div>
<span className="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
</div>
<form id="teacher-absence-form" noValidate onSubmit={handleSubmit}>
<div className="absence-form-grid">
<div className="form-group absence-teacher-field">
<label htmlFor="teacher-absence-teacher">Преподаватель</label>
<AsyncCombobox
ariaLabel="Преподаватель"
placeholder="Найдите преподавателя по ФИО"
selectedValue={teacherSelection.id}
selectedLabel={teacherSelection.label}
optionLabel={teacherOptionLabel}
optionDescription={teacher => [
teacher.jobTitle,
teacher.departmentName
].filter(Boolean).join(' · ')}
load={(query, selectedId) => fetchAbsenceTeacherOptions({
query,
selectedId,
role,
departmentId: session?.departmentId
})}
onSelect={item => setTeacherSelection(item
? { id: String(item.id), label: teacherOptionLabel(item) }
: { id: '', label: '' })}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-start">Начало</label>
<input
type="date"
id="teacher-absence-start"
required
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-end">Окончание</label>
<input
type="date"
id="teacher-absence-end"
required
value={endDate}
onChange={event => setEndDate(event.target.value)}
/>
</div>
<div className="form-group absence-reason-field">
<label htmlFor="teacher-absence-reason">Причина</label>
<input
type="text"
id="teacher-absence-reason"
maxLength="2000"
placeholder="Болезнь, командировка или другая причина"
required
value={reason}
onChange={event => setReason(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary absence-create-submit">
Зарегистрировать
</button>
</div>
<FormAlert id="teacher-absence-form-alert" alert={formAlert} />
</form>
</section>
<section className="card absence-registry-card">
<div className="card-header-row absence-registry-head">
<div>
<span className="absence-section-number">02</span>
<h2>Реестр инцидентов</h2>
</div>
<div className="absence-registry-actions">
<select
id="teacher-absence-status-filter"
aria-label="Фильтр по статусу"
value={statusFilter}
onChange={event => {
setStatusFilter(event.target.value || '');
void loadAbsences(event.target.value || '');
}}
>
<option value="">Все статусы</option>
<option value="PENDING">На подтверждении</option>
<option value="APPROVED">Согласованы</option>
<option value="RESOLVED">Разрешены</option>
<option value="REJECTED">Отклонены</option>
<option value="CANCELLED">Отменены</option>
</select>
<button
type="button"
className="btn btn-md btn-secondary"
id="teacher-absence-refresh"
onClick={() => void loadAbsences()}
>
Обновить
</button>
</div>
</div>
<div className="absence-registry" id="teacher-absence-registry" aria-live="polite">
{registryContent}
</div>
</section>
<section className="teacher-dialogue-grid">
<article className="card teacher-dialogue-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">03</span>
<h2>Пожелания на семестр</h2>
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-preference-pending-count">
{preferenceItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>ожидают</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-preference-semester-filter"
aria-label="Семестр пожеланий"
value={preferenceSemesterId}
onChange={event => {
setPreferenceSemesterId(event.target.value || '');
void loadPreferenceReviews({ semesterId: event.target.value || '' });
}}
>
<option value="">Все семестры</option>
{preferenceSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{absenceSemesterLabel(semester)}
</option>
))}
</select>
<select
id="teacher-preference-status-filter"
aria-label="Статус пожеланий"
value={preferenceStatusFilter}
onChange={event => {
setPreferenceStatusFilter(event.target.value || '');
void loadPreferenceReviews({ status: event.target.value || '' });
}}
>
<option value="PENDING">На рассмотрении</option>
<option value="APPROVED">Согласованные</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-preference-refresh"
type="button"
onClick={() => void loadPreferenceReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite">
{preferenceContent}
</div>
</article>
<article className="card teacher-dialogue-card change-review-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">04</span>
<h2>Заявки на изменение</h2>
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-change-pending-count">
{changeItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>в очереди</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-change-status-filter"
aria-label="Статус заявок"
value={changeStatusFilter}
onChange={event => {
setChangeStatusFilter(event.target.value || '');
void loadChangeReviews(event.target.value || '');
}}
>
<option value="PENDING">Ожидают решения</option>
<option value="APPROVED">Одобренные</option>
<option value="REJECTED">Отклонённые</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-change-refresh"
type="button"
onClick={() => void loadChangeReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite">
{changeContent}
</div>
</article>
</section>
{wizardOpen ? (
<div
className="absence-wizard-overlay"
id="absence-wizard-overlay"
onClick={event => {
if (event.target === event.currentTarget) closeWizard();
}}
>
<section className="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
<header className="absence-wizard-header">
<div>
<span className="absence-kicker">Мастер замены</span>
<h2 id="absence-wizard-title">
{wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'}
</h2>
<p id="absence-wizard-summary">
{wizardData
? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}`
: 'Загрузка...'}
</p>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost"
id="absence-wizard-close"
aria-label="Закрыть"
onClick={closeWizard}
>
&times;
</button>
</header>
<div className="absence-wizard-progress" aria-hidden="true">
<span className="done">Инцидент</span><i></i><span className="done">Занятия</span><i></i><span>Решения</span>
</div>
<FormAlert id="absence-wizard-alert" alert={wizardAlert} />
<div className="absence-wizard-lessons" id="absence-wizard-lessons">
{wizardLoading
? <div className="loading-row">Подбираем свободные ресурсы...</div>
: (wizardData
? (wizardData.lessons.length
? wizardData.lessons.map((lesson, index) => (
<LessonDecision
key={index}
lesson={lesson}
index={index}
locked={wizardData.absence.status !== 'APPROVED' || Boolean(lesson.decisionStatus)}
value={decisions[index] || ''}
onChange={value => setDecisions(previous => ({ ...previous, [index]: value }))}
/>
))
: (
<div className="absence-empty">
<strong>Затронутых занятий нет</strong>
<span>Инцидент можно завершить без точечных изменений.</span>
</div>
))
: null)}
</div>
<section className="absence-journal">
<div className="card-header-row">
<h3>Журнал решений</h3>
<span className="muted" id="absence-journal-count">
{wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'}
</span>
</div>
<div id="absence-journal-list" className="absence-journal-list">
{wizardData ? (
(wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => (
<article key={index}>
<span className={`absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}`}></span>
<div>
<strong>{resolutionLabel(item.resolution)}</strong>
<small>{formatAbsenceDate(item.lessonDate)}</small>
</div>
<span>
{decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''}
</span>
</article>
)) : <div className="loading-row">Решений пока нет</div>
) : null}
</div>
</section>
<footer className="absence-wizard-footer">
<span className="muted">Пустые строки останутся без изменений</span>
<div>
<button type="button" className="btn btn-md btn-ghost" id="absence-wizard-cancel" onClick={closeWizard}>
Закрыть
</button>
<button
type="button"
className="btn btn-md btn-primary"
id="absence-wizard-apply"
hidden={wizardData?.absence.status !== 'APPROVED'}
disabled={wizardApplying}
onClick={() => void applyWizard()}
>
Применить выбранные
</button>
</div>
</footer>
</section>
</div>
) : null}
{alert ? (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) setAlert(null);
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{alert.title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={() => setAlert(null)}
>
&times;
</button>
</header>
<p id="project-dialog-message">{alert.message}</p>
<footer>
<button type="button" className="btn btn-md btn-primary" onClick={() => setAlert(null)}>
Понятно
</button>
</footer>
</section>
</div>
) : null}
</>
);
}
function LessonDecision({ lesson, index, locked, value, onChange }) {
const options = decisionOptions(lesson);
return (
<article className="absence-lesson">
<div className="absence-lesson-time">
<strong>{trimTime(lesson.startTime)}</strong>
<span>{trimTime(lesson.endTime)}</span>
<small>{formatAbsenceDate(lesson.lessonDate)}</small>
</div>
<div className="absence-lesson-copy">
<span className="absence-lesson-type">{lesson.lessonTypeName || 'Занятие'}</span>
<h3>{lesson.subjectName || 'Дисциплина не указана'}</h3>
<p>
{(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'}
</p>
{lesson.existingOverrideId
? <small className="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>
: null}
</div>
<div className="absence-lesson-decision">
<label htmlFor={`absence-decision-${index}`}>Решение</label>
<select
id={`absence-decision-${index}`}
className="absence-decision-select"
disabled={locked}
value={value}
onChange={event => onChange(event.target.value)}
>
<option value="">Оставить без изменений</option>
{options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{lesson.decisionStatus
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
: null}
</div>
</article>
);
}

View File

@@ -0,0 +1,372 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { PromptDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import { requestCountLabel } from '../admin-service.js';
import {
REQUEST_STATUS_OPTIONS,
REQUEST_TABLE_COLUMNS,
approvePayloadFromFields,
approveRequest,
fetchDepartments,
fetchTeacherRequestsPage,
initialTeacherRequestsState,
pendingCountDetail,
rejectRequest,
requestDepartmentLabel,
reviewedStatusLabel,
teacherRequestsEmptyMessage,
teacherRequestsUrlState
} from '../teacher-requests-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function TeacherRequestsTab() {
const [state, setState] = useState(() => initialTeacherRequestsState(
typeof window === 'undefined' ? {} : readUrlState({
requestQuery: '',
requestStatus: 'PENDING',
requestPage: '0',
requestSize: '25'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [departments, setDepartments] = useState([]);
const [departmentsError, setDepartmentsError] = useState(false);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [alert, setAlert] = useState({ text: '', isError: false });
const [rejectState, setRejectState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let active = true;
(async () => {
try {
const loadedDepartments = await fetchDepartments();
if (!active) return;
setDepartments(loadedDepartments || []);
await loadRequests(stateRef.current);
} catch {
if (!active) return;
setTableLoading(false);
setDepartmentsError(true);
}
})();
return () => {
active = false;
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadRequests(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
setAlert(current => (current.text ? { text: '', isError: false } : current));
try {
let page = await fetchTeacherRequestsPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchTeacherRequestsPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(teacherRequestsUrlState(nextState));
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: pendingCountDetail(nextState, page.totalItems)
}));
trackUxEvent('реестр_заявок_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(null);
setTableLoading(false);
setTableError('Не удалось загрузить заявки преподавателей');
}
}
function applyState(patch) {
void loadRequests({ ...stateRef.current, ...patch });
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function approve(requestId, fields) {
const { payload, error } = approvePayloadFromFields(fields);
if (error) {
setAlert({ text: error, isError: true });
return;
}
try {
await approveRequest(requestId, payload);
setAlert({ text: 'Преподаватель создан, заявка одобрена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка одобрения заявки', isError: true });
}
}
async function confirmReject(reviewComment) {
const requestId = rejectState?.requestId;
setRejectState(null);
if (!requestId) return;
try {
await rejectRequest(requestId, reviewComment);
setAlert({ text: 'Заявка отклонена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка отклонения заявки', isError: true });
}
}
const requests = pageData?.items || [];
const countLabel = requestCountLabel(pageData ? Number(pageData.totalItems || 0) : 0);
return (
<div className="card">
<div className="card-header-row">
<h2>Заявки на преподавателей</h2>
<div className="teacher-request-toolbar">
<span className="muted">{countLabel}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Обновить
</button>
</div>
</div>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="teacher-requests-search">Поиск</label>
<input
type="search"
id="teacher-requests-search"
placeholder="ФИО, логин, должность или кафедра"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-requests-status">Статус</label>
<select
id="teacher-requests-status"
value={state.status}
onChange={event => applyState({ status: event.target.value, page: 0 })}
>
{REQUEST_STATUS_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
{departmentsError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
Не удалось загрузить заявки преподавателей
</td>
</tr>
) : tableError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
<span className="state-spinner" aria-hidden="true" />Загрузка заявок...
</td>
</tr>
) : requests.length === 0 ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-empty">
{teacherRequestsEmptyMessage(state)}
</td>
</tr>
) : requests.map(request => (
<TeacherRequestRow
key={request.id}
request={request}
departments={departments}
onApprove={approve}
onReject={requestId => setRejectState({ requestId })}
/>
))}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="заявок"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
{rejectState && (
<PromptDialog
title="Отклонить заявку?"
message="Комментарий увидит кафедра, отправившая заявку."
confirmLabel="Отклонить"
danger
input={{
label: 'Причина отклонения',
placeholder: 'Укажите, что нужно исправить',
required: true,
requiredMessage: 'Укажите причину отклонения'
}}
onConfirm={value => void confirmReject(value)}
onClose={() => setRejectState(null)}
/>
)}
</div>
);
}
// Строка заявки хранит черновик полей локально: значения инпутов строки
// отправляются при одобрении — как в legacy-реестре.
function TeacherRequestRow({ request, departments, onApprove, onReject }) {
const [departmentId, setDepartmentId] = useState(String(request.departmentId ?? ''));
const [username, setUsername] = useState(request.username || '');
const [fullName, setFullName] = useState(request.fullName || '');
const [jobTitle, setJobTitle] = useState(request.jobTitle || '');
const [password, setPassword] = useState('');
function approve() {
onApprove(request.id, { departmentId, username, fullName, jobTitle, password });
}
return (
<tr>
<td>
<select
value={departmentId}
onChange={event => setDepartmentId(event.target.value)}
>
{departments.map(department => (
<option key={department.id} value={department.id}>
{requestDepartmentLabel(department)}
</option>
))}
</select>
</td>
<td>
<input
type="text"
maxLength={50}
autoComplete="username"
value={username}
onChange={event => setUsername(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={fullName}
onChange={event => setFullName(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={jobTitle}
onChange={event => setJobTitle(event.target.value)}
/>
</td>
<td>
<input
type="password"
className="teacher-request-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
value={password}
onChange={event => setPassword(event.target.value)}
/>
</td>
<td>{request.comment || '-'}</td>
<td>
<div className="teacher-request-actions">
{request.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary teacher-request-approve"
onClick={approve}
>
Одобрить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle teacher-request-reject"
onClick={() => onReject(request.id)}
>
Отклонить
</button>
</>
) : (
<span className="badge">{reviewedStatusLabel(request.status)}</span>
)}
</div>
</td>
</tr>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
import { useEffect, useRef, useState } from 'react';
import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
ROLE_FILTER_OPTIONS,
USER_SORT_OPTIONS,
USER_TABLE_COLUMNS,
archiveUser,
createUser,
fetchUsersPage,
initialUsersState,
restoreUser,
roleBadgeClass,
roleLabel,
usersEmptyMessage,
usersUrlState
} from '../users-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function UsersTab() {
const [state, setState] = useState(() => initialUsersState(
typeof window === 'undefined' ? {} : readUrlState({
userQuery: '',
userRole: '',
userPage: '0',
userSize: '25',
userSort: 'fullName,asc'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createRole, setCreateRole] = useState('STUDENT');
const [createFullName, setCreateFullName] = useState('');
const [createJobTitle, setCreateJobTitle] = useState('');
const [createDepartmentId, setCreateDepartmentId] = useState('');
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
void loadUsers(stateRef.current);
return () => {
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadUsers(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
try {
let page = await fetchUsersPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchUsersPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(usersUrlState(nextState));
trackUxEvent('реестр_пользователей_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch (error) {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setTableLoading(false);
setTableError(error.message || 'Не удалось загрузить пользователей');
}
}
function applyState(patch) {
void loadUsers({ ...stateRef.current, ...patch });
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function submitCreate(event) {
event.preventDefault();
const username = createUsername.trim();
const password = createPassword;
const fullName = createFullName.trim();
const jobTitle = createJobTitle.trim();
if (!username || !password || !fullName || !jobTitle || !createDepartmentId) {
setCreateAlert({ text: 'Заполните все поля', isError: true });
return;
}
if (password.length < 8) {
setCreateAlert({ text: 'Пароль должен быть не короче 8 символов', isError: true });
return;
}
try {
const created = await createUser({
username,
password,
role: createRole,
fullName,
jobTitle,
departmentId: createDepartmentId
});
setCreateAlert({ text: `Пользователь «${created.username}» создан`, isError: false });
setCreateUsername('');
setCreatePassword('');
setCreateFullName('');
setCreateJobTitle('');
setCreateDepartmentId('');
await loadUsers(stateRef.current);
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка соединения', isError: true });
}
}
function confirmArchiveUser(user) {
setConfirmState({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true,
action: async () => {
try {
await archiveUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
}
}
});
}
async function restore(user) {
try {
await restoreUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка восстановления', message: error.message || 'Ошибка восстановления' });
}
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const users = pageData?.items || [];
return (
<>
<div className="card create-card">
<h2>Новый пользователь</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-username">Имя пользователя</label>
<input
type="text"
id="new-username"
placeholder="Имя пользователя"
autoComplete="username"
required
value={createUsername}
onChange={event => setCreateUsername(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-password">Пароль</label>
<input
type="password"
id="new-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
required
value={createPassword}
onChange={event => setCreatePassword(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-role">Роль</label>
<select
id="new-role"
value={createRole}
onChange={event => setCreateRole(event.target.value)}
>
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div className="form-group">
<label htmlFor="new-fullname">ФИО пользователя</label>
<input
type="text"
id="new-fullname"
placeholder="Иванов Иван Иванович"
required
value={createFullName}
onChange={event => setCreateFullName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-jobtitle">Должность</label>
<input
type="text"
id="new-jobtitle"
placeholder="Студент / Доцент"
required
value={createJobTitle}
onChange={event => setCreateJobTitle(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-department">ID Кафедры</label>
<input
type="number"
id="new-department"
placeholder="ID"
required
value={createDepartmentId}
onChange={event => setCreateDepartmentId(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</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="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="users-search">Поиск</label>
<input
type="search"
id="users-search"
placeholder="Логин, ФИО или должность"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="users-role-filter">Роль</label>
<select
id="users-role-filter"
value={state.role}
onChange={event => applyState({ role: event.target.value, page: 0 })}
>
<option value="">Все роли</option>
{ROLE_FILTER_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="users-sort">Сортировка</label>
<select
id="users-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{USER_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>ФИО</th>
<th>Должность</th>
<th>Кафедра</th>
<th>Роль</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{tableError ? (
<tr>
<td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadUsers(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка пользователей...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-empty">{usersEmptyMessage(state)}</td></tr>
) : users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.fullName || '-'}</td>
<td>{user.jobTitle || '-'}</td>
<td>{user.departmentName || '-'}</td>
<td><span className={`badge ${roleBadgeClass(user.role)}`}>{roleLabel(user.role)}</span></td>
<td className="table-cell-actions">
{user.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-user"
onClick={() => void restore(user)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-user"
onClick={() => confirmArchiveUser(user)}
>
Архивировать
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="пользователей"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
</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)}
/>
)}
</>
);
}