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:
350
frontend/react/teacher/PreferencesTab.jsx
Normal file
350
frontend/react/teacher/PreferencesTab.jsx
Normal file
@@ -0,0 +1,350 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user