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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 'Загрузка расписания...';
}

View 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;
}

View 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>
);

View 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: 'Не удалось отменить заявку' }
);
}

View 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));
}