Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
467 lines
22 KiB
JavaScript
467 lines
22 KiB
JavaScript
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>
|
||
);
|
||
}
|