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:
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal file
774
frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx
Normal file
@@ -0,0 +1,774 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user