Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
351 lines
16 KiB
JavaScript
351 lines
16 KiB
JavaScript
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>
|
||
);
|
||
}
|