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:
2244
frontend/react/admin/tabs/AcademicCalendarTab.jsx
Normal file
2244
frontend/react/admin/tabs/AcademicCalendarTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
775
frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx
Normal file
775
frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal file
632
frontend/react/admin/tabs/ClassroomsTab.jsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
|
||||
import {
|
||||
archiveClassroom,
|
||||
classroomStatusLabel,
|
||||
createClassroom,
|
||||
createEquipment,
|
||||
deleteEquipment,
|
||||
equipmentIdSelection,
|
||||
equipmentNamesText,
|
||||
equipmentSelectLabel,
|
||||
fetchClassrooms,
|
||||
fetchEquipments,
|
||||
parseCapacity,
|
||||
restoreClassroom,
|
||||
toggleClassroomAvailability,
|
||||
updateClassroom
|
||||
} from '../classrooms-service.js';
|
||||
|
||||
export function ClassroomsTab() {
|
||||
const [equipments, setEquipments] = useState([]);
|
||||
const [classrooms, setClassrooms] = useState(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createCapacity, setCreateCapacity] = useState('');
|
||||
const [createEquipmentIds, setCreateEquipmentIds] = useState(() => new Set());
|
||||
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [editModal, setEditModal] = useState(null);
|
||||
const [equipmentModal, setEquipmentModal] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [alertState, setAlertState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [loadedEquipments, loadedClassrooms] = await Promise.all([
|
||||
fetchEquipments(),
|
||||
fetchClassrooms()
|
||||
]);
|
||||
if (!active) return;
|
||||
setEquipments(loadedEquipments || []);
|
||||
setClassrooms(loadedClassrooms || []);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (active) setLoadError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function reloadClassrooms() {
|
||||
try {
|
||||
setClassrooms(await fetchClassrooms());
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadEquipments() {
|
||||
const loaded = await fetchEquipments();
|
||||
setEquipments(loaded || []);
|
||||
return loaded || [];
|
||||
}
|
||||
|
||||
function toggleCreateEquipment(equipmentId) {
|
||||
setCreateEquipmentIds(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(equipmentId)) {
|
||||
next.delete(equipmentId);
|
||||
} else {
|
||||
next.add(equipmentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
const name = createName.trim();
|
||||
const capacity = parseCapacity(createCapacity);
|
||||
if (!name || Number.isNaN(capacity)) {
|
||||
setCreateAlert({ text: 'Заполните обязательные поля', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createClassroom({
|
||||
name,
|
||||
capacity,
|
||||
equipmentIds: [...createEquipmentIds].map(Number)
|
||||
});
|
||||
setCreateAlert({ text: `Аудитория «${created.name}» добавлена`, isError: false });
|
||||
setCreateName('');
|
||||
setCreateCapacity('');
|
||||
setCreateEquipmentIds(new Set());
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchive(classroom) {
|
||||
setConfirmState({
|
||||
title: 'Вывод аудитории из эксплуатации',
|
||||
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
|
||||
confirmLabel: 'Вывести из эксплуатации',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await archiveClassroom(classroom.id);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка архивирования',
|
||||
message: error.message || 'Не удалось архивировать аудиторию'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restore(classroom) {
|
||||
try {
|
||||
await restoreClassroom(classroom.id);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка восстановления',
|
||||
message: error.message || 'Не удалось восстановить аудиторию'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAvailability(classroom) {
|
||||
try {
|
||||
await toggleClassroomAvailability(classroom.id, !classroom.isAvailable);
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка изменения статуса',
|
||||
message: error.message || 'Не удалось изменить доступность аудитории'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditModal(classroomId) {
|
||||
try {
|
||||
const fresh = await fetchClassrooms();
|
||||
const classroom = (fresh || []).find(item => String(item.id) === String(classroomId));
|
||||
if (!classroom) return;
|
||||
setEditModal({
|
||||
classroom,
|
||||
name: classroom.name,
|
||||
capacity: String(classroom.capacity),
|
||||
equipmentIds: new Set((classroom.equipments || []).map(equipment => String(equipment.id))),
|
||||
alert: { text: '', isError: false }
|
||||
});
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка загрузки',
|
||||
message: error.message || 'Не удалось загрузить данные аудитории'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditEquipment(equipmentId) {
|
||||
setEditModal(current => {
|
||||
if (!current) return current;
|
||||
const next = new Set(current.equipmentIds);
|
||||
if (next.has(equipmentId)) {
|
||||
next.delete(equipmentId);
|
||||
} else {
|
||||
next.add(equipmentId);
|
||||
}
|
||||
return { ...current, equipmentIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function submitEdit(event) {
|
||||
event.preventDefault();
|
||||
const modal = editModal;
|
||||
const name = modal.name.trim();
|
||||
const capacity = parseCapacity(modal.capacity);
|
||||
if (!name || Number.isNaN(capacity)) {
|
||||
setEditModal({ ...modal, alert: { text: 'Заполните обязательные поля', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await updateClassroom(modal.classroom.id, {
|
||||
name,
|
||||
capacity,
|
||||
equipmentIds: [...modal.equipmentIds].map(Number),
|
||||
isAvailable: modal.classroom.isAvailable
|
||||
});
|
||||
setEditModal(null);
|
||||
setCreateAlert({ text: `Аудитория «${updated.name}» обновлена`, isError: false });
|
||||
await reloadClassrooms();
|
||||
} catch (error) {
|
||||
setEditModal({
|
||||
...modal,
|
||||
alert: { text: error.message || 'Ошибка обновления', isError: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openEquipmentModal() {
|
||||
setEquipmentModal({
|
||||
name: '',
|
||||
list: null,
|
||||
alert: { text: '', isError: false }
|
||||
});
|
||||
await reloadEquipmentModalList();
|
||||
}
|
||||
|
||||
async function reloadEquipmentModalList() {
|
||||
try {
|
||||
const loaded = await reloadEquipments();
|
||||
setEquipmentModal(current => (current ? { ...current, list: loaded } : current));
|
||||
} catch {
|
||||
setEquipmentModal(current => (current ? { ...current, list: [] } : current));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEquipment(event) {
|
||||
event.preventDefault();
|
||||
const modal = equipmentModal;
|
||||
const name = modal.name.trim();
|
||||
if (!name) {
|
||||
setEquipmentModal({ ...modal, alert: { text: 'Введите название', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createEquipment(name);
|
||||
setEquipmentModal(current => (
|
||||
current ? { ...current, name: '', alert: { text: `Оборудование «${created.name}» добавлено`, isError: false } } : current
|
||||
));
|
||||
await reloadEquipmentModalList();
|
||||
} catch (error) {
|
||||
setEquipmentModal({
|
||||
...modal,
|
||||
alert: { text: error.message || 'Ошибка добавления', isError: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteEquipment(equipment) {
|
||||
setConfirmState({
|
||||
title: 'Удаление оборудования',
|
||||
message: 'Удалить оборудование из каталога?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await deleteEquipment(equipment.id);
|
||||
await reloadEquipmentModalList();
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
title: 'Ошибка удаления',
|
||||
message: error.message || 'Не удалось удалить оборудование'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const confirm = confirmState;
|
||||
setConfirmState(null);
|
||||
await confirm.action();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card create-card">
|
||||
<div className="card-toolbar">
|
||||
<h2>Новая аудитория</h2>
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={openEquipmentModal}>
|
||||
Справочник оборудования
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-classroom-name">Номер / Название</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-classroom-name"
|
||||
placeholder="101 Ленинская"
|
||||
required
|
||||
value={createName}
|
||||
onChange={event => setCreateName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="new-classroom-capacity"
|
||||
placeholder="30"
|
||||
min="1"
|
||||
required
|
||||
value={createCapacity}
|
||||
onChange={event => setCreateCapacity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group form-group--wide">
|
||||
<label>Оборудование</label>
|
||||
<EquipmentMultiSelect
|
||||
equipments={equipments}
|
||||
selectedIds={createEquipmentIds}
|
||||
onToggle={toggleCreateEquipment}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group--submit">
|
||||
<button type="submit" className="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{createAlert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Список аудиторий</h2>
|
||||
<div className="table-wrap">
|
||||
<table id="classrooms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Вместимость</th>
|
||||
<th>Оборудование</th>
|
||||
<th>Статус</th>
|
||||
<th className="th-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadError ? (
|
||||
<tr><td colSpan="6" className="loading-row">Ошибка загрузки данных</td></tr>
|
||||
) : classrooms === null ? (
|
||||
<tr><td colSpan="6" className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
|
||||
) : classrooms.length === 0 ? (
|
||||
<tr><td colSpan="6" className="loading-row">Нет аудиторий</td></tr>
|
||||
) : classrooms.map(classroom => (
|
||||
<tr key={classroom.id}>
|
||||
<td>{classroom.id}</td>
|
||||
<td><strong>{classroom.name}</strong></td>
|
||||
<td>{classroom.capacity} чел.</td>
|
||||
<td><small>{equipmentNamesText(classroom) || '—'}</small></td>
|
||||
<td>
|
||||
<div className="status-cell">
|
||||
<span className={`badge ${classroom.isAvailable ? 'badge-available' : 'badge-unavailable'}`}>
|
||||
{classroomStatusLabel(classroom)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-sm btn-secondary btn-icon-toggle"
|
||||
title="Сменить статус"
|
||||
aria-label="Сменить статус"
|
||||
onClick={() => void toggleAvailability(classroom)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell-actions">
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary classroom-edit-action"
|
||||
onClick={() => void openEditModal(classroom.id)}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
{classroom.status === 'ARCHIVED' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary btn-restore-classroom"
|
||||
onClick={() => void restore(classroom)}
|
||||
>
|
||||
Восстановить
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle btn-archive-classroom"
|
||||
onClick={() => confirmArchive(classroom)}
|
||||
>
|
||||
Архивировать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setEditModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card">
|
||||
<h2>Редактировать аудиторию</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setEditModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<form onSubmit={submitEdit}>
|
||||
<input type="hidden" value={editModal.classroom.id} />
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-classroom-name">Номер / Название</label>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-classroom-name"
|
||||
required
|
||||
value={editModal.name}
|
||||
onChange={event => setEditModal({ ...editModal, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-classroom-capacity"
|
||||
min="1"
|
||||
required
|
||||
value={editModal.capacity}
|
||||
onChange={event => setEditModal({ ...editModal, capacity: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row form-row--spacing">
|
||||
<div className="form-group form-group--wide">
|
||||
<label>Оборудование</label>
|
||||
<EquipmentMultiSelect
|
||||
equipments={equipments}
|
||||
selectedIds={editModal.equipmentIds}
|
||||
onToggle={toggleEditEquipment}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group--submit">
|
||||
<button type="submit" className="btn btn-md btn-primary btn-block">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${editModal.alert.text ? (editModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{editModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{equipmentModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setEquipmentModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card modal-content--medium">
|
||||
<div className="modal-header-row">
|
||||
<h2>Справочник оборудования</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setEquipmentModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="card create-card modal-subcard">
|
||||
<h3>Добавить оборудование</h3>
|
||||
<form onSubmit={submitEquipment}>
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group form-group--flush">
|
||||
<label htmlFor="manage-equipment-name">Название оборудования</label>
|
||||
<input
|
||||
type="text"
|
||||
id="manage-equipment-name"
|
||||
placeholder="Проектор"
|
||||
required
|
||||
value={equipmentModal.name}
|
||||
onChange={event => setEquipmentModal({ ...equipmentModal, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary btn-inline">Добавить</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${equipmentModal.alert.text ? (equipmentModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{equipmentModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="manage-equipments-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipmentModal.list === null ? (
|
||||
<tr><td colSpan="3" className="loading-row">Загрузка...</td></tr>
|
||||
) : equipmentModal.list.length === 0 ? (
|
||||
<tr><td colSpan="3" className="loading-row">Нет оборудования</td></tr>
|
||||
) : equipmentModal.list.map(equipment => (
|
||||
<tr key={equipment.id}>
|
||||
<td>{equipment.id}</td>
|
||||
<td>{equipment.name}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-equipment"
|
||||
onClick={() => confirmDeleteEquipment(equipment)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
danger={confirmState.danger}
|
||||
onConfirm={() => void runConfirm()}
|
||||
onClose={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{alertState && (
|
||||
<AlertDialog
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
onClose={() => setAlertState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EquipmentMultiSelect({ equipments, selectedIds, onToggle }) {
|
||||
const rootRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const selection = equipmentIdSelection(equipments, [...selectedIds]);
|
||||
const selectedNames = selection.filter(item => item.checked).map(item => item.name);
|
||||
|
||||
return (
|
||||
<div className="custom-multi-select" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-box${open ? ' active' : ''}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(current => !current)}
|
||||
>
|
||||
<span className="select-text">{equipmentSelectLabel(selectedNames)}</span>
|
||||
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={`dropdown-menu${open ? ' open' : ''}`}>
|
||||
<div className="checkbox-group-vertical">
|
||||
{equipments.length === 0 ? (
|
||||
<p className="text-muted"><small>Оборудование отсутствует</small></p>
|
||||
) : selection.map(item => (
|
||||
<label className="checkbox-item" key={item.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={item.id}
|
||||
checked={item.checked}
|
||||
onChange={() => onToggle(String(item.id))}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span>{item.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal file
354
frontend/react/admin/tabs/DashboardTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal file
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1279
frontend/react/admin/tabs/GroupsTab.jsx
Normal file
1279
frontend/react/admin/tabs/GroupsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal file
468
frontend/react/admin/tabs/ScheduleQualityTab.jsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2368
frontend/react/admin/tabs/ScheduleTab.jsx
Normal file
2368
frontend/react/admin/tabs/ScheduleTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
622
frontend/react/admin/tabs/ScheduleVersionsTab.jsx
Normal file
622
frontend/react/admin/tabs/ScheduleVersionsTab.jsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1771
frontend/react/admin/tabs/ScheduleViewTab.jsx
Normal file
1771
frontend/react/admin/tabs/ScheduleViewTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal file
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal file
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal file
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1094
frontend/react/admin/tabs/UniversityStructureTab.jsx
Normal file
1094
frontend/react/admin/tabs/UniversityStructureTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
399
frontend/react/admin/tabs/UsersTab.jsx
Normal file
399
frontend/react/admin/tabs/UsersTab.jsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user