Files
magistr/frontend/react/settings/TimeSlotsTab.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

467 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}