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:
158
frontend/react/teacher/AbsencePanel.jsx
Normal file
158
frontend/react/teacher/AbsencePanel.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
|
||||
import {
|
||||
absenceStatusClass,
|
||||
absenceStatusLabel,
|
||||
cancelAbsence,
|
||||
createAbsence,
|
||||
fetchAbsences,
|
||||
validateAbsence
|
||||
} from './teacher-service.js';
|
||||
|
||||
export function AbsencePanel() {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [startDate, setStartDate] = useState(() => toIsoDate(new Date()));
|
||||
const [endDate, setEndDate] = useState(() => toIsoDate(new Date()));
|
||||
const [reason, setReason] = useState('');
|
||||
const [status, setStatus] = useState({ message: '', isError: false });
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchAbsences();
|
||||
if (active) setItems(loaded);
|
||||
} catch {
|
||||
if (active) setError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
setItems(await fetchAbsences());
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
const validation = validateAbsence({ endDate, reason, startDate });
|
||||
if (validation) {
|
||||
setStatus({ message: validation, isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createAbsence({ endDate, reason: reason.trim(), startDate });
|
||||
setReason('');
|
||||
setStatus({ message: 'Заявка отправлена кафедре', isError: false });
|
||||
await reload();
|
||||
} catch {
|
||||
setStatus({ message: 'Не удалось отправить заявку об отсутствии', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(id) {
|
||||
try {
|
||||
await cancelAbsence(id);
|
||||
setStatus({ message: 'Заявка отменена', isError: false });
|
||||
await reload();
|
||||
} catch {
|
||||
setStatus({ message: 'Не удалось отменить заявку', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="teacher-absence-panel">
|
||||
<div className="teacher-absence-heading">
|
||||
<div>
|
||||
<span>Изменение планов</span>
|
||||
<h2>Сообщить об отсутствии</h2>
|
||||
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-md btn-secondary"
|
||||
type="button"
|
||||
aria-expanded={formOpen}
|
||||
onClick={() => setFormOpen(open => !open)}
|
||||
>
|
||||
{formOpen ? 'Скрыть форму' : 'Новая заявка'}
|
||||
</button>
|
||||
</div>
|
||||
<form className="teacher-absence-form" hidden={!formOpen} onSubmit={submit}>
|
||||
<label>
|
||||
<span>Начало</span>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={event => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Окончание</span>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={event => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="teacher-absence-reason">
|
||||
<span>Причина</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2000}
|
||||
placeholder="Например, командировка"
|
||||
required
|
||||
value={reason}
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
|
||||
</form>
|
||||
<div className={`teacher-absence-status${status.isError ? ' error' : ''}${status.message && !status.isError ? ' success' : ''}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
<div className="teacher-absence-list" aria-live="polite">
|
||||
{error ? (
|
||||
<div className="teacher-absence-empty error">Не удалось загрузить заявки об отсутствии</div>
|
||||
) : items === null ? (
|
||||
<div className="teacher-absence-empty">Загрузка заявок...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="teacher-absence-empty">Заявок об отсутствии пока нет</div>
|
||||
) : items.map(item => (
|
||||
<article key={item.id} className="teacher-absence-item">
|
||||
<span className={`teacher-absence-marker ${absenceStatusClass(item.status)}`} />
|
||||
<div>
|
||||
<strong>{formatIsoDate(item.startDate)} — {formatIsoDate(item.endDate)}</strong>
|
||||
<p>{item.reason || 'Причина не указана'}</p>
|
||||
</div>
|
||||
<span className={`teacher-absence-badge ${absenceStatusClass(item.status)}`}>
|
||||
{absenceStatusLabel(item.status)}
|
||||
</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
type="button"
|
||||
onClick={() => void cancel(item.id)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
177
frontend/react/teacher/RequestDialog.jsx
Normal file
177
frontend/react/teacher/RequestDialog.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { formatIsoDate, trimTime } from '../shared/dates.js';
|
||||
import {
|
||||
buildChangeRequestPayload,
|
||||
candidateOptions,
|
||||
fetchChangeRequestCandidates
|
||||
} from './teacher-service.js';
|
||||
|
||||
export function RequestDialog({ lesson, onClose, onSubmitted }) {
|
||||
const [candidates, setCandidates] = useState(null);
|
||||
const [candidatesError, setCandidatesError] = useState(false);
|
||||
const [requestType, setRequestType] = useState('MOVE');
|
||||
const [targetDate, setTargetDate] = useState('');
|
||||
const [timeSlotId, setTimeSlotId] = useState('');
|
||||
const [classroomId, setClassroomId] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [message, setMessage] = useState({ text: 'Проверяем допустимые варианты...', isError: false });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('dialog-open');
|
||||
return () => {
|
||||
document.body.classList.remove('dialog-open');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchChangeRequestCandidates({
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date
|
||||
});
|
||||
if (!active) return;
|
||||
setCandidates(loaded);
|
||||
setCandidatesError(false);
|
||||
setTargetDate(loaded.checkedDate || (loaded.availableDates || [])[0] || '');
|
||||
setMessage({ text: 'Доступны только варианты, прошедшие проверку конфликтов', isError: false });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setCandidatesError(true);
|
||||
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [lesson]);
|
||||
|
||||
async function reloadCandidates(nextDate) {
|
||||
if (!nextDate) return;
|
||||
setMessage({ text: 'Проверяем выбранную дату...', isError: false });
|
||||
try {
|
||||
const loaded = await fetchChangeRequestCandidates({
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date,
|
||||
targetDate: nextDate
|
||||
});
|
||||
setCandidates(loaded);
|
||||
setCandidatesError(false);
|
||||
setTimeSlotId('');
|
||||
setClassroomId('');
|
||||
setMessage({ text: 'Предварительная проверка завершена', isError: false });
|
||||
} catch {
|
||||
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!reason.trim()) {
|
||||
setMessage({ text: 'Укажите обоснование заявки', isError: true });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmitted(buildChangeRequestPayload({
|
||||
classroomId,
|
||||
lesson,
|
||||
reason,
|
||||
requestType,
|
||||
targetDate,
|
||||
timeSlotId
|
||||
}));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const timeOptions = candidateOptions(candidates?.timeSlots, 'Нет свободных временных слотов');
|
||||
const classroomOptions = candidateOptions(candidates?.classrooms, 'Нет свободных аудиторий');
|
||||
|
||||
return (
|
||||
<div className="request-dialog-overlay" onClick={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<section className="request-dialog" role="dialog" aria-modal="true" aria-labelledby="request-dialog-title">
|
||||
<header>
|
||||
<div>
|
||||
<span>Предварительная проверка</span>
|
||||
<h2 id="request-dialog-title">Заявка на изменение</h2>
|
||||
<p>{lesson.subjectName} · {formatIsoDate(lesson.date)} · {trimTime(lesson.startTime)}–{trimTime(lesson.endTime)}</p>
|
||||
</div>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Закрыть" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
<span>Что изменить</span>
|
||||
<select value={requestType} onChange={event => setRequestType(event.target.value)}>
|
||||
<option value="MOVE">Перенести дату или время</option>
|
||||
<option value="CHANGE_CLASSROOM">Сменить аудиторию</option>
|
||||
<option value="CANCEL">Отменить занятие</option>
|
||||
</select>
|
||||
</label>
|
||||
{requestType === 'MOVE' && (
|
||||
<label>
|
||||
<span>Новая дата</span>
|
||||
<select
|
||||
value={targetDate}
|
||||
onChange={event => {
|
||||
setTargetDate(event.target.value);
|
||||
void reloadCandidates(event.target.value);
|
||||
}}
|
||||
>
|
||||
{(candidates?.availableDates || []).map(date => (
|
||||
<option key={date} value={date}>{formatIsoDate(date)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{requestType === 'MOVE' && (
|
||||
<label>
|
||||
<span>Новая пара</span>
|
||||
<select value={timeSlotId} onChange={event => setTimeSlotId(event.target.value)}>
|
||||
{timeOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{requestType === 'CHANGE_CLASSROOM' && (
|
||||
<label>
|
||||
<span>Новая аудитория</span>
|
||||
<select value={classroomId} onChange={event => setClassroomId(event.target.value)}>
|
||||
{classroomOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="request-reason-field">
|
||||
<span>Обоснование</span>
|
||||
<textarea
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="Почему требуется изменение"
|
||||
required
|
||||
value={reason}
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`request-dialog-message${message.isError ? ' error' : ''}`} role="alert">
|
||||
{message.text}
|
||||
</div>
|
||||
<footer>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onClose}>Закрыть</button>
|
||||
<button className="btn btn-md btn-primary" type="submit" disabled={submitting || candidatesError}>
|
||||
Отправить в учебный отдел
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/react/teacher/RequestsTab.jsx
Normal file
80
frontend/react/teacher/RequestsTab.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
changeRequestTarget,
|
||||
changeTypeLabel,
|
||||
formatInstant,
|
||||
preferenceStatusLabel
|
||||
} from './teacher-service.js';
|
||||
import { formatIsoDate, trimTime } from '../shared/dates.js';
|
||||
|
||||
export function RequestsTab({ items, onCancel, onRefresh, stateKind }) {
|
||||
return (
|
||||
<>
|
||||
<section className="request-ledger-head">
|
||||
<div>
|
||||
<span>Журнал решений</span>
|
||||
<h2>Заявки на изменение занятий</h2>
|
||||
<p>Откройте занятие в расписании, чтобы запросить перенос, другую аудиторию или отмену. Здесь сохраняются статус, комментарий и вся история решения.</p>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onRefresh}>Обновить</button>
|
||||
</section>
|
||||
<div className="request-ledger" aria-live="polite">
|
||||
{stateKind === 'error' ? (
|
||||
<div className="request-empty error">Не удалось загрузить заявки</div>
|
||||
) : stateKind === 'loading' ? (
|
||||
<div className="request-empty">Загрузка заявок...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="request-empty">Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».</div>
|
||||
) : items.map(item => (
|
||||
<RequestLedgerItem key={item.id} item={item} onCancel={onCancel} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestLedgerItem({ item, onCancel }) {
|
||||
const history = item.history || [];
|
||||
const statusClass = String(item.status || '').toLowerCase();
|
||||
|
||||
return (
|
||||
<article className="request-ledger-item">
|
||||
<div className={`request-ledger-stripe ${statusClass}`} />
|
||||
<div className="request-ledger-main">
|
||||
<span>{formatIsoDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)}</span>
|
||||
<h3>{item.subjectName}</h3>
|
||||
<p>{(item.groupNames || []).join(', ')} · {item.sourceClassroomName}</p>
|
||||
<div className="request-change-line">
|
||||
<b>{changeTypeLabel(item.requestType)}</b>
|
||||
<i>→</i>
|
||||
<strong>{changeRequestTarget(item)}</strong>
|
||||
</div>
|
||||
<p className="request-reason">{item.reason}</p>
|
||||
{item.reviewComment && <p className="request-review-comment">Решение: {item.reviewComment}</p>}
|
||||
<details>
|
||||
<summary>История решения · {history.length}</summary>
|
||||
<ol>
|
||||
{history.map((entry, index) => (
|
||||
<li key={index}>
|
||||
<time>{formatInstant(entry.createdAt)}</time>
|
||||
<strong>{preferenceStatusLabel(entry.status)}</strong>
|
||||
<span>{entry.comment || 'Без комментария'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
<div className="request-ledger-side">
|
||||
<span className={`request-status ${statusClass}`}>{preferenceStatusLabel(item.status)}</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger-subtle"
|
||||
onClick={() => void onCancel(item.id)}
|
||||
>
|
||||
Отозвать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
363
frontend/react/teacher/TeacherApp.jsx
Normal file
363
frontend/react/teacher/TeacherApp.jsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { readUrlState, writeUrlState } from '../../admin/js/url-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.js';
|
||||
import { buildScheduleOverviewModel } from '../../schedule-overview.js';
|
||||
import { logoutAndRedirect } from '../shared/auth-client.js';
|
||||
import { SESSION_PHASES } from '../shared/session-policy.js';
|
||||
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
||||
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
||||
import { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
|
||||
import {
|
||||
addDays,
|
||||
formatWeekLabel,
|
||||
parseIsoDate,
|
||||
startOfWeek,
|
||||
toIsoDate,
|
||||
weekEndIso
|
||||
} from '../shared/dates.js';
|
||||
import { AbsencePanel } from './AbsencePanel.jsx';
|
||||
import { PreferencesTab } from './PreferencesTab.jsx';
|
||||
import { RequestDialog } from './RequestDialog.jsx';
|
||||
import { RequestsTab } from './RequestsTab.jsx';
|
||||
import { TeacherWeekGrid } from './TeacherWeekGrid.jsx';
|
||||
import {
|
||||
cancelChangeRequest,
|
||||
createChangeRequest,
|
||||
fetchChangeRequests,
|
||||
fetchTeacherSchedule,
|
||||
pendingCount,
|
||||
WORKSPACE_TABS
|
||||
} from './teacher-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const OVERVIEW_DAYS = 14;
|
||||
|
||||
const TAB_TITLES = Object.freeze({
|
||||
preferences: 'Мои пожелания',
|
||||
requests: 'Заявки на изменение',
|
||||
schedule: 'Расписание'
|
||||
});
|
||||
|
||||
export function TeacherApp() {
|
||||
const sessionState = useAuthorizedSession(['TEACHER']);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||
}, []);
|
||||
|
||||
if (sessionState.phase === SESSION_PHASES.loading) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="loading" title="Кабинет преподавателя" message="Проверяем сессию…" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionState.phase !== SESSION_PHASES.ready) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <TeacherWorkspace teacherId={sessionState.session.userId} />;
|
||||
}
|
||||
|
||||
function TeacherWorkspace({ teacherId }) {
|
||||
const [initialUrlState] = useState(() => readUrlState({ teacherTab: 'schedule', teacherDate: '' }));
|
||||
const [activeTab, setActiveTab] = useState(() => (
|
||||
WORKSPACE_TABS.includes(initialUrlState.teacherTab) ? initialUrlState.teacherTab : 'schedule'
|
||||
));
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(
|
||||
initialUrlState.teacherDate ? parseIsoDate(initialUrlState.teacherDate) : new Date()
|
||||
));
|
||||
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
|
||||
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
|
||||
const [requestsState, setRequestsState] = useState({ kind: 'loading', items: [] });
|
||||
const [dialogLesson, setDialogLesson] = useState(null);
|
||||
|
||||
const weekStartIso = toIsoDate(weekStart);
|
||||
|
||||
useEffect(() => {
|
||||
writeUrlState({ teacherDate: weekStartIso });
|
||||
}, [weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
setRequestsState({ kind: 'loading', items: [] });
|
||||
try {
|
||||
const items = await fetchChangeRequests();
|
||||
if (!active) return;
|
||||
setRequestsState({ kind: 'ready', items });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teacherId) {
|
||||
setScheduleState({ kind: 'error', message: 'Не удалось определить преподавателя' });
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
(async () => {
|
||||
setScheduleState({ kind: 'loading' });
|
||||
try {
|
||||
const lessons = await fetchTeacherSchedule({
|
||||
endDate: weekEndIso(weekStart),
|
||||
startDate: weekStartIso,
|
||||
teacherId
|
||||
});
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'ready', lessons });
|
||||
trackUxEvent('teacher_schedule_loaded', { lessons: lessons.length, startDate: weekStartIso });
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setScheduleState({ kind: 'error' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [teacherId, weekStart, weekStartIso]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teacherId) {
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось определить преподавателя' });
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
(async () => {
|
||||
setOverviewState({ kind: 'loading' });
|
||||
const now = new Date();
|
||||
try {
|
||||
const lessons = await fetchTeacherSchedule({
|
||||
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS)),
|
||||
startDate: toIsoDate(now),
|
||||
teacherId
|
||||
});
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'ready', lessons, now });
|
||||
const overview = buildScheduleOverviewModel(lessons, now);
|
||||
trackUxEvent('teacher_schedule_overview_loaded', {
|
||||
todayCount: overview.todayLessons.length,
|
||||
hasNext: Boolean(overview.nextLesson)
|
||||
});
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [teacherId]);
|
||||
|
||||
function switchTab(name) {
|
||||
setActiveTab(name);
|
||||
writeUrlState({ teacherTab: name });
|
||||
trackUxEvent('teacher_workspace_opened', { section: name });
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
setWeekStart(current => addDays(current, delta * 7));
|
||||
}
|
||||
|
||||
function goToCurrentWeek() {
|
||||
setWeekStart(startOfWeek(new Date()));
|
||||
}
|
||||
|
||||
function handleDateChange(event) {
|
||||
if (event.target.value) {
|
||||
setWeekStart(startOfWeek(parseIsoDate(event.target.value)));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
function handleLogout(event) {
|
||||
event.preventDefault();
|
||||
void logoutAndRedirect();
|
||||
}
|
||||
|
||||
async function refreshRequests() {
|
||||
try {
|
||||
const items = await fetchChangeRequests();
|
||||
setRequestsState({ kind: 'ready', items });
|
||||
} catch {
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelRequest(id) {
|
||||
try {
|
||||
await cancelChangeRequest(id);
|
||||
await refreshRequests();
|
||||
} catch {
|
||||
setRequestsState({ kind: 'error', items: [] });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitRequest(payload) {
|
||||
try {
|
||||
await createChangeRequest(payload);
|
||||
setDialogLesson(null);
|
||||
await refreshRequests();
|
||||
switchTab('requests');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="topbar">
|
||||
<div className="title">
|
||||
<h1>Кабинет преподавателя</h1>
|
||||
<span>{formatWeekLabel(weekStart)}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn btn-icon-md btn-secondary"
|
||||
type="button"
|
||||
aria-label="Переключить тему"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a className="btn btn-danger-subtle" href="/" onClick={handleLogout}>Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="workspace-nav" aria-label="Разделы кабинета">
|
||||
{WORKSPACE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`workspace-tab${activeTab === tab ? ' active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => switchTab(tab)}
|
||||
>
|
||||
{TAB_TITLES[tab]}
|
||||
{tab === 'requests' && (
|
||||
<span>{pendingCount(requestsState.items)}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'schedule' ? ' active' : ''}`} hidden={activeTab !== 'schedule'}>
|
||||
<section className="toolbar">
|
||||
<div className="week-nav">
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Предыдущая неделя" onClick={() => moveWeek(-1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="btn btn-md btn-primary" type="button" onClick={goToCurrentWeek}>Сегодня</button>
|
||||
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Следующая неделя" onClick={() => moveWeek(1)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
aria-label="Дата недели"
|
||||
value={weekStartIso}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{overviewState.kind === 'ready' ? (
|
||||
<ScheduleOverview
|
||||
lessons={overviewState.lessons}
|
||||
now={overviewState.now}
|
||||
onGoToday={goToCurrentWeek}
|
||||
role="teacher"
|
||||
/>
|
||||
) : (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
{overviewState.kind === 'error' ? (
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Быстрый обзор"
|
||||
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
|
||||
/>
|
||||
) : (
|
||||
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
|
||||
{scheduleStatus(scheduleState)}
|
||||
</div>
|
||||
|
||||
<AbsencePanel />
|
||||
|
||||
{scheduleState.kind === 'ready' ? (
|
||||
<TeacherWeekGrid
|
||||
lessons={scheduleState.lessons}
|
||||
onRequestLesson={setDialogLesson}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
) : (
|
||||
<TeacherWeekGrid
|
||||
emptyMessage={scheduleState.kind === 'error' ? 'Ошибка загрузки' : 'Загрузка...'}
|
||||
lessons={[]}
|
||||
onRequestLesson={setDialogLesson}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'preferences' ? ' active' : ''}`} hidden={activeTab !== 'preferences'}>
|
||||
<PreferencesTab />
|
||||
</section>
|
||||
|
||||
<section className={`workspace-view${activeTab === 'requests' ? ' active' : ''}`} hidden={activeTab !== 'requests'}>
|
||||
<RequestsTab
|
||||
items={requestsState.items}
|
||||
onCancel={handleCancelRequest}
|
||||
onRefresh={() => void refreshRequests()}
|
||||
stateKind={requestsState.kind}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{dialogLesson && (
|
||||
<RequestDialog
|
||||
lesson={dialogLesson}
|
||||
onClose={() => setDialogLesson(null)}
|
||||
onSubmitted={handleSubmitRequest}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleStatus(scheduleState) {
|
||||
if (scheduleState.kind === 'error') {
|
||||
return scheduleState.message || 'Не удалось загрузить расписание';
|
||||
}
|
||||
if (scheduleState.kind === 'ready') {
|
||||
const count = scheduleState.lessons.length;
|
||||
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
|
||||
}
|
||||
return 'Загрузка расписания...';
|
||||
}
|
||||
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
156
frontend/react/teacher/TeacherWeekGrid.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { dayName, formatDate, toIsoDate, weekDates } from '../shared/dates.js';
|
||||
import {
|
||||
buildDesktopWeekModel,
|
||||
buildTeacherLessonChips,
|
||||
canRequestChange,
|
||||
lessonsByDate
|
||||
} from './teacher-service.js';
|
||||
|
||||
const MOBILE_BREAKPOINT = '(max-width: 980px)';
|
||||
|
||||
export function TeacherWeekGrid({ emptyMessage = 'Загрузка...', lessons = [], onRequestLesson, weekStart }) {
|
||||
const mobile = useMobileLayout();
|
||||
|
||||
if (mobile) {
|
||||
return <MobileWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
||||
}
|
||||
return <DesktopWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
|
||||
}
|
||||
|
||||
function DesktopWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
||||
const { days, slots, lessonsByKey } = buildDesktopWeekModel(weekStart, lessons);
|
||||
const hasLessons = lessons.length > 0;
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
<div className="corner" />
|
||||
{days.map(day => <DayHead key={day.isoDate} date={day.date} />)}
|
||||
{hasLessons ? (
|
||||
slots.map(slot => (
|
||||
<SlotRow
|
||||
key={slot.order}
|
||||
days={days}
|
||||
lessonsByKey={lessonsByKey}
|
||||
onRequestLesson={onRequestLesson}
|
||||
slot={slot}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="time-cell">Неделя</div>
|
||||
{days.map(day => (
|
||||
<div key={day.isoDate} className="day-cell">
|
||||
<div className="empty">{emptyMessage}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotRow({ days, lessonsByKey, onRequestLesson, slot }) {
|
||||
return (
|
||||
<>
|
||||
<div className="time-cell">{slot.label}</div>
|
||||
{days.map(day => {
|
||||
const lesson = lessonsByKey.get(`${day.isoDate}:${slot.order}`);
|
||||
return (
|
||||
<div key={`${day.isoDate}:${slot.order}`} className="day-cell">
|
||||
{lesson
|
||||
? <LessonCard lesson={lesson} onRequestLesson={onRequestLesson} />
|
||||
: <div className="empty">Нет занятия</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
|
||||
const byDate = lessonsByDate(lessons);
|
||||
|
||||
return (
|
||||
<section className="schedule" aria-live="polite">
|
||||
{weekDates(weekStart).map(date => {
|
||||
const isoDate = toIsoDate(date);
|
||||
const dayLessons = byDate.get(isoDate) || [];
|
||||
return (
|
||||
<article key={isoDate} className="mobile-day">
|
||||
<DayHead date={date} />
|
||||
<div className="mobile-list">
|
||||
{dayLessons.length > 0
|
||||
? dayLessons.map((lesson, index) => (
|
||||
<LessonCard
|
||||
key={`${lesson.date}-${trimKey(lesson)}-${index}`}
|
||||
lesson={lesson}
|
||||
onRequestLesson={onRequestLesson}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">{emptyMessage}</div>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DayHead({ date }) {
|
||||
return (
|
||||
<div className="day-head">
|
||||
<strong>{dayName(date)}</strong>
|
||||
<span>{formatDate(date)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCard({ lesson, onRequestLesson }) {
|
||||
return (
|
||||
<div className="lesson">
|
||||
<div className="lesson-title">{lesson.subjectName}</div>
|
||||
<div className="lesson-meta">
|
||||
{buildTeacherLessonChips(lesson).map((chip, index) => (
|
||||
<span key={index} className="chip">{chip}</span>
|
||||
))}
|
||||
</div>
|
||||
{canRequestChange(lesson) ? (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary lesson-request-button"
|
||||
type="button"
|
||||
onClick={() => onRequestLesson(lesson)}
|
||||
>
|
||||
Запросить изменение
|
||||
</button>
|
||||
) : (
|
||||
<span className="lesson-change-note">Занятие уже изменено учебным отделом</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function trimKey(lesson) {
|
||||
return String(lesson.startTime || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function useMobileLayout() {
|
||||
const [mobile, setMobile] = useState(() => matchesMobile());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
||||
const query = window.matchMedia(MOBILE_BREAKPOINT);
|
||||
const update = () => setMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function matchesMobile() {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia)
|
||||
&& window.matchMedia(MOBILE_BREAKPOINT).matches;
|
||||
}
|
||||
22
frontend/react/teacher/main.jsx
Normal file
22
frontend/react/teacher/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import { AppErrorBoundary } from '../shared/ui/AppErrorBoundary.jsx';
|
||||
import { TeacherApp } from './TeacherApp.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const rootElement = document.getElementById('teacher-root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент кабинета преподавателя');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<TeacherApp />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
292
frontend/react/teacher/teacher-service.js
Normal file
292
frontend/react/teacher/teacher-service.js
Normal file
@@ -0,0 +1,292 @@
|
||||
import { apiClient } from '../shared/api-client.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatIsoDate,
|
||||
toIsoDate,
|
||||
trimTime,
|
||||
weekDates
|
||||
} from '../shared/dates.js';
|
||||
|
||||
export const WORKSPACE_TABS = Object.freeze(['schedule', 'preferences', 'requests']);
|
||||
export const PREFERENCE_BRUSHES = Object.freeze(['SOFT_PREFERRED', 'SOFT_UNWANTED', 'HARD_UNAVAILABLE']);
|
||||
export const CHANGE_REQUEST_TYPES = Object.freeze(['MOVE', 'CHANGE_CLASSROOM', 'CANCEL']);
|
||||
|
||||
export function lessonKey(lesson) {
|
||||
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
|
||||
}
|
||||
|
||||
export function collectSlots(lessons) {
|
||||
const slots = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
slots.set(lesson.timeSlotOrder, {
|
||||
order: lesson.timeSlotOrder,
|
||||
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
|
||||
});
|
||||
});
|
||||
if (!slots.size) {
|
||||
slots.set(1, { order: 1, label: 'Расписание' });
|
||||
}
|
||||
return [...slots.values()].sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
export function buildDesktopWeekModel(weekStart, lessons) {
|
||||
const slots = collectSlots(lessons);
|
||||
const lessonsByKey = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
|
||||
});
|
||||
const days = weekDates(weekStart).map(date => ({
|
||||
date,
|
||||
isoDate: toIsoDate(date),
|
||||
label: formatDate(date)
|
||||
}));
|
||||
return { days, slots, lessonsByKey };
|
||||
}
|
||||
|
||||
export function lessonsByDate(lessons) {
|
||||
const byDate = new Map();
|
||||
(lessons || []).forEach(lesson => {
|
||||
if (!byDate.has(lesson.date)) byDate.set(lesson.date, []);
|
||||
byDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
export function buildTeacherLessonChips(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return [
|
||||
`${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`,
|
||||
lesson.lessonTypeName,
|
||||
subgroups,
|
||||
lesson.classroomName,
|
||||
groups
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function canRequestChange(lesson) {
|
||||
return Boolean(lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId);
|
||||
}
|
||||
|
||||
export function absenceStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На подтверждении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
RESOLVED: 'Замены выполнены',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function absenceStatusClass(status) {
|
||||
return `is-${String(status || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function preferenceTypeLabel(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'Строго недоступен',
|
||||
SOFT_PREFERRED: 'Предпочтительно',
|
||||
SOFT_UNWANTED: 'Нежелательно',
|
||||
CONSECUTIVE: 'Пары подряд',
|
||||
NO_GAPS: 'Без окон'
|
||||
}[type] || type || '—';
|
||||
}
|
||||
|
||||
export function preferenceTypeClass(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'is-hard',
|
||||
SOFT_PREFERRED: 'is-preferred',
|
||||
SOFT_UNWANTED: 'is-unwanted'
|
||||
}[type] || '';
|
||||
}
|
||||
|
||||
export function preferenceTypeSymbol(type) {
|
||||
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
|
||||
}
|
||||
|
||||
export function preferenceStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
export function changeTypeLabel(type) {
|
||||
return {
|
||||
MOVE: 'Перенос',
|
||||
CHANGE_CLASSROOM: 'Смена аудитории',
|
||||
CANCEL: 'Отмена'
|
||||
}[type] || type || '—';
|
||||
}
|
||||
|
||||
export function changeRequestTarget(item) {
|
||||
if (item.requestType === 'MOVE') {
|
||||
return `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`;
|
||||
}
|
||||
if (item.requestType === 'CHANGE_CLASSROOM') {
|
||||
return item.requestedClassroomName;
|
||||
}
|
||||
return 'Отмена занятия';
|
||||
}
|
||||
|
||||
export function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'AUTUMN'
|
||||
? 'осенний'
|
||||
: semester.semesterType === 'SPRING'
|
||||
? 'весенний'
|
||||
: semester.semesterType;
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
export function resolveCurrentSemester(semesters, todayIso) {
|
||||
return (semesters || []).find(semester => semester.startDate <= todayIso && semester.endDate >= todayIso) || null;
|
||||
}
|
||||
|
||||
export function resolveSelectedSemester(semesters, semesterId) {
|
||||
return (semesters || []).find(semester => String(semester.id) === String(semesterId));
|
||||
}
|
||||
|
||||
export function activeWeekPreferences(preferences) {
|
||||
return (preferences || []).filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
|
||||
}
|
||||
|
||||
export function activeDatePreferences(preferences) {
|
||||
return (preferences || []).filter(item => item.preferenceDate && item.status !== 'CANCELLED');
|
||||
}
|
||||
|
||||
export function findCompactPreference(preferences, type) {
|
||||
return (preferences || []).find(
|
||||
candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status)
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function findWeekPreference(preferences, dayOfWeek, timeSlotId) {
|
||||
return (preferences || []).find(
|
||||
candidate => ['PENDING', 'APPROVED'].includes(candidate.status)
|
||||
&& candidate.dayOfWeek === dayOfWeek
|
||||
&& candidate.timeSlotId === timeSlotId
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function pendingCount(items) {
|
||||
return (items || []).filter(item => item.status === 'PENDING').length;
|
||||
}
|
||||
|
||||
export function formatInstant(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function validateAbsence({ startDate = '', endDate = '', reason = '' } = {}) {
|
||||
if (!startDate || !endDate || !reason.trim()) {
|
||||
return 'Заполните период и причину отсутствия';
|
||||
}
|
||||
if (endDate < startDate) {
|
||||
return 'Дата окончания не может быть раньше даты начала';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildChangeRequestPayload({ lesson, requestType, targetDate = '', timeSlotId = '', classroomId = '', reason = '' }) {
|
||||
return {
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.date,
|
||||
requestType,
|
||||
targetLessonDate: requestType === 'MOVE' ? targetDate : null,
|
||||
requestedTimeSlotId: requestType === 'MOVE' ? Number(timeSlotId) || null : null,
|
||||
requestedClassroomId: requestType === 'CHANGE_CLASSROOM' ? Number(classroomId) || null : null,
|
||||
reason: reason.trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function candidateOptions(items, emptyLabel) {
|
||||
const available = (items || []).filter(item => item.available);
|
||||
return available.length > 0 ? available : [{ id: '', label: emptyLabel }];
|
||||
}
|
||||
|
||||
export async function fetchTeacherSchedule({ teacherId, startDate, endDate }, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`,
|
||||
{ errorMessage: 'Не удалось загрузить расписание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPreferenceMeta(client = apiClient) {
|
||||
return client.get('/api/teacher-preferences/meta', { errorMessage: 'Не удалось загрузить параметры семестра' });
|
||||
}
|
||||
|
||||
export async function fetchPreferences(semesterId, client = apiClient) {
|
||||
return client.get(
|
||||
`/api/teacher-preferences?semesterId=${encodeURIComponent(semesterId)}`,
|
||||
{ errorMessage: 'Не удалось загрузить пожелания' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPreference(payload, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-preferences',
|
||||
payload,
|
||||
{ errorMessage: 'Не удалось сохранить пожелание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePreference(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-preferences/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отменить пожелание' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangeRequests(client = apiClient) {
|
||||
return client.get('/api/teacher-change-requests', { errorMessage: 'Не удалось загрузить заявки' });
|
||||
}
|
||||
|
||||
export async function createChangeRequest(payload, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-change-requests',
|
||||
payload,
|
||||
{ errorMessage: 'Не удалось отправить заявку' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChangeRequest(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-change-requests/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отозвать заявку' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangeRequestCandidates({ baseRuleSlotId, lessonDate, targetDate = '' }, client = apiClient) {
|
||||
const params = new URLSearchParams({
|
||||
baseRuleSlotId: String(baseRuleSlotId),
|
||||
lessonDate
|
||||
});
|
||||
if (targetDate) params.set('targetDate', targetDate);
|
||||
return client.get(
|
||||
`/api/teacher-change-requests/candidates?${params}`,
|
||||
{ errorMessage: 'Не удалось проверить допустимые варианты' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAbsences(client = apiClient) {
|
||||
return client.get('/api/teacher-absences', { errorMessage: 'Не удалось загрузить заявки об отсутствии' });
|
||||
}
|
||||
|
||||
export async function createAbsence({ startDate, endDate, reason }, client = apiClient) {
|
||||
return client.post(
|
||||
'/api/teacher-absences',
|
||||
{ startDate, endDate, reason },
|
||||
{ errorMessage: 'Не удалось отправить заявку об отсутствии' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelAbsence(id, client = apiClient) {
|
||||
return client.delete(
|
||||
`/api/teacher-absences/${encodeURIComponent(id)}`,
|
||||
{ errorMessage: 'Не удалось отменить заявку' }
|
||||
);
|
||||
}
|
||||
38
frontend/react/teacher/testing/render-teacher.jsx
Normal file
38
frontend/react/teacher/testing/render-teacher.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ScheduleOverview } from '../../shared/ui/ScheduleOverview.jsx';
|
||||
import { AbsencePanel } from '../AbsencePanel.jsx';
|
||||
import { PreferencesTab } from '../PreferencesTab.jsx';
|
||||
import { RequestDialog } from '../RequestDialog.jsx';
|
||||
import { RequestsTab } from '../RequestsTab.jsx';
|
||||
import { TeacherWeekGrid } from '../TeacherWeekGrid.jsx';
|
||||
import { parseIsoDate, startOfWeek } from '../../shared/dates.js';
|
||||
|
||||
export function renderTeacherWeekGrid(props) {
|
||||
return renderToStaticMarkup(createElement(TeacherWeekGrid, props));
|
||||
}
|
||||
|
||||
export function renderScheduleOverview(props) {
|
||||
return renderToStaticMarkup(createElement(ScheduleOverview, props));
|
||||
}
|
||||
|
||||
export function renderAbsencePanel() {
|
||||
return renderToStaticMarkup(createElement(AbsencePanel));
|
||||
}
|
||||
|
||||
export function renderPreferencesTab() {
|
||||
return renderToStaticMarkup(createElement(PreferencesTab));
|
||||
}
|
||||
|
||||
export function renderRequestsTab(props) {
|
||||
return renderToStaticMarkup(createElement(RequestsTab, props));
|
||||
}
|
||||
|
||||
export function renderRequestDialog(props) {
|
||||
return renderToStaticMarkup(createElement(RequestDialog, props));
|
||||
}
|
||||
|
||||
export function weekStartOf(isoDate) {
|
||||
return startOfWeek(parseIsoDate(isoDate));
|
||||
}
|
||||
Reference in New Issue
Block a user