Files
magistr/frontend/react/admin/tabs/DepartmentWorkspaceTab.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

775 lines
37 KiB
JavaScript

import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { addDays, toIsoDate } from '../../shared/dates.js';
import { requestCountLabel } from '../admin-service.js';
import {
DEPARTMENT_REQUEST_TABLE_COLUMNS,
SUBJECT_TABLE_COLUMNS,
assignTeacher,
assignTeacherError,
availableTeachers,
defaultWorkloadPeriod,
departmentOptionLabel,
fetchAllTeachers,
fetchDepartmentSubjects,
fetchDepartmentTeacherRequests,
fetchDepartmentTeachers,
fetchDepartments,
fetchSemesters,
fetchSubjectComments,
fetchTeacherWorkload,
formatDateTime,
importSubjects,
importSubjectsPayload,
requestStatusClass,
requestStatusLabel,
saveSubjectComment,
subjectCountLabel,
submitTeacherRequest,
teacherMetrics,
teacherOptionLabel,
teacherRequestPayload,
workloadLabel
} from '../department-workspace-service.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export function DepartmentWorkspaceTab() {
const [session] = useState(() => getSession());
const role = session?.role;
const userDepartmentId = session?.departmentId;
const isDepartmentRole = role === ROLE_DEPARTMENT;
const departmentIdRef = useRef('');
const startDateRef = useRef(toIsoDate(new Date()));
const endDateRef = useRef(toIsoDate(addDays(new Date(), 7)));
const [departments, setDepartments] = useState([]);
const [departmentId, setDepartmentId] = useState('');
const [startDate, setStartDate] = useState(startDateRef.current);
const [endDate, setEndDate] = useState(endDateRef.current);
const [workspaceAlert, setWorkspaceAlert] = useState({ text: '', isError: false });
const [subjects, setSubjects] = useState({ loading: true, items: [], error: '' });
const [teachers, setTeachers] = useState({ items: null, error: '' });
const [workload, setWorkload] = useState({ items: null, error: '' });
const [requests, setRequests] = useState({ items: null, error: '' });
const [allTeachers, setAllTeachers] = useState([]);
const [allTeachersError, setAllTeachersError] = useState('');
const [importRows, setImportRows] = useState([{ code: '', name: '' }]);
const [importAlert, setImportAlert] = useState({ text: '', isError: false });
const [assignTeacherId, setAssignTeacherId] = useState('');
const [assignComment, setAssignComment] = useState('');
const [assignAlert, setAssignAlert] = useState({ text: '', isError: false });
const [requestForm, setRequestForm] = useState({ username: '', fullName: '', jobTitle: '', comment: '' });
const [requestAlert, setRequestAlert] = useState({ text: '', isError: false });
const [comments, setComments] = useState(null);
const [commentText, setCommentText] = useState('');
const [commentAlert, setCommentAlert] = useState({ text: '', isError: false });
function currentDepartmentId() {
return isDepartmentRole ? userDepartmentId : departmentIdRef.current;
}
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchDepartments();
if (!active) return;
const list = loaded || [];
setDepartments(list);
if (isDepartmentRole) {
departmentIdRef.current = userDepartmentId || '';
setDepartmentId(departmentIdRef.current);
} else if (list.length) {
departmentIdRef.current = String(list[0].id);
setDepartmentId(departmentIdRef.current);
}
try {
const semesters = await fetchSemesters();
const period = defaultWorkloadPeriod(semesters, new Date());
startDateRef.current = period.startDate;
endDateRef.current = period.endDate;
if (active) {
setStartDate(period.startDate);
setEndDate(period.endDate);
}
} catch {
// Текущий диапазон остаётся безопасным fallback, если справочник
// семестров недоступен.
}
try {
const loadedTeachers = await fetchAllTeachers();
if (active) setAllTeachers(Array.isArray(loadedTeachers) ? loadedTeachers : []);
} catch (error) {
if (active) setAllTeachersError(error.message || 'Ошибка загрузки преподавателей');
}
if (!active) return;
await loadWorkspace();
} catch (error) {
if (!active) return;
setWorkspaceAlert({ text: error.message || 'Ошибка загрузки кабинета кафедры', isError: true });
}
})();
return () => {
active = false;
};
}, []);
async function loadWorkspace() {
setWorkspaceAlert({ text: '', isError: false });
setTeachers({ items: null, error: '' });
setWorkload({ items: null, error: '' });
setRequests({ items: null, error: '' });
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
setSubjects({ loading: true, items: [], error: '' });
try {
const items = await fetchDepartmentSubjects(currentDepartmentId());
setSubjects({ loading: false, items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setSubjects({ loading: false, items: [], error: error.message || 'Ошибка загрузки дисциплин' });
}
}
async function loadTeachers() {
setTeachers({ items: null, error: '' });
try {
const items = await fetchDepartmentTeachers(currentDepartmentId());
setTeachers({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setTeachers({ items: [], error: error.message || 'Ошибка загрузки преподавателей' });
}
}
async function loadWorkload() {
if (!startDateRef.current || !endDateRef.current) {
setWorkload({ items: [], error: 'Выберите начало и окончание периода' });
return;
}
setWorkload({ items: null, error: '' });
try {
const items = await fetchTeacherWorkload({
startDate: startDateRef.current,
endDate: endDateRef.current,
departmentId: currentDepartmentId()
});
setWorkload({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setWorkload({ items: [], error: error.message || 'Ошибка расчёта нагрузки' });
}
}
async function loadTeacherRequests() {
setRequests({ items: null, error: '' });
try {
const items = await fetchDepartmentTeacherRequests(currentDepartmentId());
setRequests({ items: Array.isArray(items) ? items : [], error: '' });
} catch (error) {
setRequests({ items: [], error: error.message || 'Ошибка загрузки заявок' });
}
}
function handleDepartmentChange(value) {
departmentIdRef.current = value;
setDepartmentId(value);
void loadWorkspace();
}
function handleStartDateChange(value) {
startDateRef.current = value;
setStartDate(value);
if (value && endDateRef.current && value > endDateRef.current) {
endDateRef.current = '';
setEndDate('');
}
void loadWorkload();
}
function handleEndDateChange(value) {
endDateRef.current = value;
setEndDate(value);
void loadWorkload();
}
// --- Загрузка дисциплин ---
function addImportRow() {
setImportRows(current => [...current, { code: '', name: '' }]);
}
function updateImportRow(index, patch) {
setImportRows(current => current.map((row, rowIndex) => (
rowIndex === index ? { ...row, ...patch } : row
)));
}
function removeImportRow(index) {
setImportRows(current => {
const rows = current.filter((_, rowIndex) => rowIndex !== index);
return rows.length ? rows : [{ code: '', name: '' }];
});
}
async function submitImport(event) {
event.preventDefault();
setImportAlert({ text: '', isError: false });
const rows = importRows.map(row => ({
code: (row.code || '').trim(),
name: (row.name || '').trim()
}));
const { payload, error } = importSubjectsPayload(rows, currentDepartmentId());
if (error) {
setImportAlert({ text: error, isError: true });
return;
}
try {
const result = await importSubjects(payload);
setImportAlert({ text: result?.message || 'Дисциплины загружены', isError: false });
setImportRows([{ code: '', name: '' }]);
await loadSubjects();
} catch (err) {
setImportAlert({ text: err.message || 'Ошибка загрузки дисциплин', isError: true });
}
}
// --- Привязка преподавателя ---
async function submitAssign(event) {
event.preventDefault();
setAssignAlert({ text: '', isError: false });
const error = assignTeacherError(currentDepartmentId(), assignTeacherId);
if (error) {
setAssignAlert({ text: error, isError: true });
return;
}
try {
await assignTeacher(assignTeacherId, currentDepartmentId(), assignComment.trim());
setAssignAlert({ text: 'Преподаватель привязан к кафедре', isError: false });
setAssignComment('');
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (err) {
setAssignAlert({ text: err.message || 'Ошибка привязки преподавателя', isError: true });
}
}
// --- Заявка на преподавателя ---
async function submitRequest(event) {
event.preventDefault();
setRequestAlert({ text: '', isError: false });
const { payload, error } = teacherRequestPayload(requestForm, currentDepartmentId());
if (error) {
setRequestAlert({ text: error, isError: true });
return;
}
try {
await submitTeacherRequest(payload);
setRequestAlert({ text: 'Заявка отправлена администратору', isError: false });
setRequestForm({ username: '', fullName: '', jobTitle: '', comment: '' });
await loadTeacherRequests();
} catch (err) {
setRequestAlert({ text: err.message || 'Ошибка отправки заявки', isError: true });
}
}
// --- Комментарии к дисциплинам ---
async function openComments(subjectId, subjectName) {
setComments({ subjectId, subjectName, items: null, error: '' });
setCommentText('');
setCommentAlert({ text: '', isError: false });
try {
const items = await fetchSubjectComments(subjectId);
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: Array.isArray(items) ? items : [], error: '' }
: current
));
} catch (error) {
setComments(current => (
current && current.subjectId === subjectId
? { ...current, items: [], error: error.message || 'Ошибка загрузки комментариев' }
: current
));
}
}
async function submitComment(event) {
event.preventDefault();
const subjectId = comments?.subjectId;
const comment = commentText.trim();
if (!subjectId) {
setCommentAlert({ text: 'Выберите дисциплину', isError: true });
return;
}
if (!comment) {
setCommentAlert({ text: 'Комментарий не должен быть пустым', isError: true });
return;
}
try {
await saveSubjectComment(subjectId, comment);
setCommentAlert({ text: 'Комментарий сохранён', isError: false });
await openComments(subjectId, comments.subjectName);
} catch (error) {
setCommentAlert({ text: error.message || 'Ошибка сохранения комментария', isError: true });
}
}
const metricRows = teachers.items === null || workload.items === null
? null
: teacherMetrics(teachers.items, workload.items);
const freeTeachers = teachers.items === null
? null
: availableTeachers(allTeachers, teachers.items);
const requestsCountLabel = requests.error
? '0 заявок'
: (requests.items === null ? 'Загрузка...' : requestCountLabel(requests.items.length));
const subjectsCountLabel = subjects.error
? '0 дисциплин'
: (subjects.loading ? 'Загрузка...' : subjectCountLabel(subjects.items.length));
return (
<>
<div className="card department-workspace-control-card">
<div className="card-header-row">
<h2>Рабочая область кафедры</h2>
<button
type="button"
className="btn btn-md btn-secondary"
id="department-workspace-refresh"
onClick={() => void loadWorkspace()}
>
Обновить
</button>
</div>
<div className="department-workspace-filter-grid">
{!isDepartmentRole && (
<div className="form-group" id="department-workspace-department-field">
<label htmlFor="department-workspace-department">Кафедра</label>
<select
id="department-workspace-department"
value={departmentId}
onChange={event => handleDepartmentChange(event.target.value)}
>
<option value="">Выберите кафедру</option>
{departments.map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label htmlFor="department-workspace-start">Начало периода</label>
<input
type="date"
id="department-workspace-start"
value={startDate}
onChange={event => handleStartDateChange(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="department-workspace-end">Окончание периода</label>
<input
type="date"
id="department-workspace-end"
value={endDate}
onChange={event => handleEndDateChange(event.target.value)}
/>
</div>
</div>
<div
className={`form-alert${workspaceAlert.text ? (workspaceAlert.isError ? ' error' : ' success') : ''}`}
id="department-workspace-alert"
role="alert"
>
{workspaceAlert.text}
</div>
</div>
<div className="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div className="card create-card department-subject-import-card">
<div className="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" className="stack-form" onSubmit={submitImport}>
<div className="subject-import-slots-panel">
<div className="card-header-row">
<label className="schedule-section-label schedule-section-label--flush">
Дисциплины для загрузки
</label>
<button
type="button"
className="btn btn-sm btn-secondary"
id="subject-import-add"
onClick={addImportRow}
>
Добавить строку
</button>
</div>
<div id="subject-import-rows" className="subject-import-rows-list">
{importRows.map((row, index) => (
<div className="subject-import-row" key={index}>
<div className="form-group">
<label>Код</label>
<input
type="text"
className="subject-import-code"
placeholder="DB201"
value={row.code}
onChange={event => updateImportRow(index, { code: event.target.value })}
/>
</div>
<div className="form-group">
<label>Название</label>
<input
type="text"
className="subject-import-name"
placeholder="Базы данных"
value={row.name}
onChange={event => updateImportRow(index, { name: event.target.value })}
/>
</div>
<button
type="button"
className="btn btn-sm btn-danger subject-import-remove"
onClick={() => removeImportRow(index)}
>
Удалить
</button>
</div>
))}
</div>
</div>
<button type="submit" className="btn btn-md btn-secondary">Загрузить</button>
<div
className={`form-alert${importAlert.text ? (importAlert.isError ? ' error' : ' success') : ''}`}
id="department-subject-import-alert"
role="alert"
>
{importAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-assign-card">
<div className="card-header-row">
<h2>Добавить преподавателя</h2>
</div>
<form id="department-teacher-assign-form" className="stack-form" onSubmit={submitAssign}>
<div className="form-group">
<label htmlFor="department-teacher-select">Преподаватель</label>
<select
id="department-teacher-select"
value={assignTeacherId}
onChange={event => setAssignTeacherId(event.target.value)}
>
{freeTeachers === null ? (
<option value="">Загрузка...</option>
) : allTeachersError ? (
<option value="">{allTeachersError}</option>
) : freeTeachers.length === 0 ? (
<option value="">Нет доступных преподавателей</option>
) : (
<>
<option value="">Выберите преподавателя</option>
{freeTeachers.map(teacher => (
<option key={teacher.id} value={teacher.id}>
{teacherOptionLabel(teacher)}
</option>
))}
</>
)}
</select>
</div>
<div className="form-group">
<label htmlFor="department-teacher-assign-comment">Комментарий</label>
<input
type="text"
id="department-teacher-assign-comment"
placeholder="Например, ведет лабораторные работы"
value={assignComment}
onChange={event => setAssignComment(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-secondary">Привязать к кафедре</button>
<div
className={`form-alert${assignAlert.text ? (assignAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-assign-alert"
role="alert"
>
{assignAlert.text}
</div>
</form>
</div>
<div className="card create-card department-teacher-request-card">
<div className="card-header-row">
<h2>Заявка на преподавателя</h2>
</div>
<form id="department-teacher-request-form" className="stack-form" onSubmit={submitRequest}>
<div className="form-group">
<label htmlFor="teacher-request-username">Логин</label>
<input
type="text"
id="teacher-request-username"
placeholder="ivanov_teacher"
maxLength={50}
required
value={requestForm.username}
onChange={event => setRequestForm(current => (
{ ...current, username: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-fullname">ФИО</label>
<input
type="text"
id="teacher-request-fullname"
placeholder="Иванов Иван Иванович"
maxLength={255}
required
value={requestForm.fullName}
onChange={event => setRequestForm(current => (
{ ...current, fullName: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-jobtitle">Должность</label>
<input
type="text"
id="teacher-request-jobtitle"
placeholder="Доцент"
maxLength={255}
value={requestForm.jobTitle}
onChange={event => setRequestForm(current => (
{ ...current, jobTitle: event.target.value }
))}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-request-comment">Комментарий</label>
<input
type="text"
id="teacher-request-comment"
placeholder="Что важно проверить администратору"
value={requestForm.comment}
onChange={event => setRequestForm(current => (
{ ...current, comment: event.target.value }
))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Отправить заявку</button>
<div
className={`form-alert${requestAlert.text ? (requestAlert.isError ? ' error' : ' success') : ''}`}
id="department-teacher-request-alert"
role="alert"
>
{requestAlert.text}
</div>
</form>
</div>
<div className="card department-teachers-card">
<div className="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div className="metric-grid" id="department-teachers-list">
{metricRows === null ? (
'Загрузка...'
) : metricRows.length === 0 ? (
<div className="loading-row">
{teachers.error || 'Преподаватели не найдены'}
</div>
) : metricRows.map((row, index) => (
<article className="metric-card" key={index}>
<strong>{row.name || '-'}</strong>
<span>{row.meta || 'Должность не указана'}</span>
<small>{workloadLabel(row.workload, workload.error)}</small>
</article>
))}
</div>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Заявки кафедры</h2>
<span className="muted" id="department-teacher-request-count">{requestsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Статус</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Комментарий</th>
<th>Рассмотрение</th>
</tr>
</thead>
<tbody id="department-teacher-requests-tbody">
{requests.items === null && !requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : requests.error ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
{requests.error}
</td>
</tr>
) : requests.items.length === 0 ? (
<tr>
<td colSpan={DEPARTMENT_REQUEST_TABLE_COLUMNS} className="loading-row">
Заявок пока нет
</td>
</tr>
) : requests.items.map(request => (
<tr key={request.id}>
<td>
<span className={`badge ${requestStatusClass(request.status)}`}>
{requestStatusLabel(request.status)}
</span>
</td>
<td>{request.username || '-'}</td>
<td>{request.fullName || '-'}</td>
<td>{request.jobTitle || '-'}</td>
<td>{request.comment || '-'}</td>
<td>{request.reviewComment || formatDateTime(request.reviewedAt) || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card department-workspace-table-card">
<div className="card-header-row">
<h2>Дисциплины кафедры</h2>
<span className="muted" id="department-subject-count">{subjectsCountLabel}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
{subjects.loading ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Загрузка...
</td>
</tr>
) : subjects.error ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
{subjects.error}
</td>
</tr>
) : subjects.items.length === 0 ? (
<tr>
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">
Дисциплины не найдены
</td>
</tr>
) : subjects.items.map(subject => (
<tr key={subject.id}>
<td>{subject.code || '-'}</td>
<td>{subject.name || '-'}</td>
<td>{subject.description || '-'}</td>
<td>
<button
type="button"
className="btn btn-sm btn-secondary department-comments-open"
onClick={() => void openComments(subject.id, subject.name || '')}
>
Открыть
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{comments && (
<div className="card department-workspace-table-card" id="department-comments-card">
<div className="card-header-row">
<h2 id="department-comments-title">
Комментарии: {comments.subjectName || 'дисциплина'}
</h2>
<button
type="button"
className="btn btn-sm btn-ghost"
id="department-comments-close"
onClick={() => setComments(null)}
>
Скрыть
</button>
</div>
<form id="department-comment-form" className="form-row" onSubmit={submitComment}>
<div className="form-group">
<label htmlFor="department-comment-text">Новый комментарий</label>
<input
type="text"
id="department-comment-text"
placeholder="Добавьте примечание по дисциплине"
value={commentText}
onChange={event => setCommentText(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</form>
<div
className={`form-alert${commentAlert.text ? (commentAlert.isError ? ' error' : ' success') : ''}`}
id="department-comment-alert"
role="alert"
>
{commentAlert.text}
</div>
<div className="department-comment-list" id="department-comment-list">
{comments.items === null ? (
'Загрузка...'
) : comments.error ? (
<div className="loading-row">{comments.error}</div>
) : comments.items.length === 0 ? (
<div className="loading-row">Комментариев пока нет</div>
) : comments.items.map((comment, index) => (
<article className="department-comment-item" key={index}>
<strong>{comment.authorName || 'Автор не указан'}</strong>
<span>{formatDateTime(comment.createdAt)}</span>
<p>{comment.comment}</p>
</article>
))}
</div>
</div>
)}
</>
);
}