Много разных изменений
This commit is contained in:
@@ -630,7 +630,7 @@
|
||||
}
|
||||
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -689,6 +689,58 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.schedule-initial-state {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schedule-initial-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.schedule-initial-state[hidden],
|
||||
.schedule-initial-content.is-loading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-initial-content .card,
|
||||
#schedule-table tbody tr {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
#schedule-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
#schedule-table .schedule-rule-col-id { width: 5%; }
|
||||
#schedule-table .schedule-rule-col-subject { width: 16%; }
|
||||
#schedule-table .schedule-rule-col-semester { width: 13%; }
|
||||
#schedule-table .schedule-rule-col-groups { width: 16%; }
|
||||
#schedule-table .schedule-rule-col-load { width: 14%; }
|
||||
#schedule-table .schedule-rule-col-slots { width: 20%; }
|
||||
#schedule-table .schedule-rule-col-actions { width: 16%; }
|
||||
|
||||
#schedule-table th,
|
||||
#schedule-table td {
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#schedule-table thead th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-group-select {
|
||||
max-width: 520px;
|
||||
}
|
||||
@@ -703,6 +755,23 @@
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.schedule-group-search {
|
||||
width: calc(100% - 1rem);
|
||||
min-height: 38px;
|
||||
margin: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.schedule-group-search:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid var(--accent-glow);
|
||||
}
|
||||
|
||||
.schedule-hours-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -3920,3 +3989,193 @@ tbody tr:hover {
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.registry-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.registry-toolbar .form-group {
|
||||
min-width: min(260px, 100%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registry-search {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-actions,
|
||||
.pagination-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.pagination-summary,
|
||||
.pagination-page,
|
||||
.pagination-size span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.pagination-size select {
|
||||
min-height: 36px;
|
||||
padding: 0.25rem 1.75rem 0.25rem 0.55rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-right: 0.55rem;
|
||||
vertical-align: -2px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: state-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes state-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.view-state-error {
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
.view-state-error .btn {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.async-combobox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.async-combobox-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.async-combobox-control input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.async-combobox-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.45rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.async-combobox-menu {
|
||||
position: absolute;
|
||||
z-index: 1200;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 10, 15, 0.96);
|
||||
backdrop-filter: blur(20px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||
}
|
||||
|
||||
[data-theme="light"] .async-combobox-menu {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||
}
|
||||
|
||||
.async-combobox-option {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.async-combobox-option:hover,
|
||||
.async-combobox-option:focus,
|
||||
.async-combobox-option[aria-selected="true"] {
|
||||
outline: none;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.async-combobox-option small,
|
||||
.async-combobox-state {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.async-combobox-state {
|
||||
padding: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.async-combobox-state.is-error { color: var(--danger, #dc2626); }
|
||||
|
||||
.project-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.project-dialog {
|
||||
width: min(520px, 100%);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 18px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.project-dialog header,
|
||||
.project-dialog footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-dialog header h2 { margin: 0; }
|
||||
.project-dialog footer { justify-content: flex-end; margin-top: 1rem; }
|
||||
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
|
||||
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
||||
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
}
|
||||
|
||||
.version-hero-controls label {
|
||||
display: block;
|
||||
margin-bottom: .35rem;
|
||||
color: rgba(255, 255, 255, .62);
|
||||
font-size: .7rem;
|
||||
font-weight: 800;
|
||||
@@ -98,6 +100,12 @@
|
||||
|
||||
.version-hero-controls option { color: #152536; background: #f7f4ed; }
|
||||
|
||||
.version-hero-period-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .65rem;
|
||||
}
|
||||
|
||||
.version-status {
|
||||
min-height: 2.8rem;
|
||||
padding: .8rem 1rem;
|
||||
@@ -130,9 +138,13 @@
|
||||
}
|
||||
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
|
||||
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; }
|
||||
.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; }
|
||||
.version-constructor-field { min-width: 0; }
|
||||
.version-constructor-field-wide { grid-column: 1 / -1; }
|
||||
.version-constructor-controls label { display: block; margin-bottom: .35rem; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
|
||||
.version-constructor-field select { width: 100%; }
|
||||
.version-constructor-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .55rem; }
|
||||
|
||||
.version-current {
|
||||
display: grid;
|
||||
@@ -304,5 +316,7 @@
|
||||
.version-diff-score { grid-template-columns: 1fr; }
|
||||
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
|
||||
.version-constructor-controls { grid-template-columns: 1fr; }
|
||||
.version-constructor-controls label { grid-column: auto; }
|
||||
.version-constructor-field-wide { grid-column: auto; }
|
||||
.version-constructor-actions { grid-column: auto; flex-direction: column; }
|
||||
.version-hero-period-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.absence-create-card {
|
||||
z-index: 20;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.absence-create-card:focus-within {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.absence-create-card .card-header-row > div:first-child,
|
||||
.absence-registry-head > div:first-child {
|
||||
display: flex;
|
||||
@@ -526,13 +535,64 @@ body.absence-wizard-open {
|
||||
|
||||
.teacher-dialogue-toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: .55rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar select {
|
||||
.teacher-dialogue-toolbar > select,
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar > select {
|
||||
min-height: 44px;
|
||||
padding: 0.65rem 2.15rem 0.65rem 0.8rem;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||
border-radius: 11px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-trigger {
|
||||
min-height: 44px;
|
||||
border-color: color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--bg-input) 88%, #f59e0b 4%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-trigger:hover,
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger {
|
||||
border-color: color-mix(in srgb, #f59e0b 62%, var(--bg-card-border));
|
||||
background: color-mix(in srgb, var(--bg-hover) 90%, #f59e0b 6%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger,
|
||||
.teacher-dialogue-toolbar .custom-select-trigger:focus {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 20%, transparent);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-menu {
|
||||
border-color: color-mix(in srgb, #f59e0b 25%, var(--bg-card-border));
|
||||
background: rgba(10, 10, 15, 0.96);
|
||||
backdrop-filter: blur(20px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-item:hover:not(.disabled) {
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--bg-hover));
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-item.selected {
|
||||
background: color-mix(in srgb, #f59e0b 18%, var(--bg-input));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .teacher-dialogue-toolbar .custom-select-menu {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||
}
|
||||
|
||||
.teacher-dialogue-list {
|
||||
@@ -669,6 +729,16 @@ body.absence-wizard-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper,
|
||||
.teacher-dialogue-toolbar > select,
|
||||
.teacher-dialogue-toolbar > .btn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.absence-command-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
51
frontend/admin/js/academic-period.js
Normal file
51
frontend/admin/js/academic-period.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { preferredScheduleSemester } from './schedule-period.js';
|
||||
|
||||
export function academicYearsFromSemesters(semesters = []) {
|
||||
const years = new Map();
|
||||
semesters.forEach(semester => {
|
||||
const id = String(semester?.academicYearId ?? '');
|
||||
if (!id || years.has(id)) return;
|
||||
years.set(id, {
|
||||
id,
|
||||
title: semester.academicYearTitle || `Учебный год ${id}`
|
||||
});
|
||||
});
|
||||
return [...years.values()];
|
||||
}
|
||||
|
||||
export function semestersForAcademicYear(semesters = [], academicYearId = '') {
|
||||
return semesters.filter(semester =>
|
||||
String(semester?.academicYearId ?? '') === String(academicYearId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAcademicPeriod(semesters = [], options = {}) {
|
||||
if (!semesters.length) return { academicYearId: '', semesterId: '' };
|
||||
|
||||
const requestedSemester = semesters.find(semester =>
|
||||
String(semester.id) === String(options.semesterId || '')
|
||||
);
|
||||
const availableYearIds = new Set(academicYearsFromSemesters(semesters).map(year => year.id));
|
||||
const requestedYearId = availableYearIds.has(String(options.academicYearId || ''))
|
||||
? String(options.academicYearId)
|
||||
: '';
|
||||
const preferred = preferredScheduleSemester(semesters, options.date || isoToday()) || semesters[0];
|
||||
const academicYearId = requestedSemester
|
||||
? String(requestedSemester.academicYearId)
|
||||
: (requestedYearId || String(preferred?.academicYearId || semesters[0].academicYearId));
|
||||
const yearSemesters = semestersForAcademicYear(semesters, academicYearId);
|
||||
const semester = requestedSemester && String(requestedSemester.academicYearId) === academicYearId
|
||||
? requestedSemester
|
||||
: (preferredScheduleSemester(yearSemesters, options.date || isoToday()) || yearSemesters[0]);
|
||||
|
||||
return {
|
||||
academicYearId,
|
||||
semesterId: String(semester?.id || '')
|
||||
};
|
||||
}
|
||||
|
||||
function isoToday() {
|
||||
const date = new Date();
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -135,7 +135,8 @@ export async function logout() {
|
||||
|
||||
export const api = {
|
||||
get: async (url) => {
|
||||
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||
const isCachable = !url.includes('/page') && !url.includes('/options')
|
||||
&& CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||
if (isCachable) {
|
||||
if (!cache.has(url)) {
|
||||
cache.set(url, await apiFetch(url, 'GET'));
|
||||
|
||||
193
frontend/admin/js/async-combobox.js
Normal file
193
frontend/admin/js/async-combobox.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { debounce } from './url-state.js';
|
||||
|
||||
export class AsyncCombobox {
|
||||
constructor(select, {
|
||||
load,
|
||||
optionValue = item => item.id,
|
||||
optionLabel = item => item.name,
|
||||
optionDescription = () => '',
|
||||
placeholder = 'Начните вводить название',
|
||||
emptyLabel = 'Ничего не найдено',
|
||||
allowEmpty = true
|
||||
}) {
|
||||
if (!select || typeof load !== 'function') throw new Error('Для поискового списка нужны select и функция загрузки');
|
||||
this.select = select;
|
||||
this.load = load;
|
||||
this.optionValue = optionValue;
|
||||
this.optionLabel = optionLabel;
|
||||
this.optionDescription = optionDescription;
|
||||
this.emptyLabel = emptyLabel;
|
||||
this.allowEmpty = allowEmpty;
|
||||
this.requestNumber = 0;
|
||||
this.items = [];
|
||||
|
||||
select.dataset.nativeSelect = 'true';
|
||||
select.hidden = true;
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'async-combobox';
|
||||
this.root.innerHTML = `
|
||||
<div class="async-combobox-control">
|
||||
<input type="search" role="combobox" aria-autocomplete="list" aria-expanded="false"
|
||||
autocomplete="off" placeholder="${escapeText(placeholder)}">
|
||||
<button type="button" class="async-combobox-clear" aria-label="Очистить выбор" ${allowEmpty ? '' : 'hidden'}>×</button>
|
||||
</div>
|
||||
<div class="async-combobox-menu" role="listbox" hidden></div>
|
||||
`;
|
||||
select.insertAdjacentElement('afterend', this.root);
|
||||
this.input = this.root.querySelector('input');
|
||||
this.menu = this.root.querySelector('[role="listbox"]');
|
||||
this.clearButton = this.root.querySelector('.async-combobox-clear');
|
||||
this.input.setAttribute('aria-label', select.getAttribute('aria-label') || placeholder);
|
||||
this.labels = select.id
|
||||
? [...document.querySelectorAll('label[for]')].filter(label => label.htmlFor === select.id)
|
||||
: [];
|
||||
if (select.id) {
|
||||
this.input.id = `${select.id}-search`;
|
||||
this.labels.forEach(label => { label.htmlFor = this.input.id; });
|
||||
}
|
||||
const listId = `${select.id || 'async-combobox'}-listbox`;
|
||||
this.menu.id = listId;
|
||||
this.input.setAttribute('aria-controls', listId);
|
||||
|
||||
this.input.addEventListener('focus', () => this.search(this.input.value));
|
||||
this.input.addEventListener('input', debounce(() => this.search(this.input.value), 320));
|
||||
this.input.addEventListener('keydown', event => this.handleKeydown(event));
|
||||
this.clearButton.addEventListener('click', () => this.clear());
|
||||
this.menu.addEventListener('mousedown', event => event.preventDefault());
|
||||
this.menu.addEventListener('keydown', event => this.handleMenuKeydown(event));
|
||||
this.handleOutsideClick = event => {
|
||||
if (!this.root.contains(event.target)) this.close();
|
||||
};
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
this.syncFromSelect();
|
||||
}
|
||||
|
||||
async search(query = '') {
|
||||
return this.loadMatches(query, true);
|
||||
}
|
||||
|
||||
async preload(query = '') {
|
||||
return this.loadMatches(query, false);
|
||||
}
|
||||
|
||||
async loadMatches(query, revealMenu) {
|
||||
const requestNumber = ++this.requestNumber;
|
||||
if (revealMenu) this.open();
|
||||
this.menu.innerHTML = '<div class="async-combobox-state"><span class="state-spinner" aria-hidden="true"></span>Поиск...</div>';
|
||||
try {
|
||||
const items = await this.load(query.trim(), this.select.value || '');
|
||||
if (requestNumber !== this.requestNumber) return;
|
||||
this.items = Array.isArray(items) ? items : [];
|
||||
this.renderItems();
|
||||
} catch (error) {
|
||||
if (requestNumber !== this.requestNumber) return;
|
||||
this.menu.innerHTML = `<div class="async-combobox-state is-error">${escapeText(error.message || 'Не удалось выполнить поиск')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
renderItems() {
|
||||
if (!this.items.length) {
|
||||
this.menu.innerHTML = `<div class="async-combobox-state">${escapeText(this.emptyLabel)}</div>`;
|
||||
return;
|
||||
}
|
||||
this.menu.innerHTML = this.items.map((item, index) => {
|
||||
const value = String(this.optionValue(item));
|
||||
const description = this.optionDescription(item);
|
||||
return `<button type="button" role="option" class="async-combobox-option" data-index="${index}"
|
||||
aria-selected="${String(this.select.value) === value}">
|
||||
<strong>${escapeText(this.optionLabel(item))}</strong>
|
||||
${description ? `<small>${escapeText(description)}</small>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
this.menu.querySelectorAll('[data-index]').forEach(button => {
|
||||
button.addEventListener('click', () => this.choose(this.items[Number(button.dataset.index)]));
|
||||
});
|
||||
}
|
||||
|
||||
choose(item) {
|
||||
const value = String(this.optionValue(item));
|
||||
const label = this.optionLabel(item);
|
||||
this.select.innerHTML = `<option value="${escapeText(value)}" selected>${escapeText(label)}</option>`;
|
||||
this.select.value = value;
|
||||
this.input.value = label;
|
||||
this.input.dataset.selectedValue = value;
|
||||
this.select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
this.close();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.select.innerHTML = '<option value="" selected></option>';
|
||||
this.select.value = '';
|
||||
this.input.value = '';
|
||||
delete this.input.dataset.selectedValue;
|
||||
this.select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
this.input.focus();
|
||||
this.search('');
|
||||
}
|
||||
|
||||
syncFromSelect() {
|
||||
const selected = this.select.selectedOptions?.[0];
|
||||
this.input.value = selected?.value ? selected.textContent.trim() : '';
|
||||
if (selected?.value) this.input.dataset.selectedValue = selected.value;
|
||||
}
|
||||
|
||||
open() {
|
||||
this.menu.hidden = false;
|
||||
this.input.setAttribute('aria-expanded', 'true');
|
||||
this.root.classList.add('is-open');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.hidden = true;
|
||||
this.input.setAttribute('aria-expanded', 'false');
|
||||
this.root.classList.remove('is-open');
|
||||
this.syncFromSelect();
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
const options = [...this.menu.querySelectorAll('[role="option"]')];
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
} else if (event.key === 'ArrowDown' && options.length) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)].focus();
|
||||
} else if (event.key === 'ArrowUp' && options.length) {
|
||||
event.preventDefault();
|
||||
options[Math.max(current - 1, 0)].focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleMenuKeydown(event) {
|
||||
const options = [...this.menu.querySelectorAll('[role="option"]')];
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.input.focus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
} else if (event.key === 'ArrowUp' && current >= 0) {
|
||||
event.preventDefault();
|
||||
(current === 0 ? this.input : options[current - 1])?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
this.requestNumber += 1;
|
||||
this.root.remove();
|
||||
this.select.hidden = false;
|
||||
this.labels.forEach(label => { label.htmlFor = this.select.id; });
|
||||
}
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
103
frontend/admin/js/dialog.js
Normal file
103
frontend/admin/js/dialog.js
Normal file
@@ -0,0 +1,103 @@
|
||||
let activeDialog = null;
|
||||
|
||||
function ensureDialogRoot() {
|
||||
let root = document.getElementById('project-dialog-root');
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = 'project-dialog-root';
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function showDialog({ title, message, confirmLabel = 'Подтвердить', cancelLabel = 'Отмена', danger = false, input = null }) {
|
||||
activeDialog?.close(false);
|
||||
const root = ensureDialogRoot();
|
||||
const previousFocus = document.activeElement;
|
||||
root.innerHTML = `
|
||||
<div class="project-dialog-backdrop">
|
||||
<section class="project-dialog" role="dialog" aria-modal="true" aria-labelledby="project-dialog-title" aria-describedby="project-dialog-message">
|
||||
<header>
|
||||
<h2 id="project-dialog-title">${escapeText(title || 'Подтвердите действие')}</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost" data-dialog-cancel aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<p id="project-dialog-message">${escapeText(message || '')}</p>
|
||||
${input ? `<label class="project-dialog-field"><span>${escapeText(input.label || 'Комментарий')}</span><textarea data-dialog-input rows="4" maxlength="${Number(input.maxlength || 2000)}" placeholder="${escapeText(input.placeholder || '')}">${escapeText(input.value || '')}</textarea></label>` : ''}
|
||||
<div class="project-dialog-error" data-dialog-error role="alert"></div>
|
||||
<footer>
|
||||
${cancelLabel ? `<button type="button" class="btn btn-md btn-secondary" data-dialog-cancel>${escapeText(cancelLabel)}</button>` : ''}
|
||||
<button type="button" class="btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}" data-dialog-confirm>${escapeText(confirmLabel)}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
const backdrop = root.firstElementChild;
|
||||
const dialog = backdrop.querySelector('.project-dialog');
|
||||
const inputElement = dialog.querySelector('[data-dialog-input]');
|
||||
|
||||
return new Promise(resolve => {
|
||||
const close = result => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
root.innerHTML = '';
|
||||
activeDialog = null;
|
||||
previousFocus?.focus?.();
|
||||
resolve(result);
|
||||
};
|
||||
activeDialog = { close };
|
||||
const cancel = () => close(input ? null : false);
|
||||
const confirm = () => {
|
||||
if (input?.required && !inputElement?.value.trim()) {
|
||||
dialog.querySelector('[data-dialog-error]').textContent = input.requiredMessage || 'Заполните поле';
|
||||
inputElement?.focus();
|
||||
return;
|
||||
}
|
||||
close(input ? inputElement.value.trim() : true);
|
||||
};
|
||||
const handleKeydown = event => {
|
||||
if (event.key === 'Escape') cancel();
|
||||
if (event.key === 'Tab') trapFocus(event, dialog);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
dialog.querySelectorAll('[data-dialog-cancel]').forEach(button => button.addEventListener('click', cancel));
|
||||
dialog.querySelector('[data-dialog-confirm]').addEventListener('click', confirm);
|
||||
backdrop.addEventListener('click', event => {
|
||||
if (event.target === backdrop) cancel();
|
||||
});
|
||||
window.setTimeout(() => (inputElement || dialog.querySelector('[data-dialog-confirm]')).focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmAction(options) {
|
||||
return showDialog(options);
|
||||
}
|
||||
|
||||
export function promptAction(options) {
|
||||
return showDialog({ ...options, input: options.input || { label: 'Комментарий' } });
|
||||
}
|
||||
|
||||
export function alertAction(options) {
|
||||
return showDialog({ ...options, cancelLabel: null, confirmLabel: options.confirmLabel || 'Понятно' });
|
||||
}
|
||||
|
||||
function trapFocus(event, root) {
|
||||
const focusable = [...root.querySelectorAll('button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')];
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
36
frontend/admin/js/dirty-state.js
Normal file
36
frontend/admin/js/dirty-state.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { confirmAction } from './dialog.js';
|
||||
|
||||
const scopes = new Map();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (![...scopes.values()].some(scope => scope.dirty)) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
}
|
||||
|
||||
export function createDirtyScope(name, message = 'Есть несохранённые изменения. Покинуть страницу и потерять их?') {
|
||||
const scope = { name, message, dirty: false };
|
||||
scopes.set(name, scope);
|
||||
return {
|
||||
mark() { scope.dirty = true; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
|
||||
clear() { scope.dirty = false; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
|
||||
isDirty() { return scope.dirty; },
|
||||
dispose() { scopes.delete(name); }
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmAbandonDirtyChanges() {
|
||||
const dirty = [...scopes.values()].filter(scope => scope.dirty);
|
||||
if (!dirty.length) return true;
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Несохранённые изменения',
|
||||
message: dirty[0].message,
|
||||
confirmLabel: 'Покинуть без сохранения',
|
||||
cancelLabel: 'Остаться',
|
||||
danger: true
|
||||
});
|
||||
if (confirmed) dirty.forEach(scope => { scope.dirty = false; });
|
||||
return confirmed;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import { confirmAbandonDirtyChanges } from './dirty-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const session = await initializeSession();
|
||||
@@ -224,6 +226,7 @@ navItems.forEach(item => {
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
|
||||
|
||||
// UI Update
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
@@ -245,6 +248,7 @@ async function switchTab(tab) {
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
trackUxEvent('раздел_открыт', { tab, role: currentRole });
|
||||
if (window.location.hash !== `#${tab}`) {
|
||||
window.location.hash = tab;
|
||||
}
|
||||
@@ -281,7 +285,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
|
||||
if (currentRole !== 'ADMIN') return;
|
||||
try {
|
||||
const count = countOverride === null
|
||||
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
|
||||
? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0)
|
||||
: countOverride;
|
||||
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
|
||||
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
|
||||
|
||||
34
frontend/admin/js/pagination.js
Normal file
34
frontend/admin/js/pagination.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { escapeHtml } from './utils.js';
|
||||
|
||||
export function renderPagination(container, pageData, { onPage, onSize, label = 'записей' } = {}) {
|
||||
if (!container) return;
|
||||
const totalItems = Number(pageData?.totalItems || 0);
|
||||
const page = Number(pageData?.page || 0);
|
||||
const size = Number(pageData?.size || 25);
|
||||
const totalPages = Math.max(Number(pageData?.totalPages || 0), totalItems ? 1 : 0);
|
||||
const start = totalItems ? page * size + 1 : 0;
|
||||
const end = Math.min((page + 1) * size, totalItems);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="pagination-summary" aria-live="polite">
|
||||
Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)}
|
||||
</div>
|
||||
<div class="pagination-actions">
|
||||
<label class="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select data-pagination-size data-native-select="true">
|
||||
${[25, 50, 100].map(value => `<option value="${value}" ${value === size ? 'selected' : ''}>${value}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page - 1}" ${page <= 0 ? 'disabled' : ''}>Назад</button>
|
||||
<span class="pagination-page">${totalPages ? page + 1 : 0} / ${totalPages}</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page + 1}" ${page >= totalPages - 1 ? 'disabled' : ''}>Далее</button>
|
||||
</div>
|
||||
`;
|
||||
container.querySelectorAll('[data-pagination-page]').forEach(button => {
|
||||
button.addEventListener('click', () => onPage?.(Number(button.dataset.paginationPage)));
|
||||
});
|
||||
container.querySelector('[data-pagination-size]')?.addEventListener('change', event => {
|
||||
onSize?.(Number(event.target.value));
|
||||
});
|
||||
}
|
||||
34
frontend/admin/js/url-state.js
Normal file
34
frontend/admin/js/url-state.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export function readUrlState(defaults = {}) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return Object.fromEntries(Object.entries(defaults).map(([key, fallback]) => [
|
||||
key,
|
||||
params.has(key) ? params.get(key) : fallback
|
||||
]));
|
||||
}
|
||||
|
||||
export function writeUrlState(changes, { replace = true } = {}) {
|
||||
const url = new URL(window.location.href);
|
||||
Object.entries(changes).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '' || value === false) {
|
||||
url.searchParams.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
url.searchParams.delete(key);
|
||||
value.forEach(item => url.searchParams.append(key, String(item)));
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
window.history[replace ? 'replaceState' : 'pushState']({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function readUrlValues(key) {
|
||||
return new URLSearchParams(window.location.search).getAll(key);
|
||||
}
|
||||
|
||||
export function debounce(callback, delay = 320) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => callback(...args), delay);
|
||||
};
|
||||
}
|
||||
24
frontend/admin/js/view-state.js
Normal file
24
frontend/admin/js/view-state.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { escapeHtml } from './utils.js';
|
||||
|
||||
export function renderTableLoading(tbody, columns, message = 'Загрузка данных...') {
|
||||
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row"><span class="state-spinner" aria-hidden="true"></span>${escapeHtml(message)}</td></tr>`;
|
||||
tbody.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
|
||||
export function renderTableEmpty(tbody, columns, message = 'Данных пока нет') {
|
||||
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-empty">${escapeHtml(message)}</td></tr>`;
|
||||
tbody.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
|
||||
export function renderTableError(tbody, columns, message, retry) {
|
||||
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-error">
|
||||
<span>${escapeHtml(message || 'Не удалось загрузить данные')}</span>
|
||||
${retry ? '<button type="button" class="btn btn-sm btn-secondary" data-view-retry>Повторить</button>' : ''}
|
||||
</td></tr>`;
|
||||
tbody.setAttribute('aria-busy', 'false');
|
||||
tbody.querySelector('[data-view-retry]')?.addEventListener('click', retry);
|
||||
}
|
||||
|
||||
export function markTableReady(tbody) {
|
||||
tbody.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-in
|
||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
||||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||
@@ -219,6 +222,10 @@ export async function initAcademicCalendar() {
|
||||
let isSyncingCalendarDayDialog = false;
|
||||
let calendarDayTooltip = null;
|
||||
let calendarDayTooltipPositionAnimation = null;
|
||||
let loadedGridCalendarId = '';
|
||||
let loadedSubjectCalendarId = '';
|
||||
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
|
||||
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -248,7 +255,10 @@ export async function initAcademicCalendar() {
|
||||
].forEach(bindDateInputMask);
|
||||
|
||||
calendarTabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
||||
button.addEventListener('click', async () => {
|
||||
if (!await confirmAbandonDirtyChanges()) return;
|
||||
activateCalendarTab(button.dataset.calendarTab);
|
||||
});
|
||||
});
|
||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||
@@ -269,6 +279,7 @@ export async function initAcademicCalendar() {
|
||||
if (event.target.matches('.calendar-day-activity')) {
|
||||
updateCalendarDayVisual(event.target.closest('.calendar-day'));
|
||||
renderCalendarTotals();
|
||||
markGridDirty();
|
||||
}
|
||||
});
|
||||
calendarGrid?.addEventListener('mousedown', (event) => {
|
||||
@@ -320,6 +331,7 @@ export async function initAcademicCalendar() {
|
||||
});
|
||||
updateCalendarDayDialogSummaryForSelection(days);
|
||||
renderCalendarTotals();
|
||||
markGridDirty();
|
||||
});
|
||||
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
|
||||
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
|
||||
@@ -441,7 +453,13 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
if (deleteYearButton) {
|
||||
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Удаление учебного года',
|
||||
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
|
||||
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
|
||||
@@ -653,7 +671,13 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить календарный график?')) return;
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Удаление календарного графика',
|
||||
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||||
@@ -691,9 +715,16 @@ export async function initAcademicCalendar() {
|
||||
async function loadCalendarSubjects() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
subjectCalendarSelect.value = loadedSubjectCalendarId;
|
||||
refreshSelectVisuals(subjectCalendarSelect);
|
||||
return;
|
||||
}
|
||||
populateSubjectSemesterSelect();
|
||||
calendarSubjectRows = [];
|
||||
if (!calendar) {
|
||||
loadedSubjectCalendarId = '';
|
||||
clearSubjectDirty();
|
||||
renderCalendarSubjects();
|
||||
return;
|
||||
}
|
||||
@@ -701,6 +732,8 @@ export async function initAcademicCalendar() {
|
||||
try {
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||
loadedSubjectCalendarId = String(calendar.id);
|
||||
clearSubjectDirty();
|
||||
renderCalendarSubjects();
|
||||
} catch (error) {
|
||||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||
@@ -741,6 +774,7 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
]);
|
||||
renderCalendarSubjects();
|
||||
markSubjectDirty();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
@@ -759,6 +793,8 @@ export async function initAcademicCalendar() {
|
||||
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
|
||||
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
|
||||
renderCalendarSubjects();
|
||||
clearSubjectDirty();
|
||||
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
|
||||
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
|
||||
} catch (error) {
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
|
||||
@@ -774,6 +810,7 @@ export async function initAcademicCalendar() {
|
||||
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
|
||||
);
|
||||
renderCalendarSubjects();
|
||||
markSubjectDirty();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
@@ -820,6 +857,11 @@ export async function initAcademicCalendar() {
|
||||
async function loadCalendarGrid() {
|
||||
hideAlert('calendar-editor-alert');
|
||||
const calendar = selectedEditorCalendar();
|
||||
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
editorCalendarSelect.value = loadedGridCalendarId;
|
||||
refreshSelectVisuals(editorCalendarSelect);
|
||||
return;
|
||||
}
|
||||
if (!calendar) {
|
||||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
@@ -829,6 +871,9 @@ export async function initAcademicCalendar() {
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
||||
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
||||
renderCalendarGrid(gridRows);
|
||||
loadedGridCalendarId = String(calendar.id);
|
||||
clearGridDirty();
|
||||
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
|
||||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||||
} catch (error) {
|
||||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||||
@@ -856,6 +901,8 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
try {
|
||||
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
|
||||
clearGridDirty();
|
||||
trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length });
|
||||
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
|
||||
await loadCalendarGrid();
|
||||
} catch (error) {
|
||||
@@ -895,9 +942,30 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
});
|
||||
renderCalendarTotals();
|
||||
if (changed) markGridDirty();
|
||||
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
|
||||
}
|
||||
|
||||
function markGridDirty() {
|
||||
gridDirtyScope.mark();
|
||||
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •';
|
||||
}
|
||||
|
||||
function clearGridDirty() {
|
||||
gridDirtyScope.clear();
|
||||
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку';
|
||||
}
|
||||
|
||||
function markSubjectDirty() {
|
||||
subjectDirtyScope.mark();
|
||||
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •';
|
||||
}
|
||||
|
||||
function clearSubjectDirty() {
|
||||
subjectDirtyScope.clear();
|
||||
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки';
|
||||
}
|
||||
|
||||
function renderCalendarGrid(rows) {
|
||||
activeCalendarDay = null;
|
||||
selectedCalendarDayKey = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
|
||||
async function fetchEquipments() {
|
||||
try {
|
||||
@@ -131,11 +132,16 @@ export async function initClassrooms() {
|
||||
const btnEdit = e.target.closest('.classroom-edit-action');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Вывод аудитории из эксплуатации',
|
||||
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
|
||||
confirmLabel: 'Вывести из эксплуатации',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка архивирования'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
|
||||
}
|
||||
|
||||
const btnRestore = e.target.closest('.btn-restore-classroom');
|
||||
@@ -143,7 +149,7 @@ export async function initClassrooms() {
|
||||
try {
|
||||
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка восстановления аудитории'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
|
||||
}
|
||||
|
||||
if (btnToggleStatus) {
|
||||
@@ -152,7 +158,7 @@ export async function initClassrooms() {
|
||||
try {
|
||||
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка изменения статуса'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
|
||||
}
|
||||
|
||||
if (btnEdit) {
|
||||
@@ -178,7 +184,7 @@ export async function initClassrooms() {
|
||||
hideAlert('edit-classroom-alert');
|
||||
modalEditClassroom.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Ошибка загрузки данных аудитории');
|
||||
await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,12 +294,17 @@ export async function initClassrooms() {
|
||||
manageEquipmentTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete-equipment');
|
||||
if (!btnDelete) return;
|
||||
if (!confirm('Удалить оборудование из каталога?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление оборудования',
|
||||
message: 'Удалить оборудование из каталога?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/equipments/' + btnDelete.dataset.id);
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка удаления');
|
||||
await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
async function fetchEducationForms() {
|
||||
try {
|
||||
@@ -24,6 +30,10 @@ export async function initGroups() {
|
||||
const filterEfMenu = document.getElementById('filter-ef-menu');
|
||||
const filterEfText = document.getElementById('filter-ef-text');
|
||||
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
|
||||
const searchInput = document.getElementById('groups-search');
|
||||
const sortSelect = document.getElementById('groups-sort');
|
||||
const filterResetButton = document.getElementById('groups-filter-reset');
|
||||
const pagination = document.getElementById('groups-pagination');
|
||||
|
||||
// Модалка группы
|
||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||
@@ -65,9 +75,19 @@ export async function initGroups() {
|
||||
let specialties = [];
|
||||
let profilesBySpecialty = new Map();
|
||||
let academicYears = [];
|
||||
let calendars = [];
|
||||
let currentAssignments = [];
|
||||
let activeSubgroupGroup = null;
|
||||
let calendarCombobox = null;
|
||||
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.groupQuery,
|
||||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
|
||||
sort: restored.groupSort,
|
||||
educationFormIds: readUrlValues('groupEducationForm')
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
@@ -89,7 +109,30 @@ export async function initGroups() {
|
||||
|
||||
// Модалка календарей
|
||||
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||||
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
|
||||
manageCalendarYearSelect.addEventListener('change', () => {
|
||||
calendarCombobox?.clear();
|
||||
populateCalendarSelect();
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}));
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
});
|
||||
filterResetButton.addEventListener('click', () => {
|
||||
pageState.query = '';
|
||||
pageState.page = 0;
|
||||
pageState.sort = 'name,asc';
|
||||
pageState.educationFormIds = [];
|
||||
searchInput.value = '';
|
||||
sortSelect.value = pageState.sort;
|
||||
renderFilterEfCheckboxes(educationForms);
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||
|
||||
@@ -143,20 +186,19 @@ export async function initGroups() {
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
|
||||
[educationForms, departments, specialties, academicYears] = await Promise.all([
|
||||
fetchEducationForms(),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/specialties'),
|
||||
api.get('/api/admin/calendar/years'),
|
||||
api.get('/api/admin/academic-calendars')
|
||||
api.get('/api/admin/calendar/years')
|
||||
]);
|
||||
await loadProfiles();
|
||||
populateEfSelects(educationForms);
|
||||
populateDepartmentSelects(departments);
|
||||
populateSpecialitySelects(specialties);
|
||||
populateYearSelect();
|
||||
initCalendarCombobox();
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
@@ -171,28 +213,50 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
|
||||
try {
|
||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||
applyGroupFilter();
|
||||
const [sort, direction] = pageState.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: pageState.query,
|
||||
page: String(pageState.page),
|
||||
size: String(pageState.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
|
||||
const result = await api.get(`/api/groups/page?${params}`);
|
||||
allGroups = result.items || [];
|
||||
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
|
||||
pageState.page = Math.max(0, result.totalPages - 1);
|
||||
return loadGroups();
|
||||
}
|
||||
renderGroups(allGroups);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'групп',
|
||||
onPage: page => { pageState.page = page; loadGroups(); },
|
||||
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
|
||||
});
|
||||
persistPageState();
|
||||
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroups() {
|
||||
async function loadSubgroups(groupId) {
|
||||
try {
|
||||
subgroups = await api.get('/api/subgroups');
|
||||
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
|
||||
} catch (error) {
|
||||
console.error('Не удалось загрузить подгруппы');
|
||||
subgroups = [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
const filtered = selectedIds.length
|
||||
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
|
||||
: allGroups;
|
||||
renderGroups(filtered);
|
||||
pageState.educationFormIds = selectedFilterEducationFormIds();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
function handleFilterEfChange(event) {
|
||||
@@ -247,7 +311,9 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
|
||||
? 'По заданным условиям группы не найдены'
|
||||
: 'Групп пока нет');
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
@@ -273,6 +339,7 @@ export async function initGroups() {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(groupsTbody);
|
||||
}
|
||||
|
||||
function populateEfSelects(forms) {
|
||||
@@ -282,7 +349,7 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
const selectedIds = new Set(selectedFilterEducationFormIds());
|
||||
const selectedIds = new Set(pageState.educationFormIds);
|
||||
const allChecked = !selectedIds.size;
|
||||
filterEfCheckboxes.innerHTML = `
|
||||
<label class="checkbox-item">
|
||||
@@ -372,23 +439,40 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function populateCalendarSelect() {
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
const yearId = manageCalendarYearSelect.value;
|
||||
const matching = group && yearId
|
||||
? calendars.filter(calendar =>
|
||||
String(calendar.academicYearId) === String(yearId)
|
||||
&& String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode)
|
||||
&& String(calendar.specialtyProfileId) === String(group.specialtyProfileId)
|
||||
&& String(calendar.studyFormId) === String(group.educationFormId)
|
||||
)
|
||||
: [];
|
||||
manageCalendarSelect.innerHTML = matching.length
|
||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет подходящих графиков</option>';
|
||||
syncSelects(manageCalendarSelect);
|
||||
calendarCombobox?.search('');
|
||||
}
|
||||
|
||||
function initCalendarCombobox() {
|
||||
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
|
||||
placeholder: 'Найдите совместимый график',
|
||||
emptyLabel: 'Совместимых графиков не найдено',
|
||||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
|
||||
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
|
||||
load: async (query, selectedId) => {
|
||||
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
|
||||
if (!group || !manageCalendarYearSelect.value) return [];
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
academicYearId: manageCalendarYearSelect.value,
|
||||
specialtyId: String(group.specialtyId || group.specialityCode),
|
||||
profileId: String(group.specialtyProfileId),
|
||||
studyFormId: String(group.educationFormId),
|
||||
limit: '30'
|
||||
});
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistPageState() {
|
||||
writeUrlState({
|
||||
groupQuery: pageState.query,
|
||||
groupPage: pageState.page || '',
|
||||
groupSize: pageState.size === 25 ? '' : pageState.size,
|
||||
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
|
||||
groupEducationForm: pageState.educationFormIds
|
||||
});
|
||||
}
|
||||
|
||||
async function createGroup(event) {
|
||||
@@ -463,12 +547,17 @@ export async function initGroups() {
|
||||
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать группу?',
|
||||
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка архивирования');
|
||||
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +566,7 @@ export async function initGroups() {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка восстановления');
|
||||
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +577,7 @@ export async function initGroups() {
|
||||
if (btnSubgroups) {
|
||||
const groupId = btnSubgroups.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) openSubgroupsModal(group);
|
||||
if (group) await openSubgroupsModal(group);
|
||||
}
|
||||
|
||||
if (btnCalendar) {
|
||||
@@ -498,10 +587,10 @@ export async function initGroups() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditGroupModal(id) {
|
||||
async function openEditGroupModal(id) {
|
||||
const group = allGroups.find(item => item.id == id);
|
||||
if (!group) {
|
||||
alert('Группа не найдена');
|
||||
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,13 +608,20 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
function openSubgroupsModal(group) {
|
||||
async function openSubgroupsModal(group) {
|
||||
activeSubgroupGroup = group;
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
resetSubgroupForm();
|
||||
modalManageSubgroups.classList.add('open');
|
||||
renderSubgroupsList(group.id);
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
|
||||
try {
|
||||
await loadSubgroups(group.id);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(group.id);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubgroupsList(groupId) {
|
||||
@@ -633,7 +729,7 @@ export async function initGroups() {
|
||||
try {
|
||||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
@@ -660,7 +756,7 @@ export async function initGroups() {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
@@ -727,12 +823,17 @@ export async function initGroups() {
|
||||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Удалить подгруппу?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить подгруппу?',
|
||||
message: 'Удаление возможно только если подгруппа не используется в расписании.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
@@ -778,10 +879,7 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function assignmentCalendarTitle(assignment) {
|
||||
const calendar = calendars.find(item =>
|
||||
String(item.id) === String(assignment.calendarId)
|
||||
);
|
||||
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
|
||||
return assignment.calendarTitle || '-';
|
||||
}
|
||||
|
||||
function renderAssignmentSubjects(subjects) {
|
||||
@@ -831,7 +929,12 @@ export async function initGroups() {
|
||||
manageCalendarTbody.addEventListener('click', async (event) => {
|
||||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||
if (!deleteButton) return;
|
||||
if (!confirm('Удалить назначение графика?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить назначение графика?',
|
||||
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
|
||||
confirmLabel: 'Удалить назначение',
|
||||
danger: true
|
||||
})) return;
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||||
const ACTION_LABELS = {
|
||||
@@ -83,7 +84,30 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
};
|
||||
|
||||
registryButton.hidden = false;
|
||||
fillReferenceSelects();
|
||||
const teacherCombobox = new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/users/teachers/options?${params}`);
|
||||
mergeDictionary('teachers', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
const classroomCombobox = new AsyncCombobox(classroomSelect, {
|
||||
placeholder: 'Найдите аудиторию',
|
||||
optionLabel: classroomOptionLabel,
|
||||
optionDescription: room => [room.building, room.floor ? `${room.floor} этаж` : null, room.capacity ? `${room.capacity} мест` : null].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/classrooms/options?${params}`);
|
||||
mergeDictionary('classrooms', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
bindEvents();
|
||||
renderRegistry();
|
||||
|
||||
@@ -226,15 +250,6 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
};
|
||||
}
|
||||
|
||||
function fillReferenceSelects() {
|
||||
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
|
||||
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
|
||||
).join('');
|
||||
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
|
||||
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function setEditorValues(existing) {
|
||||
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
|
||||
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
|
||||
@@ -250,6 +265,8 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
);
|
||||
teacherSelect.value = String(effectiveTeacherId);
|
||||
classroomSelect.value = String(effectiveClassroomId);
|
||||
teacherCombobox.syncFromSelect();
|
||||
classroomCombobox.syncFromSelect();
|
||||
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
|
||||
commentInput.value = existing?.comment || '';
|
||||
}
|
||||
@@ -515,6 +532,12 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||||
return classroom ? classroomOptionLabel(classroom) : '';
|
||||
}
|
||||
|
||||
function mergeDictionary(key, items) {
|
||||
const current = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => current.set(String(item.id), item));
|
||||
dictionaries[key] = [...current.values()];
|
||||
}
|
||||
}
|
||||
|
||||
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let qualityState = null;
|
||||
|
||||
export async function initScheduleQuality() {
|
||||
const root = document.querySelector('.quality-lab');
|
||||
if (!root) return;
|
||||
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
|
||||
qualityState = {
|
||||
root,
|
||||
analysis: null,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
visibleProblems: [],
|
||||
selectedProblemId: null,
|
||||
applying: false
|
||||
};
|
||||
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
|
||||
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
@@ -20,26 +36,18 @@ async function loadSemesters() {
|
||||
const select = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!select) return;
|
||||
try {
|
||||
const semesters = await api.get('/api/schedule/semesters');
|
||||
select.innerHTML = (semesters || []).map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">
|
||||
${escapeHtml(semesterLabel(semester))}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!semesters?.length) {
|
||||
select.innerHTML = '<option value="">Семестры не настроены</option>';
|
||||
qualityState.semesters = await api.get('/api/schedule/semesters');
|
||||
if (!qualityState.semesters?.length) {
|
||||
renderPeriodSelects('', '');
|
||||
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
|
||||
return;
|
||||
}
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester =>
|
||||
semester.startDate <= today && semester.endDate >= today
|
||||
);
|
||||
select.value = String(current?.id ?? semesters[0].id);
|
||||
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
|
||||
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
|
||||
select.value = savedSemesterId;
|
||||
}
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.quality.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.quality.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
@@ -47,6 +55,39 @@ async function loadSemesters() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = qualityState?.root.querySelector('#quality-year');
|
||||
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!yearSelect || !semesterSelect) return;
|
||||
const years = academicYearsFromSemesters(qualityState.semesters);
|
||||
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = qualityState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const select = qualityState?.root.querySelector('#quality-version');
|
||||
@@ -69,6 +110,7 @@ async function loadVersions() {
|
||||
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
|
||||
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
|
||||
select.value = String((saved || published || qualityState.versions[0]).id);
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
@@ -100,6 +142,11 @@ async function runAnalysis() {
|
||||
);
|
||||
qualityState.selectedProblemId = null;
|
||||
renderAnalysis();
|
||||
trackUxEvent('качество_расписания_проверено', {
|
||||
semesterId,
|
||||
versionId,
|
||||
score: Number(qualityState.analysis.score || 0)
|
||||
});
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
@@ -261,6 +308,7 @@ function handleRulePanelClick(event) {
|
||||
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let versionState = null;
|
||||
|
||||
@@ -9,6 +15,7 @@ export async function initScheduleVersions() {
|
||||
versionState = {
|
||||
root,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
history: [],
|
||||
modal: null,
|
||||
@@ -20,6 +27,7 @@ export async function initScheduleVersions() {
|
||||
|
||||
function bindEvents() {
|
||||
const root = versionState.root;
|
||||
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
|
||||
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
|
||||
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
|
||||
@@ -37,34 +45,62 @@ function bindEvents() {
|
||||
}
|
||||
|
||||
async function loadSemesters() {
|
||||
const select = versionState.root.querySelector('#version-semester');
|
||||
try {
|
||||
versionState.semesters = await api.get('/api/schedule/semesters');
|
||||
select.innerHTML = versionState.semesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('');
|
||||
if (!versionState.semesters.length) {
|
||||
select.innerHTML = '<option value="">Семестры не настроены</option>';
|
||||
renderPeriodSelects('', '');
|
||||
setStatus('Сначала создайте учебный год и семестр.', 'error');
|
||||
return;
|
||||
}
|
||||
const savedSemester = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const today = localIsoDate(new Date());
|
||||
const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today);
|
||||
const selected = versionState.semesters.find(item => String(item.id) === savedSemester)
|
||||
|| current
|
||||
|| versionState.semesters[0];
|
||||
select.value = String(selected.id);
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = versionState.root.querySelector('#version-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = versionState.root.querySelector('#version-year');
|
||||
const semesterSelect = versionState.root.querySelector('#version-semester');
|
||||
const years = academicYearsFromSemesters(versionState.semesters);
|
||||
versionState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = versionState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
const semesterId = selectedSemesterId();
|
||||
if (!semesterId || versionState.loading) return;
|
||||
versionState.loading = true;
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
|
||||
try {
|
||||
@@ -73,6 +109,7 @@ async function loadWorkspace() {
|
||||
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
|
||||
]);
|
||||
renderWorkspace();
|
||||
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
|
||||
setStatus('Контур версий синхронизирован.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
|
||||
@@ -197,6 +234,7 @@ async function handleVersionAction(event) {
|
||||
if (!version) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === 'edit') {
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(version.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
|
||||
navigateTo('schedule');
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { preferredScheduleSemester } from '../schedule-period.js';
|
||||
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
const TARGET_GROUPS = 'groups';
|
||||
@@ -62,7 +70,18 @@ export async function initScheduleView() {
|
||||
const tables = document.getElementById('schedule-view-tables');
|
||||
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
|
||||
const departmentSelect = document.getElementById('schedule-view-department');
|
||||
const academicYearSelect = document.getElementById('schedule-view-year');
|
||||
const semesterSelect = document.getElementById('schedule-view-semester');
|
||||
const restored = readUrlState({
|
||||
scheduleTarget: TARGET_GROUPS,
|
||||
scheduleEntity: '',
|
||||
scheduleDate: '',
|
||||
scheduleYear: '',
|
||||
scheduleSemester: '',
|
||||
scheduleSubject: '',
|
||||
scheduleLessonType: '',
|
||||
scheduleParity: ''
|
||||
});
|
||||
|
||||
const state = {
|
||||
dictionaries: {
|
||||
@@ -83,6 +102,10 @@ export async function initScheduleView() {
|
||||
const mobileMedia = window.matchMedia(MOBILE_QUERY);
|
||||
|
||||
setDefaultDate(dateInput);
|
||||
if (restored.scheduleDate) dateInput.value = restored.scheduleDate;
|
||||
if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget;
|
||||
restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity);
|
||||
const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId);
|
||||
configureDepartmentTarget();
|
||||
updatePeriodLabel();
|
||||
updateEntityFieldVisibility();
|
||||
@@ -92,6 +115,7 @@ export async function initScheduleView() {
|
||||
clearInactiveEntityValues();
|
||||
updateEntityFieldVisibility();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
loadButton?.addEventListener('click', loadData);
|
||||
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
|
||||
@@ -103,11 +127,22 @@ export async function initScheduleView() {
|
||||
dateInput?.addEventListener('change', () => {
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
academicYearSelect?.addEventListener('change', () => {
|
||||
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value);
|
||||
const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value });
|
||||
renderPeriodSelects(academicYearSelect.value, selected.semesterId);
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
semesterSelect?.addEventListener('change', () => {
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
|
||||
resultNextButton?.addEventListener('click', () => moveResult(1));
|
||||
@@ -132,8 +167,10 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
try {
|
||||
state.dictionaries = await loadDictionaries(role, userDepartmentId);
|
||||
selectCurrentSemester();
|
||||
Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
|
||||
mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
|
||||
restoreSimpleFilters(restored);
|
||||
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
|
||||
overridePanel = createScheduleOverridePanel({
|
||||
role,
|
||||
dictionaries: state.dictionaries,
|
||||
@@ -149,6 +186,11 @@ export async function initScheduleView() {
|
||||
resetResults(message);
|
||||
}
|
||||
|
||||
['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department',
|
||||
'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('change', persistFilters);
|
||||
});
|
||||
|
||||
function configureDepartmentTarget() {
|
||||
if (role !== ROLE_DEPARTMENT) return;
|
||||
targetSelect.value = TARGET_DEPARTMENTS;
|
||||
@@ -187,8 +229,12 @@ export async function initScheduleView() {
|
||||
].forEach(([fieldTarget, id]) => {
|
||||
if (fieldTarget === target) return;
|
||||
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.value = '';
|
||||
if (entityComboboxes[fieldTarget]) {
|
||||
entityComboboxes[fieldTarget].clear();
|
||||
} else {
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,6 +301,8 @@ export async function initScheduleView() {
|
||||
state.overrides = overrides;
|
||||
overridePanel.setRegistry(overrides, range);
|
||||
renderLessons(lessons, range, target, previousKey);
|
||||
persistFilters();
|
||||
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки расписания';
|
||||
showAlert('schedule-view-alert', message, 'error');
|
||||
@@ -894,17 +942,38 @@ export async function initScheduleView() {
|
||||
.find(semester => String(semester.id) === String(semesterId)) || null;
|
||||
}
|
||||
|
||||
function selectCurrentSemester() {
|
||||
if (!semesterSelect) return;
|
||||
const preferredSemester = preferredScheduleSemester(
|
||||
state.dictionaries.semesters,
|
||||
dateInput.value
|
||||
);
|
||||
semesterSelect.value = preferredSemester ? String(preferredSemester.id) : '';
|
||||
function selectCurrentSemester(academicYearId = '', semesterId = '') {
|
||||
if (!academicYearSelect || !semesterSelect) return;
|
||||
const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
|
||||
academicYearId,
|
||||
semesterId,
|
||||
date: dateInput.value
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const years = academicYearsFromSemesters(state.dictionaries.semesters);
|
||||
const selectedYearId = String(academicYearId || years[0]?.id || '');
|
||||
academicYearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
academicYearSelect.value = selectedYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterOptionLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| preferredScheduleSemester(yearSemesters, dateInput.value)
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
}
|
||||
|
||||
function synchronizeDateWithSemester() {
|
||||
const semester = selectedSemester();
|
||||
if (!semester) {
|
||||
@@ -924,6 +993,26 @@ export async function initScheduleView() {
|
||||
if (!range) return 1;
|
||||
return isoDay(dateInput.value || range.startDate);
|
||||
}
|
||||
|
||||
function persistFilters() {
|
||||
const target = activeTarget();
|
||||
const entitySelectId = {
|
||||
groups: 'schedule-view-group',
|
||||
teachers: 'schedule-view-teacher',
|
||||
classrooms: 'schedule-view-classroom',
|
||||
departments: 'schedule-view-department'
|
||||
}[target];
|
||||
writeUrlState({
|
||||
scheduleTarget: role === ROLE_DEPARTMENT ? '' : target,
|
||||
scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '',
|
||||
scheduleDate: dateInput.value,
|
||||
scheduleYear: academicYearSelect?.value || '',
|
||||
scheduleSemester: semesterSelect?.value || '',
|
||||
scheduleSubject: valueOf('schedule-view-subject'),
|
||||
scheduleLessonType: valueOf('schedule-view-lesson-type'),
|
||||
scheduleParity: valueOf('schedule-view-parity')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyOverridePanel() {
|
||||
@@ -935,37 +1024,111 @@ function createEmptyOverridePanel() {
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/schedule/semesters')
|
||||
]);
|
||||
|
||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||
? groups.filter(group => String(group.departmentId) === String(userDepartmentId))
|
||||
: groups;
|
||||
|
||||
fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы');
|
||||
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
|
||||
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
|
||||
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
|
||||
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
|
||||
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
|
||||
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
|
||||
|
||||
return {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
groups: [],
|
||||
teachers: [],
|
||||
classrooms: [],
|
||||
departments,
|
||||
semesters: semesters || []
|
||||
};
|
||||
}
|
||||
|
||||
function setupEntityComboboxes(dictionaries, role, userDepartmentId) {
|
||||
const definitions = {
|
||||
groups: {
|
||||
select: document.getElementById('schedule-view-group'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId);
|
||||
return api.get(`/api/groups/options?${params}`);
|
||||
},
|
||||
label: item => item.name,
|
||||
description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ')
|
||||
},
|
||||
teachers: {
|
||||
select: document.getElementById('schedule-view-teacher'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/users/teachers/options?${params}`);
|
||||
},
|
||||
label: item => item.fullName || item.username,
|
||||
description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ')
|
||||
},
|
||||
classrooms: {
|
||||
select: document.getElementById('schedule-view-classroom'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/classrooms/options?${params}`);
|
||||
},
|
||||
label: roomLabel,
|
||||
description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ')
|
||||
}
|
||||
};
|
||||
return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => {
|
||||
const combo = new AsyncCombobox(definition.select, {
|
||||
placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`,
|
||||
optionLabel: definition.label,
|
||||
optionDescription: definition.description,
|
||||
load: async (query, selectedId) => {
|
||||
const items = await definition.load(query, selectedId);
|
||||
mergeItems(dictionaries, key, items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
combo.preload('');
|
||||
return [key, combo];
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeLoadedDictionaries(dictionaries, combos) {
|
||||
Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || []));
|
||||
}
|
||||
|
||||
function mergeItems(dictionaries, key, items) {
|
||||
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => merged.set(String(item.id), item));
|
||||
dictionaries[key] = [...merged.values()];
|
||||
}
|
||||
|
||||
function restoreEntitySelect(target, value) {
|
||||
if (!value) return;
|
||||
const id = {
|
||||
groups: 'schedule-view-group',
|
||||
teachers: 'schedule-view-teacher',
|
||||
classrooms: 'schedule-view-classroom',
|
||||
departments: 'schedule-view-department'
|
||||
}[target];
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.innerHTML = `<option value="${escapeHtml(value)}" selected>Загрузка...</option>`;
|
||||
}
|
||||
|
||||
function restoreSimpleFilters(restored) {
|
||||
[
|
||||
['schedule-view-subject', restored.scheduleSubject],
|
||||
['schedule-view-lesson-type', restored.scheduleLessonType],
|
||||
['schedule-view-parity', restored.scheduleParity]
|
||||
].forEach(([id, value]) => {
|
||||
const select = document.getElementById(id);
|
||||
if (select && value && Array.from(select.options).some(option => option.value === String(value))) {
|
||||
select.value = String(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setDefaultDate(dateInput) {
|
||||
const today = new Date();
|
||||
dateInput.value = toIsoDate(today);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник' },
|
||||
@@ -61,6 +71,10 @@ export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
|
||||
return !programmatic && !(type === 'input' && fromAsyncCombobox);
|
||||
}
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
@@ -100,11 +114,16 @@ const LESSON_TYPE_LIMITS = [
|
||||
];
|
||||
|
||||
export async function initSchedule() {
|
||||
const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?');
|
||||
const initialState = document.getElementById('schedule-initial-state');
|
||||
const initialContent = document.getElementById('schedule-initial-content');
|
||||
const ruleForm = document.getElementById('schedule-rule-form');
|
||||
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
||||
const ruleIdInput = document.getElementById('schedule-rule-id');
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const scheduleVersionYearSelect = document.getElementById('schedule-version-year');
|
||||
const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester');
|
||||
const scheduleVersionSelect = document.getElementById('schedule-version-select');
|
||||
const scheduleVersionContext = document.getElementById('schedule-version-context');
|
||||
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
|
||||
@@ -128,6 +147,7 @@ export async function initSchedule() {
|
||||
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
|
||||
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
|
||||
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
|
||||
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
|
||||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||
@@ -170,8 +190,6 @@ export async function initSchedule() {
|
||||
let rules = [];
|
||||
let subjects = [];
|
||||
let groups = [];
|
||||
let teachers = [];
|
||||
let classrooms = [];
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
@@ -190,6 +208,14 @@ export async function initSchedule() {
|
||||
let visualContextMenuPositionAnimation = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
let lastVersionYearValue = '';
|
||||
let lastVersionSemesterValue = '';
|
||||
let lastVersionValue = '';
|
||||
let slotComboboxes = [];
|
||||
let conflictComboboxes = [];
|
||||
let groupSearchRequest = 0;
|
||||
let subgroupRequest = 0;
|
||||
let syncingRuleForm = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -201,13 +227,26 @@ export async function initSchedule() {
|
||||
syncRuleSemesterWithVersion();
|
||||
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
|
||||
await loadRules();
|
||||
dirtyScope.clear();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||||
} finally {
|
||||
initialContent?.classList.remove('is-loading');
|
||||
initialContent?.setAttribute('aria-busy', 'false');
|
||||
if (initialState) initialState.hidden = true;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
||||
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
||||
scheduleVersionSelect?.addEventListener('change', async () => {
|
||||
const requestedValue = scheduleVersionSelect.value;
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
scheduleVersionSelect.value = lastVersionValue;
|
||||
return;
|
||||
}
|
||||
lastVersionValue = requestedValue;
|
||||
if (scheduleVersionSelect.value) {
|
||||
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
|
||||
}
|
||||
@@ -227,7 +266,10 @@ export async function initSchedule() {
|
||||
});
|
||||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
slotAddButton?.addEventListener('click', () => {
|
||||
renderRuleSlots([...readRuleSlots(false), {}]);
|
||||
dirtyScope.mark();
|
||||
});
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
@@ -305,6 +347,7 @@ export async function initSchedule() {
|
||||
const slots = readRuleSlots(false);
|
||||
slots.splice(Number(removeButton.dataset.index), 1);
|
||||
renderRuleSlots(slots.length ? slots : [{}]);
|
||||
dirtyScope.mark();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,9 +364,11 @@ export async function initSchedule() {
|
||||
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
|
||||
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
|
||||
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
|
||||
ruleGroupCheckboxes.addEventListener('change', () => {
|
||||
ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
|
||||
ruleGroupCheckboxes.addEventListener('change', async () => {
|
||||
const slots = readRuleSlots(false);
|
||||
updateRuleGroupsText();
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
closeRuleGroupsMenu();
|
||||
@@ -340,25 +385,39 @@ export async function initSchedule() {
|
||||
updatePreferenceHints();
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
ruleForm.addEventListener('input', event => {
|
||||
if (shouldMarkRuleFormDirty({
|
||||
programmatic: syncingRuleForm,
|
||||
type: event.type,
|
||||
fromAsyncCombobox: Boolean(event.target.closest('.async-combobox'))
|
||||
})) dirtyScope.mark();
|
||||
});
|
||||
ruleForm.addEventListener('change', event => {
|
||||
if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark();
|
||||
});
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
}
|
||||
|
||||
async function loadBaseLists() {
|
||||
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
|
||||
[subjects, lessonTypes] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/subgroups')
|
||||
api.get('/api/lesson-types')
|
||||
]);
|
||||
subgroups = [];
|
||||
populateSubjectSelects();
|
||||
await searchRuleGroups('');
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
populateSemesterSelects();
|
||||
const selected = resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderVersionPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
@@ -368,15 +427,27 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadScheduleVersions(preferredVersionId = null) {
|
||||
const responses = await Promise.all(semesters.map(semester =>
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
|
||||
));
|
||||
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
|
||||
const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
|
||||
if (!semesterId) {
|
||||
scheduleVersions = [];
|
||||
scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
|
||||
updateVersionContext();
|
||||
setRuleEditorEnabled(false);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || '');
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
scheduleVersions = (await api.get(
|
||||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||||
)).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId
|
||||
|| localStorage.getItem('magistr.schedule.openVersionId')
|
||||
|| localStorage.getItem('magistr.schedule.versionId')
|
||||
|| '';
|
||||
localStorage.removeItem('magistr.schedule.openVersionId');
|
||||
const selected = chooseScheduleVersion(
|
||||
scheduleVersions,
|
||||
ruleSemesterSelect.value || defaultScheduleSemesterId(),
|
||||
semesterId,
|
||||
requestedVersionId
|
||||
);
|
||||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||||
@@ -388,6 +459,7 @@ export async function initSchedule() {
|
||||
].filter(Boolean).join('')
|
||||
: '<option value="">Расписания не созданы</option>';
|
||||
scheduleVersionSelect.value = selected ? String(selected.id) : '';
|
||||
lastVersionValue = scheduleVersionSelect.value;
|
||||
if (selected) {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
|
||||
}
|
||||
@@ -395,6 +467,64 @@ export async function initSchedule() {
|
||||
setRuleEditorEnabled(Boolean(selected));
|
||||
}
|
||||
|
||||
async function handleVersionYearChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
const yearId = scheduleVersionYearSelect?.value || '';
|
||||
const yearSemesters = semestersForAcademicYear(semesters, yearId);
|
||||
const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) });
|
||||
renderVersionPeriodSelects(yearId, selected.semesterId);
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function handleVersionSemesterChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function applyVersionPeriodChange() {
|
||||
lastVersionYearValue = scheduleVersionYearSelect?.value || '';
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || '';
|
||||
localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue);
|
||||
localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue);
|
||||
try {
|
||||
await loadScheduleVersions();
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersionPeriodSelects(academicYearId, semesterId) {
|
||||
if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return;
|
||||
const years = academicYearsFromSemesters(semesters);
|
||||
const selectedYearId = String(academicYearId || years[0]?.id || '');
|
||||
scheduleVersionYearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
scheduleVersionYearSelect.value = selectedYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(semesters, selectedYearId);
|
||||
scheduleVersionSemesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(shortSemesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
||||
lastVersionYearValue = scheduleVersionYearSelect.value;
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
||||
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
if (!versions.length) return '';
|
||||
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
|
||||
@@ -405,12 +535,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function defaultScheduleSemesterId() {
|
||||
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
|
||||
if (saved) return saved.id;
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||
return current?.id || semesters[0]?.id || '';
|
||||
return resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
}).semesterId;
|
||||
}
|
||||
|
||||
function updateVersionContext() {
|
||||
@@ -972,7 +1101,12 @@ export async function initSchedule() {
|
||||
|
||||
async function deleteRuleFromVisual(rule) {
|
||||
closeVisualContextMenu();
|
||||
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + rule.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -1265,7 +1399,12 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить правило расписания?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -1302,7 +1441,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function finishSuccessfulRuleSave(saved) {
|
||||
const mode = ruleIdInput.value ? 'update' : 'create';
|
||||
const versionId = scheduleVersionSelect.value;
|
||||
dirtyScope.clear();
|
||||
resetRuleForm();
|
||||
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
await loadRules();
|
||||
}
|
||||
@@ -1427,6 +1570,8 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderConflictSlots(rule, conflictFields = []) {
|
||||
conflictComboboxes.forEach(combobox => combobox.destroy());
|
||||
conflictComboboxes = [];
|
||||
const slots = rule.slots || [];
|
||||
if (!conflictSlotsContainer) return;
|
||||
if (!slots.length) {
|
||||
@@ -1450,11 +1595,12 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" aria-label="Преподаватель конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" aria-label="Аудитория конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
|
||||
}
|
||||
|
||||
function conflictFieldClass(field, fields) {
|
||||
@@ -1578,7 +1724,7 @@ export async function initSchedule() {
|
||||
|
||||
function buildRulePayload() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
const slots = readRuleSlots(true).map(ruleSlotToPayload);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
@@ -1597,17 +1743,22 @@ export async function initSchedule() {
|
||||
};
|
||||
}
|
||||
|
||||
function fillRuleForm(rule) {
|
||||
async function fillRuleForm(rule) {
|
||||
ruleFormTitle.textContent = 'Редактирование правила расписания';
|
||||
ruleIdInput.value = rule.id;
|
||||
ruleSubjectSelect.value = rule.subjectId || '';
|
||||
ruleSemesterSelect.value = rule.semesterId || '';
|
||||
fillTypeLimits(rule);
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
|
||||
renderRuleGroups(rule.groupIds || []);
|
||||
searchRuleGroups(ruleGroupSearch?.value || '');
|
||||
ruleSlotsContainer.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Загрузка подгрупп выбранных групп...</div>';
|
||||
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
|
||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
@@ -1616,11 +1767,14 @@ export async function initSchedule() {
|
||||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||||
ruleIdInput.value = '';
|
||||
fillTypeLimits({});
|
||||
subgroupRequest += 1;
|
||||
subgroups = [];
|
||||
renderRuleGroups([]);
|
||||
renderRuleSlots([{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
}
|
||||
|
||||
function renderRuleGroups(selectedIds) {
|
||||
@@ -1640,7 +1794,60 @@ export async function initSchedule() {
|
||||
updateRuleGroupsText();
|
||||
}
|
||||
|
||||
async function searchRuleGroups(query = '') {
|
||||
const request = ++groupSearchRequest;
|
||||
const selectedBeforeSearch = selectedRuleGroupIds();
|
||||
if (!groups.length) {
|
||||
ruleGroupCheckboxes.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Поиск групп...</div>';
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
|
||||
const loaded = await api.get(`/api/groups/options?${params}`);
|
||||
if (request !== groupSearchRequest) return;
|
||||
const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch;
|
||||
const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id)));
|
||||
groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
|
||||
renderRuleGroups(selectedIds);
|
||||
} catch (error) {
|
||||
if (request !== groupSearchRequest) return;
|
||||
ruleGroupCheckboxes.innerHTML = `<div class="loading-row view-state-error">${escapeHtml(error.message || 'Не удалось найти группы')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroupsForGroups(groupIds) {
|
||||
const request = ++subgroupRequest;
|
||||
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
|
||||
if (!normalizedIds.length) {
|
||||
subgroups = [];
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
normalizedIds.forEach(groupId => params.append('groupId', groupId));
|
||||
const loaded = await api.get(`/api/subgroups?${params}`);
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = loaded || [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = [];
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheRuleGroups(ids, names) {
|
||||
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
|
||||
groups = mergeEntitiesById([...groups, ...additions]);
|
||||
}
|
||||
|
||||
function mergeEntitiesById(items) {
|
||||
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
|
||||
}
|
||||
|
||||
function renderRuleSlots(slots) {
|
||||
slotComboboxes.forEach(combobox => combobox.destroy());
|
||||
slotComboboxes = [];
|
||||
const normalizedSlots = slots?.length ? slots : [{}];
|
||||
const isSingleSlot = normalizedSlots.length === 1;
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
@@ -1650,8 +1857,8 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" aria-label="Аудитория слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
@@ -1659,6 +1866,7 @@ export async function initSchedule() {
|
||||
<div class="schedule-preference-hint" data-preference-hint></div>
|
||||
</div>
|
||||
`).join('');
|
||||
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
@@ -1721,6 +1929,8 @@ export async function initSchedule() {
|
||||
subgroupIds,
|
||||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||
teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')),
|
||||
classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')),
|
||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||
lessonFormat: row.querySelector('.slot-format').value
|
||||
};
|
||||
@@ -2022,13 +2232,49 @@ export async function initSchedule() {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
|
||||
function toTeacherOption(teacher) {
|
||||
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
|
||||
function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
|
||||
container.querySelectorAll(teacherSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите преподавателя',
|
||||
emptyLabel: 'Преподаватели не найдены',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/users/teachers/options?${params}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
container.querySelectorAll(classroomSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите аудиторию',
|
||||
emptyLabel: 'Аудитории не найдены',
|
||||
optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`,
|
||||
optionDescription: classroom => [
|
||||
classroom.building,
|
||||
classroom.floor != null ? `${classroom.floor} этаж` : '',
|
||||
classroom.capacity ? `${classroom.capacity} мест` : ''
|
||||
].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/classrooms/options?${params}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function toClassroomOption(classroom) {
|
||||
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
|
||||
return { value: classroom.id, label: `${classroom.name}${capacity}` };
|
||||
function selectedEntityOption(id, label) {
|
||||
return id
|
||||
? `<option value="${escapeHtml(String(id))}" selected>${escapeHtml(label || `ID ${id}`)}</option>`
|
||||
: '<option value="" selected></option>';
|
||||
}
|
||||
|
||||
function selectedOptionLabel(select) {
|
||||
return select?.selectedOptions?.[0]?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
function toLessonTypeOption(lessonType) {
|
||||
@@ -2098,9 +2344,16 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
const targets = selects.filter(Boolean);
|
||||
const previousSyncState = syncingRuleForm;
|
||||
if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true;
|
||||
try {
|
||||
targets.forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
} finally {
|
||||
syncingRuleForm = previousSyncState;
|
||||
}
|
||||
}
|
||||
|
||||
function visualCellKey(groupId, dayOfWeek, slotKey) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
export async function initSubjects() {
|
||||
const subjectsTbody = document.getElementById('subjects-tbody');
|
||||
const createSubjectForm = document.getElementById('create-subject-form');
|
||||
const newSubjectDeptSelect = document.getElementById('new-subject-department');
|
||||
const departmentFilter = document.getElementById('subjects-department-filter');
|
||||
const searchInput = document.getElementById('subjects-search');
|
||||
const sortSelect = document.getElementById('subjects-sort');
|
||||
const pagination = document.getElementById('subjects-pagination');
|
||||
|
||||
// Модалка управления преподавателями
|
||||
const modalManageTeachers = document.getElementById('modal-manage-teachers');
|
||||
@@ -16,9 +26,19 @@ export async function initSubjects() {
|
||||
const manageTsTbody = document.getElementById('manage-ts-tbody');
|
||||
|
||||
let allSubjects = [];
|
||||
let allTeachers = [];
|
||||
let allDepartments = [];
|
||||
let teacherSubjects = [];
|
||||
let teacherCombobox = null;
|
||||
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.subjectQuery,
|
||||
departmentId: restored.subjectDepartment,
|
||||
page: Math.max(Number(restored.subjectPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
|
||||
sort: restored.subjectSort
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
@@ -34,15 +54,43 @@ export async function initSubjects() {
|
||||
modalManageTeachers.addEventListener('click', (e) => {
|
||||
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
}));
|
||||
departmentFilter.addEventListener('change', () => {
|
||||
pageState.departmentId = departmentFilter.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[allTeachers, allDepartments] = await Promise.all([
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/departments')
|
||||
]);
|
||||
allDepartments = await api.get('/api/departments');
|
||||
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const teachers = await api.get(`/api/users/teachers/options?${params}`);
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
const assigned = new Set(teacherSubjects
|
||||
.filter(item => String(item.subjectId) === String(subjectId))
|
||||
.map(item => String(item.userId)));
|
||||
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||||
}
|
||||
});
|
||||
populateDepartmentsSelect();
|
||||
populateDepartmentFilter();
|
||||
await loadSubjects();
|
||||
} catch (e) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||||
@@ -58,22 +106,58 @@ export async function initSubjects() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateDepartmentFilter() {
|
||||
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
|
||||
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
|
||||
).join('');
|
||||
departmentFilter.value = pageState.departmentId;
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
|
||||
hideAlert('subjects-alert');
|
||||
try {
|
||||
[allSubjects, teacherSubjects] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/teacher-subjects')
|
||||
]);
|
||||
const [sort, direction] = pageState.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: pageState.query,
|
||||
page: String(pageState.page),
|
||||
size: String(pageState.size),
|
||||
sort,
|
||||
direction
|
||||
});
|
||||
if (pageState.departmentId) params.set('departmentId', pageState.departmentId);
|
||||
const page = await api.get(`/api/subjects/page?${params}`);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && pageState.page > 0) {
|
||||
pageState.page = Math.max(0, page.totalPages - 1);
|
||||
return loadSubjects();
|
||||
}
|
||||
allSubjects = page.items || [];
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjects();
|
||||
renderPagination(pagination, page, {
|
||||
label: 'дисциплин',
|
||||
onPage: nextPage => { pageState.page = nextPage; loadSubjects(); },
|
||||
onSize: size => { pageState.size = size; pageState.page = 0; loadSubjects(); }
|
||||
});
|
||||
persistState();
|
||||
trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: pageState.page });
|
||||
} catch (e) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherAssignments(subjectIds) {
|
||||
if (!subjectIds.length) return [];
|
||||
const params = new URLSearchParams();
|
||||
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
|
||||
return api.get(`/api/teacher-subjects?${params}`);
|
||||
}
|
||||
|
||||
function renderSubjects() {
|
||||
if (!allSubjects || !allSubjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
|
||||
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
|
||||
? 'Дисциплины не найдены'
|
||||
: 'Дисциплин пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,6 +190,7 @@ export async function initSubjects() {
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
markTableReady(subjectsTbody);
|
||||
}
|
||||
|
||||
async function createSubject(e) {
|
||||
@@ -139,12 +224,17 @@ export async function initSubjects() {
|
||||
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать дисциплину?',
|
||||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,22 +272,10 @@ export async function initSubjects() {
|
||||
}
|
||||
|
||||
function populateTeachersSelect(subjectId) {
|
||||
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
|
||||
const assignedUserIds = new Set(
|
||||
teacherSubjects
|
||||
.filter(ts => String(ts.subjectId) === String(subjectId))
|
||||
.map(ts => String(ts.userId))
|
||||
);
|
||||
|
||||
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
|
||||
|
||||
if (!availableTeachers.length) {
|
||||
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
|
||||
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
|
||||
manageTsTeacherSelect.value = '';
|
||||
teacherCombobox?.syncFromSelect();
|
||||
teacherCombobox?.search('');
|
||||
}
|
||||
|
||||
async function saveTeacherAssignment(e) {
|
||||
@@ -219,7 +297,7 @@ export async function initSubjects() {
|
||||
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
|
||||
|
||||
// Перезагрузим привязки и обновим интерфейс
|
||||
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
@@ -233,7 +311,12 @@ export async function initSubjects() {
|
||||
async function handleTeacherAssignmentTableClick(e) {
|
||||
const btnDelete = e.target.closest('.btn-delete-ts');
|
||||
if (!btnDelete) return;
|
||||
if (!confirm('Удалить привязку преподавателя?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить привязку преподавателя?',
|
||||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||||
confirmLabel: 'Удалить привязку',
|
||||
danger: true
|
||||
})) return;
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
|
||||
try {
|
||||
@@ -243,14 +326,14 @@ export async function initSubjects() {
|
||||
});
|
||||
|
||||
// Перезагрузим привязки
|
||||
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
// Обновим главную таблицу
|
||||
renderSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления привязки');
|
||||
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,4 +343,14 @@ export async function initSubjects() {
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
subjectQuery: pageState.query,
|
||||
subjectDepartment: pageState.departmentId,
|
||||
subjectPage: pageState.page || '',
|
||||
subjectSize: pageState.size === 25 ? '' : pageState.size,
|
||||
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { alertAction } from '../dialog.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
|
||||
|
||||
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
export async function initTeacherAbsences() {
|
||||
const session = getSession();
|
||||
const role = session?.role;
|
||||
@@ -27,6 +36,20 @@ export async function initTeacherAbsences() {
|
||||
let preferenceItems = [];
|
||||
let changeItems = [];
|
||||
|
||||
new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
return api.get(teacherAbsenceTeacherOptionsUrl({
|
||||
query,
|
||||
selectedId,
|
||||
role,
|
||||
departmentId: session?.departmentId
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
setDefaultPeriod(startInput, endInput);
|
||||
form?.addEventListener('submit', createAbsence);
|
||||
statusFilter?.addEventListener('change', loadAbsences);
|
||||
@@ -46,21 +69,7 @@ export async function initTeacherAbsences() {
|
||||
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
|
||||
changeReviewList?.addEventListener('click', handleChangeReview);
|
||||
|
||||
await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadTeachers() {
|
||||
try {
|
||||
let teachers = await api.get('/api/users/teachers');
|
||||
if (role === 'DEPARTMENT' && session?.departmentId) {
|
||||
teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId));
|
||||
}
|
||||
teacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' + teachers.map(item => `
|
||||
<option value="${escapeHtml(item.id)}">${escapeHtml(item.fullName || item.username || `ID ${item.id}`)}</option>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
teacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Не удалось загрузить преподавателей')}</option>`;
|
||||
}
|
||||
}
|
||||
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadAbsences() {
|
||||
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
@@ -167,7 +176,7 @@ export async function initTeacherAbsences() {
|
||||
await openWizard(id);
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось выполнить действие');
|
||||
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -328,7 +337,7 @@ export async function initTeacherAbsences() {
|
||||
const approved = button.dataset.preferenceReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
@@ -339,7 +348,7 @@ export async function initTeacherAbsences() {
|
||||
});
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось рассмотреть пожелание');
|
||||
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -397,7 +406,7 @@ export async function initTeacherAbsences() {
|
||||
const approved = button.dataset.changeReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
@@ -405,7 +414,7 @@ export async function initTeacherAbsences() {
|
||||
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
|
||||
await Promise.all([loadChangeReviews(), loadAbsences()]);
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось применить решение');
|
||||
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
|
||||
import { promptAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
export async function initTeacherRequests() {
|
||||
const requestsTbody = document.getElementById('teacher-requests-tbody');
|
||||
const refreshButton = document.getElementById('teacher-requests-refresh');
|
||||
const requestsCount = document.getElementById('teacher-requests-count');
|
||||
const searchInput = document.getElementById('teacher-requests-search');
|
||||
const statusSelect = document.getElementById('teacher-requests-status');
|
||||
const pagination = document.getElementById('teacher-requests-pagination');
|
||||
let departments = [];
|
||||
const restored = readUrlState({ requestQuery: '', requestStatus: 'PENDING', requestPage: '0', requestSize: '25' });
|
||||
const state = {
|
||||
query: restored.requestQuery,
|
||||
status: restored.requestStatus,
|
||||
page: Math.max(Number(restored.requestPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.requestSize)) ? Number(restored.requestSize) : 25
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
statusSelect.value = state.status;
|
||||
|
||||
async function loadDepartments() {
|
||||
departments = await api.get('/api/departments');
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
|
||||
hideAlert('teacher-requests-alert');
|
||||
try {
|
||||
const requests = await api.get('/api/teacher-requests?status=PENDING');
|
||||
const normalizedRequests = Array.isArray(requests) ? requests : [];
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort: 'createdAt',
|
||||
direction: 'desc'
|
||||
});
|
||||
if (state.status) params.set('status', state.status);
|
||||
const page = await api.get(`/api/teacher-requests/page?${params}`);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && state.page > 0) {
|
||||
state.page = Math.max(0, page.totalPages - 1);
|
||||
return loadRequests();
|
||||
}
|
||||
const normalizedRequests = page.items || [];
|
||||
renderRequests(normalizedRequests);
|
||||
updateCount(normalizedRequests.length);
|
||||
updateCount(Number(page.totalItems || 0));
|
||||
renderPagination(pagination, page, {
|
||||
label: 'заявок',
|
||||
onPage: nextPage => { state.page = nextPage; loadRequests(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadRequests(); }
|
||||
});
|
||||
persistState();
|
||||
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
|
||||
detail: { count: normalizedRequests.length }
|
||||
detail: { count: state.status === 'PENDING' && !state.query ? Number(page.totalItems || 0) : undefined }
|
||||
}));
|
||||
trackUxEvent('реестр_заявок_загружен', { total: Number(page.totalItems || 0), page: state.page });
|
||||
} catch (error) {
|
||||
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
|
||||
renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
|
||||
updateCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequests(requests) {
|
||||
if (!requests.length) {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
|
||||
renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
|
||||
return;
|
||||
}
|
||||
requestsTbody.innerHTML = requests.map(request => `
|
||||
@@ -51,12 +87,15 @@ export async function initTeacherRequests() {
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>
|
||||
<div class="teacher-request-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
${request.status === 'PENDING' ? `
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(requestsTbody);
|
||||
}
|
||||
|
||||
function updateCount(count) {
|
||||
@@ -98,7 +137,18 @@ export async function initTeacherRequests() {
|
||||
}
|
||||
|
||||
async function rejectRequest(requestId) {
|
||||
const reviewComment = prompt('Причина отклонения');
|
||||
const reviewComment = await promptAction({
|
||||
title: 'Отклонить заявку?',
|
||||
message: 'Комментарий увидит кафедра, отправившая заявку.',
|
||||
confirmLabel: 'Отклонить',
|
||||
danger: true,
|
||||
input: {
|
||||
label: 'Причина отклонения',
|
||||
placeholder: 'Укажите, что нужно исправить',
|
||||
required: true,
|
||||
requiredMessage: 'Укажите причину отклонения'
|
||||
}
|
||||
});
|
||||
if (reviewComment === null) return;
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
|
||||
@@ -110,6 +160,16 @@ export async function initTeacherRequests() {
|
||||
}
|
||||
|
||||
refreshButton?.addEventListener('click', loadRequests);
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
}));
|
||||
statusSelect.addEventListener('change', () => {
|
||||
state.status = statusSelect.value;
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
});
|
||||
|
||||
requestsTbody.addEventListener('click', async (event) => {
|
||||
const approveButton = event.target.closest('.teacher-request-approve');
|
||||
@@ -133,4 +193,13 @@ export async function initTeacherRequests() {
|
||||
} catch (error) {
|
||||
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
requestQuery: state.query,
|
||||
requestStatus: state.status === 'PENDING' ? '' : state.status,
|
||||
requestPage: state.page || '',
|
||||
requestSize: state.size === 25 ? '' : state.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
|
||||
export async function initUniversityStructure() {
|
||||
const deptTbody = document.getElementById('departments-tbody');
|
||||
@@ -179,7 +180,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить кафедру?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/departments/' + deleteBtn.dataset.id);
|
||||
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
|
||||
@@ -252,7 +253,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить специальность?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
|
||||
showAlert('create-spec-alert', 'Специальность удалена', 'success');
|
||||
@@ -390,7 +391,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить профиль обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
|
||||
showAlert('manage-profile-alert', 'Профиль удален', 'success');
|
||||
@@ -517,7 +518,7 @@ export async function initUniversityStructure() {
|
||||
if (deleteBtn) {
|
||||
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
|
||||
if (!profile) return;
|
||||
if (!confirm('Удалить профиль обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
|
||||
showAlert('tab-profile-alert', 'Профиль удален', 'success');
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
ADMIN: 'Администратор',
|
||||
@@ -21,21 +26,53 @@ const ROLE_BADGE = {
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const searchInput = document.getElementById('users-search');
|
||||
const roleFilter = document.getElementById('users-role-filter');
|
||||
const sortSelect = document.getElementById('users-sort');
|
||||
const pagination = document.getElementById('users-pagination');
|
||||
const restored = readUrlState({ userQuery: '', userRole: '', userPage: '0', userSize: '25', userSort: 'fullName,asc' });
|
||||
const state = {
|
||||
query: restored.userQuery,
|
||||
role: restored.userRole,
|
||||
page: Math.max(Number(restored.userPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.userSize)) ? Number(restored.userSize) : 25,
|
||||
sort: restored.userSort
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
roleFilter.value = state.role;
|
||||
sortSelect.value = state.sort;
|
||||
|
||||
async function loadUsers() {
|
||||
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
|
||||
hideAlert('users-alert');
|
||||
try {
|
||||
const users = await api.get('/api/users?includeArchived=true');
|
||||
renderUsers(users);
|
||||
const [sort, direction] = state.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
if (state.role) params.set('role', state.role);
|
||||
const result = await api.get(`/api/users/page?${params}`);
|
||||
renderUsers(result.items || []);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'пользователей',
|
||||
onPage: page => { state.page = page; loadUsers(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadUsers(); }
|
||||
});
|
||||
persistState();
|
||||
trackUxEvent('реестр_пользователей_загружен', { total: Number(result.totalItems || 0), page: state.page });
|
||||
} catch (e) {
|
||||
usersTbody.innerHTML =
|
||||
'<tr><td colspan="7" class="loading-row">Ошибка загрузки: ' +
|
||||
escapeHtml(e.message) + '</td></tr>';
|
||||
renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
if (!users || !users.length) {
|
||||
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
|
||||
renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +91,25 @@ export async function initUsers() {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(usersTbody);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
}));
|
||||
roleFilter.addEventListener('change', () => {
|
||||
state.role = roleFilter.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
state.sort = sortSelect.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
createForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-alert');
|
||||
@@ -97,12 +151,17 @@ export async function initUsers() {
|
||||
const deleteBtn = e.target.closest('.btn-archive-user');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать пользователя?',
|
||||
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка архивирования');
|
||||
showAlert('users-alert', err.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,9 +173,19 @@ export async function initUsers() {
|
||||
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка восстановления');
|
||||
showAlert('users-alert', err.message || 'Ошибка восстановления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
userQuery: state.query,
|
||||
userRole: state.role,
|
||||
userPage: state.page || '',
|
||||
userSize: state.size === 25 ? '' : state.size,
|
||||
userSort: state.sort === 'fullName,asc' ? '' : state.sort
|
||||
});
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export async function initDatabase() {
|
||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||
@@ -140,14 +141,19 @@ export async function initDatabase() {
|
||||
if (!btn) return;
|
||||
|
||||
const domain = btn.dataset.domain;
|
||||
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление тенанта',
|
||||
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
|
||||
confirmLabel: 'Удалить тенант',
|
||||
danger: true
|
||||
})) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/database/tenants/${domain}`);
|
||||
loadTenants();
|
||||
loadStatus();
|
||||
} catch (e) {
|
||||
alert('Не удалось удалить тенант');
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export let allEducationForms = [];
|
||||
|
||||
@@ -58,12 +59,12 @@ export async function initEduForms() {
|
||||
efTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete-edu-form');
|
||||
if (!btn) return;
|
||||
if (!confirm('Удалить форму обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/education-forms/' + btn.dataset.id);
|
||||
loadEF();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Ошибка удаления');
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
|
||||
import { confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
const APPLY_MODE_LABELS = {
|
||||
DEFAULT: 'применяется по умолчанию',
|
||||
@@ -185,7 +186,12 @@ export async function initTimeSlotsSettings() {
|
||||
async function deleteActiveScope() {
|
||||
const scope = selectedScope();
|
||||
if (!scope || scope.systemScope) return;
|
||||
if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление сетки времени',
|
||||
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
|
||||
confirmLabel: 'Удалить сетку',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
|
||||
activeScopeId = defaultScope()?.id || null;
|
||||
@@ -244,7 +250,7 @@ export async function initTimeSlotsSettings() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить временной слот?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
||||
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
||||
|
||||
@@ -5,82 +5,6 @@
|
||||
</div>
|
||||
|
||||
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
|
||||
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="academic-year-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Год начала учебного года</label>
|
||||
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
|
||||
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Год окончания учебного года</label>
|
||||
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
|
||||
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
|
||||
<div class="academic-year-semester-builder-head">
|
||||
<div>
|
||||
<h3 id="academic-year-semesters-title">Семестры</h3>
|
||||
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
|
||||
</div>
|
||||
<span class="academic-year-required-badge">Обязательно</span>
|
||||
</div>
|
||||
<div class="academic-year-semester-grid">
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
|
||||
<input type="hidden" id="academic-year-autumn-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
|
||||
<strong>Осенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-start">Начало</label>
|
||||
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-end">Окончание</label>
|
||||
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="spring">
|
||||
<input type="hidden" id="academic-year-spring-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
|
||||
<strong>Весенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-start">Начало</label>
|
||||
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-end">Окончание</label>
|
||||
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="academic-year-form-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Учебные годы</h2>
|
||||
@@ -173,6 +97,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
|
||||
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="academic-year-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Год начала учебного года</label>
|
||||
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
|
||||
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Год окончания учебного года</label>
|
||||
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
|
||||
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
|
||||
<div class="academic-year-semester-builder-head">
|
||||
<div>
|
||||
<h3 id="academic-year-semesters-title">Семестры</h3>
|
||||
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
|
||||
</div>
|
||||
<span class="academic-year-required-badge">Обязательно</span>
|
||||
</div>
|
||||
<div class="academic-year-semester-grid">
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
|
||||
<input type="hidden" id="academic-year-autumn-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
|
||||
<strong>Осенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-start">Начало</label>
|
||||
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-end">Окончание</label>
|
||||
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="spring">
|
||||
<input type="hidden" id="academic-year-spring-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
|
||||
<strong>Весенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-start">Начало</label>
|
||||
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-end">Окончание</label>
|
||||
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="academic-year-form-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
|
||||
|
||||
@@ -71,6 +71,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="groups-search">Поиск</label>
|
||||
<input type="search" id="groups-search" placeholder="Название, код специальности, профиль">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="groups-sort">Сортировка</label>
|
||||
<select id="groups-sort" data-native-select="true">
|
||||
<option value="name,asc">По названию А–Я</option>
|
||||
<option value="name,desc">По названию Я–А</option>
|
||||
<option value="yearStartStudy,desc">Сначала новые наборы</option>
|
||||
<option value="groupSize,desc">Сначала крупные</option>
|
||||
<option value="status,asc">По статусу</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="groups-filter-reset">Сбросить фильтры</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="groups-table">
|
||||
<thead>
|
||||
@@ -94,6 +111,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="groups-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
@@ -232,7 +250,7 @@
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2; margin-bottom: 0;">
|
||||
<label for="manage-calendar-id">Календарный график</label>
|
||||
<select id="manage-calendar-id" required>
|
||||
<select id="manage-calendar-id" data-native-select="true" required>
|
||||
<option value="">Выберите учебный год</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="quality-run-panel">
|
||||
<label for="quality-year">Учебный год</label>
|
||||
<select id="quality-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
<label for="quality-semester">Семестр</label>
|
||||
<select id="quality-semester" data-native-select="true">
|
||||
<option value="">Загрузка семестров…</option>
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
<label for="quality-version">Версия</label>
|
||||
<select id="quality-version" data-native-select="true">
|
||||
|
||||
@@ -6,10 +6,20 @@
|
||||
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
|
||||
</div>
|
||||
<div class="version-hero-controls">
|
||||
<label for="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" data-native-select="true">
|
||||
<option value="">Загрузка семестров…</option>
|
||||
</select>
|
||||
<div class="version-hero-period-grid">
|
||||
<div>
|
||||
<label for="version-year">Учебный год</label>
|
||||
<select id="version-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -21,21 +21,21 @@
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
||||
<label for="schedule-view-group">Группа</label>
|
||||
<select id="schedule-view-group">
|
||||
<select id="schedule-view-group" data-native-select="true">
|
||||
<option value="">Все группы</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
||||
<label for="schedule-view-teacher">Преподаватель</label>
|
||||
<select id="schedule-view-teacher">
|
||||
<select id="schedule-view-teacher" data-native-select="true">
|
||||
<option value="">Все преподаватели</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
||||
<label for="schedule-view-classroom">Аудитория</label>
|
||||
<select id="schedule-view-classroom">
|
||||
<select id="schedule-view-classroom" data-native-select="true">
|
||||
<option value="">Все аудитории</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -53,10 +53,16 @@
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<div class="schedule-view-extra-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-year">Учебный год</label>
|
||||
<select id="schedule-view-year">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-semester">Семестр</label>
|
||||
<select id="schedule-view-semester">
|
||||
<option value="">По текущей дате</option>
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<div class="schedule-initial-state" id="schedule-initial-state" role="status" aria-live="polite">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<span>Загружаем конструктор правил…</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-initial-content is-loading" id="schedule-initial-content" aria-busy="true">
|
||||
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
|
||||
<div>
|
||||
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
|
||||
@@ -5,12 +11,28 @@
|
||||
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
|
||||
</div>
|
||||
<div class="version-constructor-controls">
|
||||
<label for="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" data-native-select="true">
|
||||
<option value="">Загрузка расписаний…</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
|
||||
<div class="version-constructor-field">
|
||||
<label for="schedule-version-year">Учебный год</label>
|
||||
<select id="schedule-version-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-field">
|
||||
<label for="schedule-version-semester">Семестр</label>
|
||||
<select id="schedule-version-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-field version-constructor-field-wide">
|
||||
<label for="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" data-native-select="true">
|
||||
<option value="">Сначала выберите семестр</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -106,6 +128,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="schedule-rule-groups-menu">
|
||||
<input type="search" id="schedule-rule-group-search" class="schedule-group-search"
|
||||
placeholder="Найти группу" aria-label="Поиск групп" autocomplete="off">
|
||||
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +223,15 @@
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="schedule-table">
|
||||
<colgroup>
|
||||
<col class="schedule-rule-col-id">
|
||||
<col class="schedule-rule-col-subject">
|
||||
<col class="schedule-rule-col-semester">
|
||||
<col class="schedule-rule-col-groups">
|
||||
<col class="schedule-rule-col-load">
|
||||
<col class="schedule-rule-col-slots">
|
||||
<col class="schedule-rule-col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -272,3 +305,4 @@
|
||||
<div class="loading-row schedule-visual-state">Загрузка...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,26 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Все дисциплины</h2>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="subjects-search">Поиск</label>
|
||||
<input type="search" id="subjects-search" placeholder="Название или код дисциплины">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subjects-department-filter">Кафедра</label>
|
||||
<select id="subjects-department-filter" data-native-select="true"><option value="">Все кафедры</option></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subjects-sort">Сортировка</label>
|
||||
<select id="subjects-sort" data-native-select="true">
|
||||
<option value="name,asc">По названию А–Я</option>
|
||||
<option value="name,desc">По названию Я–А</option>
|
||||
<option value="code,asc">По коду</option>
|
||||
<option value="departmentId,asc">По кафедре</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="subjects-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="subjects-table">
|
||||
<thead>
|
||||
@@ -44,6 +64,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="subjects-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно привязки Преподавателей -->
|
||||
@@ -65,7 +86,7 @@
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-ts-teacher-select">Преподаватель</label>
|
||||
<select id="manage-ts-teacher-select" required>
|
||||
<select id="manage-ts-teacher-select" data-native-select="true" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="absence-form-grid">
|
||||
<div class="form-group absence-teacher-field">
|
||||
<label for="teacher-absence-teacher">Преподаватель</label>
|
||||
<select id="teacher-absence-teacher" required>
|
||||
<select id="teacher-absence-teacher" data-native-select="true" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="teacher-requests-search">Поиск</label>
|
||||
<input type="search" id="teacher-requests-search" placeholder="ФИО, логин, должность или кафедра">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-requests-status">Статус</label>
|
||||
<select id="teacher-requests-status" data-native-select="true">
|
||||
<option value="PENDING">Ожидают рассмотрения</option>
|
||||
<option value="APPROVED">Одобрены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -25,4 +40,5 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="teacher-requests-pagination"></div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,36 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все пользователи</h2>
|
||||
<h2>Все пользователи</h2>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="users-search">Поиск</label>
|
||||
<input type="search" id="users-search" placeholder="Логин, ФИО или должность">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="users-role-filter">Роль</label>
|
||||
<select id="users-role-filter" data-native-select="true">
|
||||
<option value="">Все роли</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="DEPARTMENT">Кафедра</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="TEACHER">Преподаватель</option>
|
||||
<option value="STUDENT">Студент</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="users-sort">Сортировка</label>
|
||||
<select id="users-sort" data-native-select="true">
|
||||
<option value="fullName,asc">По ФИО А–Я</option>
|
||||
<option value="fullName,desc">По ФИО Я–А</option>
|
||||
<option value="username,asc">По логину</option>
|
||||
<option value="role,asc">По роли</option>
|
||||
<option value="status,asc">По статусу</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="users-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
@@ -61,5 +90,6 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination" id="users-pagination"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user