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,466 @@
import { useEffect, useRef, useState } from 'react';
import { ConfirmDialog } from '../shared/ui/ConfirmDialog.jsx';
import {
buildSlotPayload,
createScope,
deleteScope,
deleteTimeSlot,
fetchScopes,
fetchTimeSlots,
isSystemScope,
resolveActiveScopeId,
saveTimeSlot,
scopeBadgeClass,
scopeBadgeLabel,
scopeOptionLabel,
scopeSummaryText,
slotDurationMinutes,
slotsForScope,
validateSlotPayload
} from './settings-service.js';
const EMPTY_SLOT_FORM = { endTime: '', id: '', orderNumber: '', startTime: '' };
export function TimeSlotsTab() {
const [loadState, setLoadState] = useState({ kind: 'loading', message: '' });
const [scopes, setScopes] = useState([]);
const [slots, setSlots] = useState([]);
const [activeScopeId, setActiveScopeId] = useState(null);
const [slotForm, setSlotForm] = useState(EMPTY_SLOT_FORM);
const [alert, setAlert] = useState({ text: '', isError: false });
const [scopeModalOpen, setScopeModalOpen] = useState(false);
const [scopeName, setScopeName] = useState('');
const [scopeModalAlert, setScopeModalAlert] = useState({ text: '', isError: false });
const [scopeDeleting, setScopeDeleting] = useState(false);
const [slotDeleting, setSlotDeleting] = useState(null);
const formRef = useRef(null);
const scopeNameRef = useRef(null);
async function loadAll(preferredScopeId) {
setLoadState({ kind: 'loading', message: '' });
try {
const [loadedScopes, loadedSlots] = await Promise.all([
fetchScopes(),
fetchTimeSlots()
]);
setScopes(loadedScopes);
setSlots(loadedSlots);
setActiveScopeId(current => resolveActiveScopeId(
loadedScopes,
preferredScopeId !== undefined ? preferredScopeId : current
));
setLoadState({ kind: 'ready', message: '' });
} catch (error) {
setLoadState({ kind: 'error', message: error.message || 'Ошибка загрузки' });
}
}
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
if (scopeModalOpen) {
setScopeName('');
setScopeModalAlert({ text: '', isError: false });
window.setTimeout(() => scopeNameRef.current?.focus(), 0);
}
}, [scopeModalOpen]);
const activeScope = scopes.find(scope => scope.id === activeScopeId) || null;
const activeSlots = activeScopeId === null ? [] : slotsForScope(slots, activeScopeId);
const duration = slotDurationMinutes(slotForm.startTime, slotForm.endTime);
const editing = Boolean(slotForm.id);
function resetSlotForm() {
setSlotForm(EMPTY_SLOT_FORM);
setAlert({ text: '', isError: false });
}
function updateSlotField(field, value) {
setSlotForm(current => ({ ...current, [field]: value }));
}
async function submitSlot(event) {
event.preventDefault();
const payload = buildSlotPayload({
endTime: slotForm.endTime,
id: slotForm.id,
orderNumber: slotForm.orderNumber,
scopeId: activeScopeId,
startTime: slotForm.startTime
});
const validation = validateSlotPayload(payload);
if (validation) {
setAlert({ text: validation, isError: true });
return;
}
try {
await saveTimeSlot(payload);
setAlert({ text: 'Временной слот сохранён', isError: false });
resetSlotForm();
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка сохранения временного слота', isError: true });
}
}
function editSlot(slot) {
setActiveScopeId(slot.scopeId);
setSlotForm({
endTime: String(slot.endTime || '').slice(0, 5),
id: String(slot.id),
orderNumber: slot.orderNumber || '',
startTime: String(slot.startTime || '').slice(0, 5)
});
setAlert({ text: '', isError: false });
window.setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0);
}
async function confirmDeleteSlot() {
const slot = slotDeleting;
setSlotDeleting(null);
try {
await deleteTimeSlot(slot.id);
setAlert({ text: 'Временной слот удалён', isError: false });
resetSlotForm();
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка удаления временного слота', isError: true });
}
}
async function submitScope(event) {
event.preventDefault();
const name = scopeName.trim();
if (!name) {
setScopeModalAlert({ text: 'Введите название ручной сетки', isError: true });
return;
}
try {
const saved = await createScope(name);
setScopeModalOpen(false);
setAlert({ text: 'Сетка времени создана', isError: false });
await loadAll(saved.id);
} catch (error) {
setScopeModalAlert({ text: error.message || 'Ошибка создания сетки времени', isError: true });
}
}
async function confirmDeleteScope() {
const scope = activeScope;
setScopeDeleting(false);
if (!scope || isSystemScope(scope)) return;
try {
await deleteScope(scope.id);
resetSlotForm();
setAlert({ text: 'Сетка времени удалена', isError: false });
await loadAll();
} catch (error) {
setAlert({ text: error.message || 'Ошибка удаления сетки времени', isError: true });
}
}
return (
<section className="settings-section">
<div className="settings-header-row">
<div>
<h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p>
</div>
<button
type="button"
className="btn btn-md btn-secondary"
onClick={() => void loadAll()}
>
Обновить
</button>
</div>
{loadState.kind === 'error' ? (
<article className="settings-card">
<div className="loading-row">{loadState.message}</div>
</article>
) : (
<>
<div className="settings-grid">
<article className="settings-card">
<div className="card-header-row">
<h3>Сетка времени</h3>
<div className="scope-header-actions">
<button
type="button"
className="btn btn-md btn-primary"
onClick={() => setScopeModalOpen(true)}
>
Новая ручная сетка
</button>
<button
type="button"
className="btn btn-md btn-danger-subtle"
disabled={!activeScope || isSystemScope(activeScope)}
onClick={() => setScopeDeleting(true)}
>
Удалить сетку
</button>
</div>
</div>
<div className="settings-form settings-form-single">
<label className="form-field" htmlFor="time-slot-scope-select">
<span>Область действия</span>
<select
id="time-slot-scope-select"
className="native-select-control"
value={activeScopeId === null ? '' : String(activeScopeId)}
onChange={event => {
setActiveScopeId(Number(event.target.value));
resetSlotForm();
}}
>
{scopes.map(scope => (
<option key={scope.id} value={scope.id}>
{scopeOptionLabel(scope)}
</option>
))}
</select>
</label>
</div>
<div className="scope-summary">
{activeScope ? (
<>
<span className={scopeBadgeClass(activeScope)}>
{scopeBadgeLabel(activeScope)}
</span>
<span>{scopeSummaryText(activeScope, slots)}</span>
</>
) : (
<span className="muted">Сетки времени не настроены</span>
)}
</div>
</article>
<article className="settings-card settings-card-compact">
<h3>Как применяется</h3>
<div className="scope-summary">
<span className="scope-badge scope-badge-base">Базовая</span>
<span>Используется по умолчанию для всех дат.</span>
</div>
<div className="scope-summary">
<span className="scope-badge">Сб</span>
<span>Субботняя сетка применяется автоматически по субботам.</span>
</div>
<div className="scope-summary">
<span className="scope-badge">Ручная</span>
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
</div>
</article>
</div>
<article className="settings-card" ref={formRef}>
<div className="card-header-row">
<h3>{editing ? 'Редактирование слота' : 'Новый слот'}</h3>
<button type="button" className="btn btn-md btn-ghost" onClick={resetSlotForm}>
Очистить
</button>
</div>
<form className="settings-form" noValidate onSubmit={submitSlot}>
<label className="form-field" htmlFor="time-slot-order">
<span>Пара</span>
<input
type="number"
id="time-slot-order"
min="1"
step="1"
required
value={slotForm.orderNumber}
onChange={event => updateSlotField('orderNumber', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-start">
<span>Начало</span>
<input
type="time"
id="time-slot-start"
required
value={slotForm.startTime}
onChange={event => updateSlotField('startTime', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-end">
<span>Окончание</span>
<input
type="time"
id="time-slot-end"
required
value={slotForm.endTime}
onChange={event => updateSlotField('endTime', event.target.value)}
/>
</label>
<label className="form-field" htmlFor="time-slot-duration">
<span>Минут</span>
<input
type="number"
id="time-slot-duration"
min="1"
step="1"
readOnly
aria-describedby="time-slot-duration-hint"
value={duration === null ? '' : String(duration)}
/>
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" className="btn btn-md btn-primary">Сохранить слот</button>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
</form>
</article>
<article className="settings-card">
<div className="table-wrap">
<table className="settings-table">
<thead>
<tr>
<th>Пара</th>
<th>Начало</th>
<th>Окончание</th>
<th>Минут</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{loadState.kind === 'loading' ? (
<tr><td colSpan="5" className="loading-row">Загрузка...</td></tr>
) : activeSlots.length === 0 ? (
<tr><td colSpan="5" className="loading-row">В выбранной сетке нет слотов</td></tr>
) : activeSlots.map(slot => (
<tr key={slot.id}>
<td>{slot.orderNumber ?? '-'}</td>
<td>{String(slot.startTime || '').slice(0, 5)}</td>
<td>{String(slot.endTime || '').slice(0, 5)}</td>
<td>{slot.durationMinutes ?? '-'}</td>
<td className="actions-cell">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => editSlot(slot)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setSlotDeleting(slot)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
</>
)}
{scopeModalOpen && (
<div className="settings-modal-overlay open" aria-hidden="false">
<div
className="settings-modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="time-slot-scope-modal-title"
>
<div className="card-header-row">
<div>
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p className="settings-modal-subtitle">
Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.
</p>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost settings-modal-close"
aria-label="Закрыть окно"
onClick={() => setScopeModalOpen(false)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form className="settings-modal-form" noValidate onSubmit={submitScope}>
<label className="form-field" htmlFor="time-slot-scope-name">
<span>Название сетки</span>
<input
ref={scopeNameRef}
type="text"
id="time-slot-scope-name"
placeholder="Например: праздничная"
required
value={scopeName}
onChange={event => setScopeName(event.target.value)}
/>
</label>
<div className="settings-modal-actions">
<button type="submit" className="btn btn-md btn-primary">Создать сетку</button>
<button
type="button"
className="btn btn-md btn-ghost"
onClick={() => setScopeModalOpen(false)}
>
Отмена
</button>
</div>
<div
className={`form-alert${scopeModalAlert.text ? (scopeModalAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{scopeModalAlert.text}
</div>
</form>
</div>
</div>
)}
{scopeDeleting && activeScope && (
<ConfirmDialog
title="Удаление сетки времени"
message={`Удалить сетку «${activeScope.name}» вместе с её слотами и ручными назначениями?`}
confirmLabel="Удалить сетку"
danger
onClose={() => setScopeDeleting(false)}
onConfirm={() => void confirmDeleteScope()}
/>
)}
{slotDeleting && (
<ConfirmDialog
title="Удаление временного слота"
message="Удалить временной слот?"
confirmLabel="Удалить"
danger
onClose={() => setSlotDeleting(null)}
onConfirm={() => void confirmDeleteSlot()}
/>
)}
</section>
);
}