баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

View File

@@ -317,6 +317,48 @@
line-height: 1.4;
}
.dashboard-conflict-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border: 1px solid rgba(229, 231, 235, 0.1);
border-left: 4px solid var(--warning);
border-radius: var(--radius-sm);
background: rgba(245, 158, 11, 0.05);
}
.dashboard-conflict-card--error {
border-left-color: var(--error);
background: rgba(239, 68, 68, 0.05);
}
.dashboard-conflict-card__icon {
margin-top: 0.1rem;
}
.dashboard-conflict-card__body {
flex: 1;
}
.dashboard-conflict-card__body h4 {
margin: 0 0 0.25rem;
color: #d97706;
font-size: 0.95rem;
font-weight: 600;
}
.dashboard-conflict-card--error h4 {
color: var(--error);
}
.dashboard-conflict-card__body p {
margin: 0;
color: var(--text-main);
font-size: 0.85rem;
line-height: 1.4;
}
@media (max-width: 1200px) {
.dashboard-metrics-grid {
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -927,6 +969,9 @@
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.schedule-visual-context-menu[hidden] {
@@ -2014,7 +2059,7 @@
}
.calendar-week-col {
width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1));
width: 22px;
}
.calendar-semester-table thead th {
@@ -2122,6 +2167,8 @@
visibility: hidden;
transform: translateY(4px) scale(0.98);
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
right: 12px;
bottom: 12px;
}
.calendar-day-tooltip.visible {
@@ -2219,6 +2266,7 @@
}
.calendar-total-chip {
--chip-color: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.35rem;
@@ -3626,6 +3674,15 @@ tbody tr:hover {
transition: transform 0.4s ease;
}
.theme-toggle-icon-enter {
animation: theme-toggle-icon-enter 0.4s ease;
}
@keyframes theme-toggle-icon-enter {
from { opacity: 0; transform: rotate(-90deg) scale(0.5); }
to { opacity: 1; transform: rotate(0deg) scale(1); }
}
.theme-toggle:hover {
background: var(--button-secondary-bg-hover);
border-color: var(--button-secondary-border);

View File

@@ -482,3 +482,16 @@
pointer-events: auto;
}
}
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}

View File

@@ -107,4 +107,14 @@ body {
animation: admin-ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
}
.ripple-host {
position: relative;
overflow: hidden;
}

View File

@@ -5,10 +5,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- CSS Modules -->
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
@@ -16,19 +12,6 @@
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
<style>
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}
</style>
</head>
<body>

View File

@@ -1,23 +1,38 @@
let refreshPromise = null;
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
import {
clearAuthState,
fetchWithAuth,
getAccessToken,
getAuthState,
logoutSession,
refreshAccessToken,
restoreSession,
storeAuthState
} from '../../auth-session.js';
export function getToken() {
return localStorage.getItem('token');
return getAccessToken();
}
export function getSession() {
return getAuthState();
}
export async function initializeSession() {
return restoreSession();
}
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && role === 'ADMIN';
}
export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && role === expectedRole;
}
export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && roles.includes(role);
}
@@ -44,7 +59,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
const response = await fetchWithAuth(endpoint, options);
let data;
try {
@@ -54,12 +69,8 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
}
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiFetch(endpoint, method, body, false);
}
clearAuthState();
window.location.href = '/';
window.location.replace('/');
}
if (!response.ok) {
@@ -72,28 +83,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
return data;
}
export async function refreshAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin'
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.token) {
return false;
}
storeAuthState(data);
return true;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
export { clearAuthState, refreshAccessToken, storeAuthState };
const cache = new Map();
@@ -139,32 +129,8 @@ export function clearCache() {
}
export async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
console.warn('Не удалось завершить серверную сессию:', e.message);
} finally {
clearAuthState();
clearCache();
}
}
export function storeAuthState(data) {
if (data.token) localStorage.setItem('token', data.token);
if (data.role) localStorage.setItem('role', data.role);
if (data.departmentId !== undefined && data.departmentId !== null) {
localStorage.setItem('departmentId', data.departmentId);
}
if (data.userId !== undefined && data.userId !== null) {
localStorage.setItem('userId', data.userId);
}
}
export function clearAuthState() {
AUTH_KEYS.forEach(key => localStorage.removeItem(key));
await logoutSession();
clearCache();
}
export const api = {

View File

@@ -15,7 +15,7 @@ export class CustomSelect {
this.layerParent = this.originalSelect.closest('.settings-card, .card');
// Hide original but keep it accessible for form submissions and JS
this.originalSelect.style.display = 'none';
this.originalSelect.hidden = true;
// Bind methods
this.handleTriggerClick = this.handleTriggerClick.bind(this);
@@ -114,11 +114,7 @@ export class CustomSelect {
items.forEach(item => {
if (item.classList.contains('placeholder-item')) return;
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
item.hidden = !text.includes(query);
});
});
searchLi.appendChild(searchInput);
@@ -208,7 +204,7 @@ export class CustomSelect {
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (!item.classList.contains('placeholder-item')) {
item.style.display = '';
item.hidden = false;
}
});
setTimeout(() => {

View File

@@ -1,18 +1,17 @@
// OTel: загружаем только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { api, initializeSession, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js';
const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER'];
const currentRole = localStorage.getItem('role');
if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) {
window.location.href = '/';
async function bootstrap() {
const session = await initializeSession();
if (!session || !ADMIN_APP_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const currentRole = session.role;
void initializeTelemetry();
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
@@ -120,25 +119,6 @@ const ROUTES = {
},
};
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
},
EDUCATION_OFFICE: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
},
DEPARTMENT: {
defaultTab: 'department-workspace',
tabs: ['department-workspace', 'schedule-view']
},
SCHEDULE_VIEWER: {
defaultTab: 'schedule-view',
tabs: ['schedule-view']
}
};
let currentTab = null;
// DOM Elements
@@ -153,8 +133,8 @@ const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
const roleCapabilities = capabilitiesForRole(currentRole);
const allowedTabs = new Set(roleCapabilities.adminTabs);
// Setup Global Effects
applyRippleEffect();
@@ -258,8 +238,8 @@ async function switchTab(tab) {
window.location.hash = tab;
}
} catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e);
renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку');
console.error('Не удалось загрузить вкладку');
}
// Close mobile menu if open
@@ -274,7 +254,7 @@ function configureRoleInterface() {
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
if (settingsLink) {
settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole);
settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole);
}
const firstAllowedTitle = {
@@ -297,7 +277,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
console.warn('Не удалось загрузить счётчик заявок');
}
}
@@ -323,7 +303,7 @@ document.addEventListener('teacherRequestsChanged', (event) => {
});
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
@@ -331,3 +311,6 @@ window.addEventListener('hashchange', () => {
switchTab(hashTab);
}
});
}
await bootstrap();

View File

@@ -1,47 +0,0 @@
import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0';
import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0';
import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1';
import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0';
import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1';
import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0';
import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0';
import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0';
// Инициализация провайдера метрик и трейсов с именем сервиса
const provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin',
}),
});
// Экспортер отправляет данные на относительный путь /otel/v1/traces.
// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318).
const traceExporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces',
});
// Использование BatchSpanProcessor для буферизации трейсов перед отправкой
provider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc)
provider.register({
contextManager: new ZoneContextManager(),
});
// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc)
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-fetch': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
'@opentelemetry/instrumentation-xml-http-request': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
}),
],
});
console.log('OpenTelemetry Web SDK initialized successfully.');

View File

@@ -0,0 +1,46 @@
const capabilities = {
ADMIN: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
defaultSettingsTab: 'general',
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
},
EDUCATION_OFFICE: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'],
defaultSettingsTab: 'time-slots',
settingsTabs: ['time-slots', 'edu-forms']
},
DEPARTMENT: {
defaultAdminTab: 'department-workspace',
adminTabs: ['department-workspace', 'schedule-view'],
defaultSettingsTab: null,
settingsTabs: []
},
SCHEDULE_VIEWER: {
defaultAdminTab: 'schedule-view',
adminTabs: ['schedule-view'],
defaultSettingsTab: null,
settingsTabs: []
}
};
export const ROLE_CAPABILITIES = Object.freeze(
Object.fromEntries(Object.entries(capabilities).map(([role, value]) => [
role,
Object.freeze({
...value,
adminTabs: Object.freeze([...value.adminTabs]),
settingsTabs: Object.freeze([...value.settingsTabs])
})
]))
);
export const ADMIN_APP_ROLES = Object.freeze(Object.keys(ROLE_CAPABILITIES));
export const SETTINGS_ROLES = Object.freeze(
ADMIN_APP_ROLES.filter(role => ROLE_CAPABILITIES[role].settingsTabs.length > 0)
);
export function capabilitiesForRole(role) {
return ROLE_CAPABILITIES[role] || null;
}

View File

@@ -25,24 +25,44 @@ export function hideAlert(elementId) {
el.textContent = '';
}
/** Форматирует календарную дату по локальным компонентам без UTC-сдвига. */
export function formatLocalDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new TypeError('Ожидалась корректная дата');
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function renderStatusMessage(container, message, className = 'form-alert error') {
if (!container) return;
const element = document.createElement('div');
element.className = className;
element.textContent = message;
container.replaceChildren(element);
}
export function renderTableMessage(tbody, columnCount, message) {
if (!tbody) return;
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = columnCount;
cell.className = 'loading-row';
cell.textContent = message;
row.appendChild(cell);
tbody.replaceChildren(row);
}
export function applyRippleEffect() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
if (!btn) return;
const rect = btn.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
if (getComputedStyle(btn).position === 'static') {
btn.style.position = 'relative';
}
btn.style.overflow = 'hidden';
btn.classList.add('ripple-host');
btn.appendChild(ripple);

View File

@@ -801,7 +801,7 @@ export async function initAcademicCalendar() {
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return `
<div class="calendar-semester-table-wrap">
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<table class="calendar-semester-table">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
@@ -842,7 +842,6 @@ export async function initAcademicCalendar() {
const dayName = shortDayLabel(row.dayOfWeek);
const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т';
const color = activity.colorCode || '#64748b';
return `
<td class="calendar-day"
data-course="${row.courseNumber}"
@@ -853,8 +852,7 @@ export async function initAcademicCalendar() {
data-time-scope-id="${timeAssignmentForDate(row.date)?.scopeId || ''}"
role="button"
tabindex="0"
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}"
style="--activity-color:${escapeHtml(color)}">
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}">
<strong class="calendar-day-code">${escapeHtml(code)}</strong>
${timeAssignmentForDate(row.date) ? '<span class="calendar-day-time-marker">вр.</span>' : ''}
<select class="calendar-day-activity custom-select-initialized" aria-hidden="true" tabindex="-1">${options(activityTypes.map(toActivityOption), selected)}</select>
@@ -921,7 +919,7 @@ export async function initAcademicCalendar() {
return;
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)}</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
@@ -938,7 +936,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) {
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:#64748b">...</span>
<span class="calendar-day-dialog-code">...</span>
<div>
<strong>Выбрано ячеек: ${days.length}</strong>
<p>Выберите код активности, чтобы применить его ко всему выделению</p>
@@ -948,7 +946,7 @@ export async function initAcademicCalendar() {
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)} · ${days.length} ячеек</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
@@ -1041,8 +1039,6 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value);
const code = activity?.code || '-';
const color = activity?.colorCode || '#64748b';
day.style.setProperty('--activity-color', color);
day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
}
@@ -1053,11 +1049,8 @@ export async function initAcademicCalendar() {
return;
}
const tooltip = ensureCalendarDayTooltip();
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b');
tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
@@ -1076,14 +1069,13 @@ export async function initAcademicCalendar() {
function renderCalendarDayTooltip(day) {
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-';
const color = activity?.colorCode || '#64748b';
const timeAssignment = timeAssignmentForDate(day.dataset.date);
const timeLabel = timeAssignment
? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
return `
<div class="calendar-day-tooltip-head">
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
<span class="calendar-day-tooltip-code"><span>${escapeHtml(code)}</span></span>
<div>
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
@@ -1106,24 +1098,6 @@ export async function initAcademicCalendar() {
`;
}
function positionCalendarDayTooltip(tooltip, day, event) {
const offset = 14;
const rect = day.getBoundingClientRect();
const x = event?.clientX ?? rect.left + rect.width / 2;
const y = event?.clientY ?? rect.top;
const tooltipRect = tooltip.getBoundingClientRect();
let left = x + offset;
let top = y + offset;
if (left + tooltipRect.width > window.innerWidth - 12) {
left = x - tooltipRect.width - offset;
}
if (top + tooltipRect.height > window.innerHeight - 12) {
top = y - tooltipRect.height - offset;
}
tooltip.style.left = `${Math.max(12, left)}px`;
tooltip.style.top = `${Math.max(12, top)}px`;
}
function startCalendarDayDragSelection(event, day) {
if (event.button !== 0) return;
hideCalendarDayTooltip();
@@ -1246,10 +1220,9 @@ export async function initAcademicCalendar() {
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
const activity = activityTypes.find(item => item.code === code);
const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}${suffix}</span>`;
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`;
}).join('');
}

View File

@@ -5,7 +5,7 @@ async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error("Failed to fetch equipments", e);
console.error('Не удалось загрузить список оборудования');
return [];
}
}

View File

@@ -325,18 +325,16 @@ export async function initDashboard() {
const conflictCards = conflicts.map(c => {
const isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
<div style="flex: 1;">
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
<div class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
<div class="dashboard-conflict-card__body">
<h4>${escapeHtml(c.title)}</h4>
<p>${escapeHtml(c.description)}</p>
</div>
</div>
`;

View File

@@ -1,11 +1,12 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export async function initDepartmentWorkspace() {
const role = localStorage.getItem('role');
const userDepartmentId = localStorage.getItem('departmentId');
const session = getSession();
const role = session?.role;
const userDepartmentId = session?.departmentId;
const departmentField = document.getElementById('department-workspace-department-field');
const departmentSelect = document.getElementById('department-workspace-department');
const startInput = document.getElementById('department-workspace-start');
@@ -493,14 +494,10 @@ function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date();
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
startInput.value = toIsoDate(today);
endInput.value = toIsoDate(nextWeek);
}
function toIsoDate(date) {
return date.toISOString().slice(0, 10);
startInput.value = formatLocalDate(today);
endInput.value = formatLocalDate(nextWeek);
}
function formatDateTime(value) {

View File

@@ -5,7 +5,7 @@ async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error("Failed to fetch education forms", e);
console.error('Не удалось загрузить формы обучения');
return [];
}
}
@@ -182,7 +182,7 @@ export async function initGroups() {
try {
subgroups = await api.get('/api/subgroups');
} catch (error) {
console.error("Failed to load subgroups", error);
console.error('Не удалось загрузить подгруппы');
}
}
@@ -560,7 +560,7 @@ export async function initGroups() {
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.style.display = 'none';
btnCancelSubgroupEdit.hidden = true;
hideAlert('manage-subgroup-alert');
}

View File

@@ -1,4 +1,4 @@
import { api } from '../api.js';
import { api, getSession } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
@@ -38,8 +38,9 @@ const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
export async function initScheduleView() {
const role = localStorage.getItem('role');
const userDepartmentId = normalizeId(localStorage.getItem('departmentId'));
const session = getSession();
const role = session?.role;
const userDepartmentId = normalizeId(session?.departmentId);
const dateInput = document.getElementById('schedule-view-date');
const targetSelect = document.getElementById('schedule-view-target');
const loadButton = document.getElementById('schedule-view-load');

View File

@@ -658,13 +658,7 @@ export async function initSchedule() {
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
@@ -672,8 +666,6 @@ export async function initSchedule() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
async function handleVisualContextMenuClick(event) {

View File

@@ -1,5 +1,5 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
@@ -23,7 +23,7 @@ export async function initTeacherRequests() {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
updateCount(0);
}
}
@@ -44,10 +44,10 @@ export async function initTeacherRequests() {
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50" autocomplete="username"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td><input type="password" class="teacher-request-password" placeholder="Минимум 8 символов" autocomplete="new-password"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
@@ -131,6 +131,6 @@ export async function initTeacherRequests() {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
}
}

View File

@@ -335,7 +335,7 @@ export async function initUniversityStructure() {
profileIdInput.value = '';
profileFormTitle.textContent = 'Создание профиля';
btnSaveProfile.textContent = 'Создать';
btnCancelProfileEdit.style.display = 'none';
btnCancelProfileEdit.hidden = true;
hideAlert('manage-profile-alert');
}
@@ -384,7 +384,7 @@ export async function initUniversityStructure() {
profileDescInput.value = profile.description || '';
profileFormTitle.textContent = 'Редактирование профиля';
btnSaveProfile.textContent = 'Сохранить';
btnCancelProfileEdit.style.display = 'inline-block';
btnCancelProfileEdit.hidden = false;
hideAlert('manage-profile-alert');
return;
}
@@ -451,7 +451,7 @@ export async function initUniversityStructure() {
tabProfileSpecSelect.disabled = false;
mainProfileFormTitle.textContent = 'Создание профиля';
btnTabSaveProfile.textContent = 'Создать';
btnTabCancelProfileEdit.style.display = 'none';
btnTabCancelProfileEdit.hidden = true;
hideAlert('tab-profile-alert');
tabProfileSpecSelect.dispatchEvent(new Event('change'));
}
@@ -507,7 +507,7 @@ export async function initUniversityStructure() {
mainProfileFormTitle.textContent = 'Редактирование профиля';
btnTabSaveProfile.textContent = 'Сохранить';
btnTabCancelProfileEdit.style.display = 'inline-block';
btnTabCancelProfileEdit.hidden = false;
hideAlert('tab-profile-alert');
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });

View File

@@ -5,10 +5,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройки — Magistr</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../css/components.css">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">

View File

@@ -1,13 +1,17 @@
// Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
import { renderStatusMessage } from '../../js/utils.js';
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
import { getToken } from '../../js/api.js';
import { initializeSession } from '../../js/api.js';
// Проверка авторизации
const role = localStorage.getItem('role');
if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/';
async function bootstrap() {
const session = await initializeSession();
if (!session || !SETTINGS_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const role = session.role;
// Глобальная инициализация кастомных списков
document.addEventListener('DOMContentLoaded', () => {
@@ -44,12 +48,9 @@ const ROUTES = {
},
};
const ROLE_TABS = {
ADMIN: ['general', 'time-slots', 'database', 'edu-forms'],
EDUCATION_OFFICE: ['time-slots', 'edu-forms']
};
const allowedTabs = new Set(ROLE_TABS[role] || []);
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
const roleCapabilities = capabilitiesForRole(role);
const allowedTabs = new Set(roleCapabilities.settingsTabs);
const defaultTab = roleCapabilities.defaultSettingsTab;
let currentTab = null;
@@ -129,8 +130,8 @@ async function switchTab(tab) {
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div style="padding:1rem;color:var(--error);">Ошибка загрузки: ${e.message}</div>`;
console.error(e);
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
console.error('Не удалось загрузить раздел настроек');
}
// Закрываем мобильное меню после перехода
@@ -140,3 +141,6 @@ async function switchTab(tab) {
// Вкладка по умолчанию
switchTab(defaultTab);
}
await bootstrap();

View File

@@ -1,5 +1,5 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
@@ -12,8 +12,8 @@ export async function initDatabase() {
try {
const data = await api.get('/api/database/status');
const statusBadge = data.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
statusInfo.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
@@ -36,7 +36,7 @@ export async function initDatabase() {
</div>
`;
} catch (e) {
statusInfo.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки статуса: ${e.message}</div>`;
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
}
}
@@ -46,7 +46,7 @@ export async function initDatabase() {
const tenants = await api.get('/api/database/tenants');
renderTenantsTable(tenants);
} catch (e) {
tenantsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${e.message}</td></tr>`;
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
}
}
@@ -58,8 +58,8 @@ export async function initDatabase() {
tenantsTbody.innerHTML = tenants.map(t => {
const statusBadge = t.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
return `
<tr>
@@ -96,7 +96,7 @@ export async function initDatabase() {
showAlert('add-tenant-alert', `${result.message}`, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
} finally {
btnTest.textContent = 'Тест';
btnTest.disabled = false;
@@ -122,7 +122,7 @@ export async function initDatabase() {
try {
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
if (result.success) {
showAlert('add-tenant-alert', `Тенант "${escapeHtml(domain)}" добавлен!`, 'success');
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
addTenantForm.reset();
loadTenants();
loadStatus();
@@ -130,7 +130,7 @@ export async function initDatabase() {
showAlert('add-tenant-alert', result.message, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
}
});
@@ -147,7 +147,7 @@ export async function initDatabase() {
loadTenants();
loadStatus();
} catch (e) {
alert(`Ошибка: ${e.message}`);
alert('Не удалось удалить тенант');
}
});

View File

@@ -8,7 +8,7 @@ export async function fetchEducationForms() {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error("Failed to fetch education forms", e);
console.error('Не удалось загрузить формы обучения');
return [];
}
}

View File

@@ -62,8 +62,8 @@ export async function initTimeSlotsSettings() {
form?.addEventListener('submit', saveSlot);
resetButton?.addEventListener('click', resetForm);
refreshButton?.addEventListener('click', loadAll);
startInput?.addEventListener('change', fillDurationIfEmpty);
endInput?.addEventListener('change', fillDurationIfEmpty);
startInput?.addEventListener('change', calculateDuration);
endInput?.addEventListener('change', calculateDuration);
tbody?.addEventListener('click', handleSlotTableClick);
await loadAll();
@@ -206,8 +206,7 @@ export async function initTimeSlotsSettings() {
orderNumber: Number(orderInput.value),
scopeId: activeScopeId,
startTime: startInput.value,
endTime: endInput.value,
durationMinutes: durationInput.value ? Number(durationInput.value) : null
endTime: endInput.value
};
try {
@@ -272,8 +271,9 @@ export async function initTimeSlotsSettings() {
}
}
function fillDurationIfEmpty() {
if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return;
function calculateDuration() {
durationInput.value = '';
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
const [startHour, startMinute] = startInput.value.split(':').map(Number);
const [endHour, endMinute] = endInput.value.split(':').map(Number);
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));

View File

@@ -55,11 +55,11 @@
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group">
<label for="tenant-username">Пользователь</label>
<input type="text" id="tenant-username" placeholder="postgres" required>
<input type="text" id="tenant-username" placeholder="postgres" autocomplete="username" required>
</div>
<div class="form-group">
<label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" required>
<input type="password" id="tenant-password" placeholder="••••••••" autocomplete="new-password" required>
</div>
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
Тест

View File

@@ -70,7 +70,9 @@
<label class="form-field" for="time-slot-duration">
<span>Минут</span>
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
<input type="number" id="time-slot-duration" min="1" step="1" readonly
aria-describedby="time-slot-duration-hint">
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>

View File

@@ -5,11 +5,11 @@
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="username" required>
<input type="text" id="new-username" placeholder="Имя пользователя" autocomplete="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="Минимум 8 символов" required>
<input type="password" id="new-password" placeholder="Минимум 8 символов" autocomplete="new-password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>