499 lines
22 KiB
JavaScript
499 lines
22 KiB
JavaScript
import { api } from '../api.js';
|
||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||
|
||
const DAY_OPTIONS = [
|
||
{ value: 1, label: 'Понедельник' },
|
||
{ value: 2, label: 'Вторник' },
|
||
{ value: 3, label: 'Среда' },
|
||
{ value: 4, label: 'Четверг' },
|
||
{ value: 5, label: 'Пятница' },
|
||
{ value: 6, label: 'Суббота' },
|
||
{ value: 7, label: 'Воскресенье' }
|
||
];
|
||
|
||
const PARITY_LABELS = {
|
||
BOTH: 'каждую неделю',
|
||
ODD: 'нечётная',
|
||
EVEN: 'чётная'
|
||
};
|
||
|
||
const SEMESTER_LABELS = {
|
||
autumn: 'осенний',
|
||
spring: 'весенний'
|
||
};
|
||
|
||
const LESSON_TYPE_LIMITS = [
|
||
{
|
||
key: 'lecture',
|
||
label: 'Лекции',
|
||
hoursField: 'lectureAcademicHours',
|
||
startWeekField: 'lectureStartWeek',
|
||
hoursInputId: 'schedule-rule-lecture-hours',
|
||
startWeekInputId: 'schedule-rule-lecture-start-week'
|
||
},
|
||
{
|
||
key: 'laboratory',
|
||
label: 'Лабораторные',
|
||
hoursField: 'laboratoryAcademicHours',
|
||
startWeekField: 'laboratoryStartWeek',
|
||
hoursInputId: 'schedule-rule-laboratory-hours',
|
||
startWeekInputId: 'schedule-rule-laboratory-start-week'
|
||
},
|
||
{
|
||
key: 'practice',
|
||
label: 'Практики',
|
||
hoursField: 'practiceAcademicHours',
|
||
startWeekField: 'practiceStartWeek',
|
||
hoursInputId: 'schedule-rule-practice-hours',
|
||
startWeekInputId: 'schedule-rule-practice-start-week'
|
||
}
|
||
];
|
||
|
||
export async function initSchedule() {
|
||
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 ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||
hours: document.getElementById(config.hoursInputId),
|
||
startWeek: document.getElementById(config.startWeekInputId)
|
||
}]));
|
||
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
|
||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||
const rulesTbody = document.getElementById('schedule-tbody');
|
||
const refreshRulesButton = document.getElementById('schedule-refresh');
|
||
|
||
let rules = [];
|
||
let subjects = [];
|
||
let groups = [];
|
||
let teachers = [];
|
||
let classrooms = [];
|
||
let lessonTypes = [];
|
||
let subgroups = [];
|
||
let timeSlots = [];
|
||
let semesters = [];
|
||
|
||
bindEvents();
|
||
|
||
try {
|
||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
|
||
renderRuleGroups([]);
|
||
renderRuleSlots([{}]);
|
||
await loadRules();
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||
}
|
||
|
||
function bindEvents() {
|
||
refreshRulesButton?.addEventListener('click', loadRules);
|
||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||
|
||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||
const removeButton = event.target.closest('.schedule-slot-remove');
|
||
if (!removeButton) return;
|
||
const slots = readRuleSlots(false);
|
||
slots.splice(Number(removeButton.dataset.index), 1);
|
||
renderRuleSlots(slots.length ? slots : [{}]);
|
||
});
|
||
|
||
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
|
||
ruleSlotsContainer.addEventListener('change', (event) => {
|
||
if (event.target.closest('.slot-lesson-type')) {
|
||
renderRuleSlots(readRuleSlots(false));
|
||
}
|
||
});
|
||
ruleForm.addEventListener('submit', saveRule);
|
||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||
}
|
||
|
||
async function loadBaseLists() {
|
||
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = 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')
|
||
]);
|
||
populateSubjectSelects();
|
||
}
|
||
|
||
async function loadYears() {
|
||
const academicYears = await api.get('/api/admin/calendar/years');
|
||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||
populateSemesterSelects();
|
||
}
|
||
|
||
async function loadRules() {
|
||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||
try {
|
||
rules = await api.get('/api/admin/schedule-rules');
|
||
renderRules();
|
||
} catch (error) {
|
||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderRules() {
|
||
if (!rules.length) {
|
||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||
return;
|
||
}
|
||
rulesTbody.innerHTML = rules.map(rule => `
|
||
<tr>
|
||
<td>${rule.id}</td>
|
||
<td>${escapeHtml(rule.subjectName || '-')}</td>
|
||
<td>${escapeHtml(formatSemester(rule))}</td>
|
||
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
|
||
<td>${renderRuleLoadSummary(rule)}</td>
|
||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||
<td>
|
||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderRuleSlotsSummary(slots) {
|
||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||
return slots.map(slot => {
|
||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||
const parts = [
|
||
slot.dayName,
|
||
slot.timeSlotLabel,
|
||
parity,
|
||
slot.subgroupName,
|
||
slot.teacherName,
|
||
slot.classroomName,
|
||
slot.lessonTypeName,
|
||
slot.lessonFormat
|
||
].filter(Boolean);
|
||
return `<div class="schedule-slot-summary">${escapeHtml(parts.join(' · '))}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderRuleLoadSummary(rule) {
|
||
const rows = LESSON_TYPE_LIMITS
|
||
.map(config => ({
|
||
label: config.label,
|
||
hours: Number(rule[config.hoursField] || 0),
|
||
startWeek: Number(rule[config.startWeekField] || 1)
|
||
}))
|
||
.filter(item => item.hours > 0);
|
||
if (!rows.length) return '<span class="muted">Часы не указаны</span>';
|
||
return rows.map(item => (
|
||
`<div class="schedule-slot-summary">${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}</div>`
|
||
)).join('');
|
||
}
|
||
|
||
async function handleRuleTableClick(event) {
|
||
const editButton = event.target.closest('.btn-edit-rule');
|
||
const deleteButton = event.target.closest('.btn-delete-rule');
|
||
|
||
if (editButton) {
|
||
const rule = rules.find(item => item.id == editButton.dataset.id)
|
||
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
||
fillRuleForm(rule);
|
||
return;
|
||
}
|
||
|
||
if (deleteButton) {
|
||
if (!confirm('Удалить правило расписания?')) return;
|
||
try {
|
||
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
|
||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||
await loadRules();
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveRule(event) {
|
||
event.preventDefault();
|
||
hideAlert('schedule-rule-alert');
|
||
try {
|
||
const payload = buildRulePayload();
|
||
const id = ruleIdInput.value;
|
||
const saved = id
|
||
? await api.put('/api/admin/schedule-rules/' + id, payload)
|
||
: await api.post('/api/admin/schedule-rules', payload);
|
||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||
resetRuleForm();
|
||
await loadRules();
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
|
||
}
|
||
}
|
||
|
||
function buildRulePayload() {
|
||
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
||
.map(input => Number(input.value));
|
||
const slots = readRuleSlots(true);
|
||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||
const typeLimits = readTypeLimits();
|
||
validateTypeLimits(typeLimits, slots);
|
||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||
return {
|
||
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
||
subjectId: Number(ruleSubjectSelect.value),
|
||
semesterId: Number(ruleSemesterSelect.value),
|
||
...typeLimits,
|
||
groupIds,
|
||
slots
|
||
};
|
||
}
|
||
|
||
function fillRuleForm(rule) {
|
||
ruleFormTitle.textContent = 'Редактирование правила расписания';
|
||
ruleIdInput.value = rule.id;
|
||
ruleSubjectSelect.value = rule.subjectId || '';
|
||
ruleSemesterSelect.value = rule.semesterId || '';
|
||
fillTypeLimits(rule);
|
||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||
renderRuleGroups(rule.groupIds || []);
|
||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||
hideAlert('schedule-rule-alert');
|
||
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
function resetRuleForm() {
|
||
ruleForm.reset();
|
||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||
ruleIdInput.value = '';
|
||
fillTypeLimits({});
|
||
renderRuleGroups([]);
|
||
renderRuleSlots([{}]);
|
||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||
hideAlert('schedule-rule-alert');
|
||
}
|
||
|
||
function renderRuleGroups(selectedIds) {
|
||
if (!groups.length) {
|
||
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
|
||
return;
|
||
}
|
||
const selected = new Set((selectedIds || []).map(String));
|
||
ruleGroupsContainer.innerHTML = groups.map(group => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
|
||
<span class="checkmark"></span>
|
||
<span>${escapeHtml(group.name)}</span>
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
function renderRuleSlots(slots) {
|
||
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
|
||
<div class="schedule-slot-row" data-index="${index}">
|
||
<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-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</select></div>
|
||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function readRuleSlots(validate) {
|
||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||
return rows.map((row, index) => {
|
||
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
|
||
const slot = {
|
||
id: null,
|
||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||
parity: row.querySelector('.slot-parity').value,
|
||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||
subgroupId: subgroupValue ? Number(subgroupValue) : null,
|
||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||
lessonFormat: row.querySelector('.slot-format').value
|
||
};
|
||
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
|
||
throw new Error(`Заполните слот ${index + 1}`);
|
||
}
|
||
if (validate) {
|
||
validateSlotSubgroup(slot, index);
|
||
}
|
||
return slot;
|
||
});
|
||
}
|
||
|
||
function readTypeLimits() {
|
||
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
|
||
const inputs = ruleTypeInputs[config.key];
|
||
payload[config.hoursField] = Number(inputs.hours.value || 0);
|
||
payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
|
||
return payload;
|
||
}, {});
|
||
}
|
||
|
||
function fillTypeLimits(rule) {
|
||
LESSON_TYPE_LIMITS.forEach(config => {
|
||
const inputs = ruleTypeInputs[config.key];
|
||
inputs.hours.value = rule[config.hoursField] ?? 0;
|
||
inputs.startWeek.value = rule[config.startWeekField] ?? 1;
|
||
});
|
||
}
|
||
|
||
function validateTypeLimits(typeLimits, slots) {
|
||
const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
|
||
let hasHours = false;
|
||
for (const config of LESSON_TYPE_LIMITS) {
|
||
const hours = typeLimits[config.hoursField];
|
||
const startWeek = typeLimits[config.startWeekField];
|
||
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
|
||
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
|
||
if (hours > 0) hasHours = true;
|
||
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
|
||
}
|
||
if (hours === 0 && selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
|
||
}
|
||
}
|
||
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||
}
|
||
|
||
function validateSlotSubgroup(slot, index) {
|
||
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
||
if (slot.subgroupId && typeKey !== 'laboratory') {
|
||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||
}
|
||
if (!slot.subgroupId) return;
|
||
|
||
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
|
||
if (!selectedSubgroup) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||
}
|
||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
||
}
|
||
}
|
||
|
||
async function loadTimeSlots() {
|
||
const slots = await api.get('/api/admin/time-slots');
|
||
timeSlots = slots
|
||
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||
}
|
||
|
||
function populateSubjectSelects() {
|
||
ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
||
subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
|
||
syncSelects(ruleSubjectSelect);
|
||
}
|
||
|
||
function populateSemesterSelects() {
|
||
const semesterOptions = '<option value="">Выберите семестр</option>' +
|
||
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
|
||
ruleSemesterSelect.innerHTML = semesterOptions;
|
||
syncSelects(ruleSemesterSelect);
|
||
}
|
||
|
||
function options(items, selectedValue) {
|
||
const selected = selectedValue == null ? '' : String(selectedValue);
|
||
return '<option value="">Выберите</option>' + items.map(item => {
|
||
const value = String(item.value);
|
||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function subgroupOptions(slot) {
|
||
if (!isLaboratorySlot(slot)) {
|
||
return '<option value="">Только для лабораторных</option>';
|
||
}
|
||
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
|
||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||
const matchingSubgroups = subgroups
|
||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
||
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
|
||
const value = String(subgroup.id);
|
||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectedRuleGroupIds() {
|
||
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
||
.map(input => Number(input.value));
|
||
}
|
||
|
||
function toTimeSlotOption(slot) {
|
||
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 toClassroomOption(classroom) {
|
||
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
|
||
return { value: classroom.id, label: `${classroom.name}${capacity}` };
|
||
}
|
||
|
||
function toLessonTypeOption(lessonType) {
|
||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||
}
|
||
|
||
function lessonTypeKeyById(lessonTypeId) {
|
||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||
}
|
||
|
||
function isLaboratorySlot(slot) {
|
||
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
|
||
}
|
||
|
||
function subgroupLabel(subgroup) {
|
||
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
|
||
}
|
||
|
||
function lessonTypeKey(lessonType) {
|
||
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||
if (name.includes('лекц')) return 'lecture';
|
||
if (name.includes('лаб')) return 'laboratory';
|
||
if (name.includes('практ')) return 'practice';
|
||
return null;
|
||
}
|
||
|
||
function semesterLabel(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||
}
|
||
|
||
function formatSemester(rule) {
|
||
const type = normalizeSemesterType(rule.semesterType);
|
||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||
}
|
||
|
||
function normalizeSemesterType(type) {
|
||
return String(type || '').toLowerCase();
|
||
}
|
||
|
||
function trimTime(value) {
|
||
if (!value) return '';
|
||
return String(value).slice(0, 5);
|
||
}
|
||
|
||
function syncSelects(...selects) {
|
||
selects.filter(Boolean).forEach(select => {
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
}
|
||
}
|