Files
magistr/frontend/react/teacher/PreferencesTab.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

351 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
import {
activeDatePreferences,
activeWeekPreferences,
createPreference,
deletePreference,
fetchPreferenceMeta,
fetchPreferences,
findCompactPreference,
findWeekPreference,
preferenceStatusLabel,
preferenceTypeClass,
preferenceTypeLabel,
preferenceTypeSymbol,
resolveCurrentSemester,
resolveSelectedSemester,
semesterLabel
} from './teacher-service.js';
const WEEK_DAY_LABELS = Object.freeze(['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']);
export function PreferencesTab() {
const [meta, setMeta] = useState({ semesters: [], timeSlots: [] });
const [metaError, setMetaError] = useState(false);
const [semesterId, setSemesterId] = useState('');
const [preferences, setPreferences] = useState([]);
const [brush, setBrush] = useState('SOFT_PREFERRED');
const [message, setMessage] = useState({ text: '', isError: false });
const [dateValue, setDateValue] = useState('');
const [dateComment, setDateComment] = useState('');
const [busyCell, setBusyCell] = useState('');
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchPreferenceMeta();
if (!active) return;
setMeta(loaded);
const semesters = loaded.semesters || [];
const current = resolveCurrentSemester(semesters, toIsoDate(new Date()));
if (current) setSemesterId(String(current.id));
} catch {
if (active) setMetaError(true);
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!semesterId) return undefined;
let active = true;
(async () => {
try {
const loaded = await fetchPreferences(semesterId);
if (!active) return;
setPreferences(loaded);
setMessage({ text: '', isError: false });
} catch {
if (active) setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
}
})();
return () => {
active = false;
};
}, [semesterId]);
const semester = resolveSelectedSemester(meta.semesters, semesterId);
useEffect(() => {
const min = semester?.startDate || '';
const max = semester?.endDate || '';
if (!dateValue || dateValue < min || dateValue > max) {
setDateValue(min);
}
}, [semester, dateValue]);
async function reloadPreferences() {
try {
setPreferences(await fetchPreferences(semesterId));
} catch {
setMessage({ text: 'Не удалось загрузить пожелания', isError: true });
}
}
function showMessage(text, isError) {
setMessage({ text, isError });
}
async function handleCell(dayOfWeek, timeSlotId) {
const existing = findWeekPreference(preferences, dayOfWeek, timeSlotId);
if (existing) {
if (existing.status === 'APPROVED') {
showMessage('Согласованную отметку может снять кафедра или учебный отдел', true);
return;
}
try {
await deletePreference(existing.id);
showMessage('Ожидающее пожелание отменено', false);
await reloadPreferences();
} catch {
showMessage('Не удалось отменить пожелание', true);
}
return;
}
const cellKey = `${dayOfWeek}:${timeSlotId}`;
setBusyCell(cellKey);
try {
await createPreference({
dayOfWeek,
preferenceType: brush,
semesterId: Number(semesterId),
timeSlotId: Number(timeSlotId)
});
showMessage('Отметка отправлена на согласование', false);
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
} finally {
setBusyCell('');
}
}
async function submitDate(event) {
event.preventDefault();
if (!dateValue) return;
try {
await createPreference({
comment: dateComment.trim() || null,
preferenceDate: dateValue,
preferenceType: 'HARD_UNAVAILABLE',
semesterId: Number(semesterId)
});
setDateComment('');
showMessage('Дата недоступности отправлена на согласование', false);
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
}
}
async function cancelDatePreference(id) {
try {
await deletePreference(id);
showMessage('Ожидающее пожелание отменено', false);
await reloadPreferences();
} catch {
showMessage('Не удалось отменить пожелание', true);
}
}
async function toggleCompact(type, checked, existingId) {
try {
if (!checked && existingId) {
await deletePreference(existingId);
} else if (checked) {
await createPreference({
preferenceType: type,
semesterId: Number(semesterId)
});
}
await reloadPreferences();
} catch {
showMessage('Не удалось сохранить пожелание', true);
await reloadPreferences();
}
}
const slots = meta.timeSlots || [];
const weekCells = activeWeekPreferences(preferences);
const dateItems = activeDatePreferences(preferences);
return (
<>
<section className="preference-hero">
<div>
<span className="preference-kicker">Контур доступности</span>
<h2>Соберите ритм семестра до составления расписания</h2>
<p>Строгая недоступность блокирует назначение. Мягкие отметки и пожелания к компактности помогают учебному отделу выбрать удобный вариант.</p>
</div>
<label className="preference-semester-field">
<span>Семестр</span>
<select value={semesterId} onChange={event => setSemesterId(event.target.value)}>
{(meta.semesters || []).map(item => (
<option key={item.id} value={item.id}>{semesterLabel(item)}</option>
))}
</select>
</label>
</section>
<section className="preference-board">
<header className="preference-board-head">
<div>
<span>Недельный шаблон</span>
<h3>Отмечайте интервалы одним касанием</h3>
</div>
<label>
<span>Режим отметки</span>
<select value={brush} onChange={event => setBrush(event.target.value)}>
<option value="SOFT_PREFERRED">Предпочтительно</option>
<option value="SOFT_UNWANTED">Нежелательно</option>
<option value="HARD_UNAVAILABLE">Строго недоступен</option>
</select>
</label>
</header>
<div className="preference-legend" aria-label="Легенда">
<span className="is-preferred">Предпочтительно</span>
<span className="is-unwanted">Нежелательно</span>
<span className="is-hard">Строгий запрет</span>
<span className="is-pending">Ожидает согласования</span>
</div>
<div className="preference-grid-wrap">
<div className="preference-grid" aria-live="polite">
{metaError ? (
<div className="preference-empty">Не удалось загрузить параметры семестра</div>
) : slots.length === 0 ? (
<div className="preference-empty">Базовая сетка времени не настроена</div>
) : (
<>
<div className="preference-grid-corner">Пара</div>
{WEEK_DAY_LABELS.map(day => (
<div key={day} className="preference-grid-day">{day}</div>
))}
{slots.flatMap(slot => [
<div key={`time-${slot.id}`} className="preference-grid-time">
<strong>{slot.orderNumber}</strong>
<span>{String(slot.startTime || '').slice(0, 5)}–{String(slot.endTime || '').slice(0, 5)}</span>
</div>,
...WEEK_DAY_LABELS.map((_, index) => {
const dayOfWeek = index + 1;
const item = findWeekPreference(weekCells, dayOfWeek, slot.id);
const title = item
? `${preferenceTypeLabel(item.preferenceType)} · ${preferenceStatusLabel(item.status)}`
: 'Добавить отметку';
return (
<button
key={`cell-${slot.id}-${dayOfWeek}`}
type="button"
className={`preference-cell ${preferenceTypeClass(item?.preferenceType)}${item?.status === 'PENDING' ? ' is-pending' : ''}`}
disabled={busyCell === `${dayOfWeek}:${slot.id}`}
title={title}
aria-label={title}
onClick={() => void handleCell(dayOfWeek, slot.id)}
>
<span>{item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
</button>
);
})
])}
</>
)}
</div>
</div>
<div
className={`preference-message${message.isError ? ' error' : ''}${message.text && !message.isError ? ' success' : ''}`}
role="status"
>
{message.text}
</div>
</section>
<div className="preference-lower-grid">
<section className="preference-card">
<span className="preference-card-number">01</span>
<h3>Полная недоступность</h3>
<p>Добавьте отдельную дату внутри семестра — все пары этого дня будут считаться строгим ограничением.</p>
<form className="preference-date-form" onSubmit={submitDate}>
<input
type="date"
required
min={semester?.startDate || ''}
max={semester?.endDate || ''}
value={dateValue}
onChange={event => setDateValue(event.target.value)}
/>
<input
type="text"
maxLength={2000}
placeholder="Комментарий (необязательно)"
value={dateComment}
onChange={event => setDateComment(event.target.value)}
/>
<button className="btn btn-md btn-primary" type="submit">Добавить дату</button>
</form>
<div className="preference-date-list">
{dateItems.length === 0 ? (
<div className="preference-empty">Даты полной недоступности не добавлены</div>
) : dateItems.map(item => (
<article key={item.id}>
<span className={`preference-date-mark ${item.status === 'APPROVED' ? 'approved' : ''}`} />
<div>
<strong>{formatIsoDate(item.preferenceDate)}</strong>
<small>{item.comment || 'Весь день'}</small>
</div>
<span className="preference-status">{preferenceStatusLabel(item.status)}</span>
{item.status === 'PENDING' && (
<button
type="button"
className="btn btn-sm btn-danger-subtle"
onClick={() => void cancelDatePreference(item.id)}
>
Отменить
</button>
)}
</article>
))}
</div>
</section>
<section className="preference-card">
<span className="preference-card-number">02</span>
<h3>Компактность недели</h3>
<p>Эти мягкие пожелания не отменяют занятия автоматически, но видны при составлении правил.</p>
<CompactToggle
type="CONSECUTIVE"
title="Пары подряд"
description="Собирать занятия последовательными блоками"
preference={findCompactPreference(preferences, 'CONSECUTIVE')}
onToggle={toggleCompact}
/>
<CompactToggle
type="NO_GAPS"
title="Без окон"
description="Не оставлять пустые интервалы между занятиями"
preference={findCompactPreference(preferences, 'NO_GAPS')}
onToggle={toggleCompact}
/>
</section>
</div>
</>
);
}
function CompactToggle({ type, title, description, preference, onToggle }) {
const approved = preference?.status === 'APPROVED';
return (
<label className={`preference-toggle-card${approved ? ' approved' : ''}`}>
<input
type="checkbox"
checked={Boolean(preference)}
disabled={approved}
onChange={event => void onToggle(type, event.target.checked, preference?.id)}
/>
<span><strong>{title}</strong><small>{description}</small></span>
</label>
);
}