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:
466
frontend/react/settings/TimeSlotsTab.jsx
Normal file
466
frontend/react/settings/TimeSlotsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user