602 lines
23 KiB
JavaScript
602 lines
23 KiB
JavaScript
const MOSCOW_TIMEZONE = 'Europe/Moscow';
|
||
const WEEKDAYS = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'];
|
||
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
||
const VIEW_WEEKS = 4;
|
||
const SLOT_MINUTES = 15;
|
||
const START_HOUR = 8;
|
||
const END_HOUR = 20;
|
||
const PIXELS_PER_MINUTE = 0.8;
|
||
|
||
let currentRangeStart = null;
|
||
let scheduleItems = [];
|
||
let itemIndex = new Map();
|
||
|
||
function getMoscowDateString() {
|
||
const now = new Date();
|
||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: MOSCOW_TIMEZONE,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
});
|
||
return formatter.format(now);
|
||
}
|
||
|
||
function parseISODate(value) {
|
||
const [year, month, day] = value.split('-').map(Number);
|
||
return new Date(year, month - 1, day);
|
||
}
|
||
|
||
function formatISODate(date) {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function addDays(dateStr, days) {
|
||
const date = parseISODate(dateStr);
|
||
date.setDate(date.getDate() + days);
|
||
return formatISODate(date);
|
||
}
|
||
|
||
function startOfWeek(dateStr = null) {
|
||
const base = parseISODate(dateStr || getMoscowDateString());
|
||
const jsDay = base.getDay();
|
||
const diff = jsDay === 0 ? -6 : 1 - jsDay;
|
||
base.setDate(base.getDate() + diff);
|
||
return formatISODate(base);
|
||
}
|
||
|
||
function getVisibleDates() {
|
||
return Array.from({ length: VIEW_WEEKS * 7 }, (_, index) => addDays(currentRangeStart, index));
|
||
}
|
||
|
||
function formatDayLabel(dateStr) {
|
||
const date = parseISODate(dateStr);
|
||
const weekday = WEEKDAYS[(date.getDay() + 6) % 7];
|
||
return {
|
||
title: `${weekday}, ${date.getDate()} ${MONTHS[date.getMonth()]}`,
|
||
short: weekday.slice(0, 2),
|
||
monthDay: `${date.getDate()} ${MONTHS[date.getMonth()]}`
|
||
};
|
||
}
|
||
|
||
function calculateEndTime(startTime, durationMin) {
|
||
const [hour, minute] = startTime.split(':').map(Number);
|
||
const totalMinutes = hour * 60 + minute + durationMin;
|
||
const endHour = Math.floor(totalMinutes / 60);
|
||
const endMinute = totalMinutes % 60;
|
||
return `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`;
|
||
}
|
||
|
||
function timeToMinutes(time) {
|
||
const [hour, minute] = time.split(':').map(Number);
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
function minutesToTime(totalMinutes) {
|
||
const hour = Math.floor(totalMinutes / 60);
|
||
const minute = totalMinutes % 60;
|
||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
}
|
||
|
||
function rebuildItemIndex() {
|
||
itemIndex = new Map();
|
||
scheduleItems.forEach((item) => {
|
||
itemIndex.set(`${item.kind}:${item.id}:${item.source_date || item.date}`, item);
|
||
});
|
||
}
|
||
|
||
async function loadSchedule() {
|
||
if (!currentRangeStart) {
|
||
currentRangeStart = startOfWeek();
|
||
}
|
||
|
||
const fromDate = currentRangeStart;
|
||
const toDate = addDays(currentRangeStart, VIEW_WEEKS * 7 - 1);
|
||
|
||
const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`);
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
throw new Error(error.detail || 'Не удалось загрузить расписание');
|
||
}
|
||
|
||
const data = await response.json();
|
||
scheduleItems = data.items || [];
|
||
rebuildItemIndex();
|
||
renderSchedule();
|
||
}
|
||
|
||
function renderSchedule() {
|
||
const grid = document.getElementById('schedule-grid');
|
||
const dates = getVisibleDates();
|
||
const today = getMoscowDateString();
|
||
|
||
grid.innerHTML = dates.map((dateStr) => {
|
||
const dayItems = scheduleItems.filter((item) => item.date === dateStr);
|
||
const tasks = dayItems.filter((item) => item.kind === 'task');
|
||
const events = dayItems
|
||
.filter((item) => item.kind === 'event')
|
||
.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||
const label = formatDayLabel(dateStr);
|
||
const weekNumber = Math.floor(dates.indexOf(dateStr) / 7) + 1;
|
||
|
||
return `
|
||
<section class="day-card ${dateStr === today ? 'is-today' : ''}">
|
||
<div class="day-header">
|
||
<div>
|
||
<h2 class="day-title">${label.title}</h2>
|
||
<div class="day-subtitle">Неделя ${weekNumber} в текущем окне</div>
|
||
</div>
|
||
<div class="day-badge">${dateStr === today ? 'Сегодня' : label.short}</div>
|
||
</div>
|
||
|
||
<div class="task-section">
|
||
<div class="section-label">Задачи</div>
|
||
<div class="task-list">
|
||
${tasks.map(renderTaskCard).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-label">Занятия</div>
|
||
${renderTimeline(dateStr, events)}
|
||
</section>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('week-range').textContent = `${formatDayLabel(dates[0]).title} - ${formatDayLabel(dates[dates.length - 1]).title}`;
|
||
bindInteractiveHandlers();
|
||
}
|
||
|
||
function renderTaskCard(task) {
|
||
return `
|
||
<article class="task-item" data-item-key="${task.kind}:${task.id}:${task.date}">
|
||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||
<div class="meta-text">${task.repeat_weekly ? 'Цикличная задача, обновления можно применить ко всей серии.' : 'Разовая задача.'}</div>
|
||
<div class="item-actions">
|
||
<button class="item-action edit" data-action="edit" data-kind="task" data-id="${task.id}" data-date="${task.source_date || task.date}">Изменить</button>
|
||
<button class="item-action delete" data-action="delete" data-kind="task" data-id="${task.id}" data-date="${task.source_date || task.date}">Удалить</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderTimeline(dateStr, events) {
|
||
const hours = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, index) => START_HOUR + index);
|
||
|
||
return `
|
||
<div class="timeline">
|
||
<div class="timeline-dropzone" data-drop-date="${dateStr}">
|
||
${hours.map((hour, index) => `
|
||
<div class="timeline-hour" style="top:${index * 60 * PIXELS_PER_MINUTE}px;">
|
||
<span class="timeline-hour-label">${String(hour).padStart(2, '0')}:00</span>
|
||
</div>
|
||
`).join('')}
|
||
${events.map((event) => renderEventBlock(event)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderEventBlock(event) {
|
||
const startMinutes = timeToMinutes(event.start_time) - START_HOUR * 60;
|
||
const top = Math.max(0, startMinutes * PIXELS_PER_MINUTE);
|
||
const height = Math.max(42, event.duration_min * PIXELS_PER_MINUTE);
|
||
const endTime = calculateEndTime(event.start_time, event.duration_min);
|
||
return `
|
||
<article
|
||
class="event-block ${event.repeat_weekly ? 'recurring' : ''}"
|
||
draggable="true"
|
||
data-kind="event"
|
||
data-id="${event.id}"
|
||
data-date="${event.source_date || event.date}"
|
||
style="top:${top}px;height:${height}px;"
|
||
>
|
||
<div class="event-time">${event.start_time} - ${endTime}</div>
|
||
<div class="event-title">${escapeHtml(event.title)}</div>
|
||
<div class="meta-text" style="color: rgba(255,255,255,0.82);">${event.repeat_weekly ? 'Цикличное занятие' : 'Разовое занятие'}</div>
|
||
<div class="item-actions">
|
||
<button class="item-action edit" data-action="edit" data-kind="event" data-id="${event.id}" data-date="${event.source_date || event.date}">Изменить</button>
|
||
<button class="item-action delete" data-action="delete" data-kind="event" data-id="${event.id}" data-date="${event.source_date || event.date}">Удалить</button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function bindInteractiveHandlers() {
|
||
document.removeEventListener('click', clearActiveItems);
|
||
document.addEventListener('click', clearActiveItems);
|
||
|
||
document.querySelectorAll('[data-action="edit"]').forEach((button) => {
|
||
button.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
openEditModal(button.dataset.kind, Number(button.dataset.id), button.dataset.date);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-action="delete"]').forEach((button) => {
|
||
button.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
deleteItem(button.dataset.kind, Number(button.dataset.id), button.dataset.date);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.task-item').forEach((item) => {
|
||
item.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
toggleActiveItem(item);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.event-block').forEach((block) => {
|
||
block.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
toggleActiveItem(block);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.day-card').forEach((card, index) => {
|
||
card.addEventListener('click', (event) => {
|
||
if (event.target.closest('button, .event-block, .task-item')) {
|
||
return;
|
||
}
|
||
const targetDate = getVisibleDates()[index];
|
||
if (event.ctrlKey || event.metaKey) {
|
||
showTaskModal({ date: targetDate });
|
||
return;
|
||
}
|
||
showEventModal({ date: targetDate });
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.event-block').forEach((block) => {
|
||
block.addEventListener('dragstart', handleDragStart);
|
||
block.addEventListener('dragend', () => block.classList.remove('dragging'));
|
||
});
|
||
|
||
document.querySelectorAll('.timeline-dropzone').forEach((zone) => {
|
||
zone.addEventListener('dragover', (event) => event.preventDefault());
|
||
zone.addEventListener('drop', handleEventDrop);
|
||
});
|
||
}
|
||
|
||
function clearActiveItems() {
|
||
document.querySelectorAll('.task-item.is-active, .event-block.is-active').forEach((item) => {
|
||
item.classList.remove('is-active');
|
||
});
|
||
}
|
||
|
||
function toggleActiveItem(element) {
|
||
const isActive = element.classList.contains('is-active');
|
||
clearActiveItems();
|
||
if (!isActive) {
|
||
element.classList.add('is-active');
|
||
}
|
||
}
|
||
|
||
function handleDragStart(event) {
|
||
const block = event.currentTarget;
|
||
block.classList.add('dragging');
|
||
event.dataTransfer.setData('text/plain', JSON.stringify({
|
||
id: Number(block.dataset.id),
|
||
occurrenceDate: block.dataset.date
|
||
}));
|
||
}
|
||
|
||
async function handleEventDrop(event) {
|
||
event.preventDefault();
|
||
const raw = event.dataTransfer.getData('text/plain');
|
||
if (!raw) {
|
||
return;
|
||
}
|
||
|
||
const payload = JSON.parse(raw);
|
||
const item = itemIndex.get(`event:${payload.id}:${payload.occurrenceDate}`);
|
||
if (!item) {
|
||
return;
|
||
}
|
||
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
const relativeY = Math.max(0, Math.min(rect.height, event.clientY - rect.top));
|
||
const minutesFromStart = Math.round((relativeY / PIXELS_PER_MINUTE) / SLOT_MINUTES) * SLOT_MINUTES;
|
||
const clampedMinutes = Math.min((END_HOUR - START_HOUR) * 60 - item.duration_min, Math.max(0, minutesFromStart));
|
||
const newTime = minutesToTime(START_HOUR * 60 + clampedMinutes);
|
||
const newDate = event.currentTarget.dataset.dropDate;
|
||
|
||
let scope = null;
|
||
if (item.repeat_weekly) {
|
||
scope = promptRecurringScope('Переместить только это занятие или всю серию?', true);
|
||
if (!scope) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
await saveItemUpdate('event', item.id, {
|
||
occurrence_date: item.source_date || item.date,
|
||
date: newDate,
|
||
start_time: newTime,
|
||
duration_min: item.duration_min,
|
||
scope
|
||
});
|
||
}
|
||
|
||
function promptRecurringScope(message, allowCancel = false) {
|
||
const answer = window.prompt(`${message}\nВведите "1" для одного вхождения, "2" для всей серии.${allowCancel ? ' Оставьте пустым для отмены.' : ''}`, '2');
|
||
if (!answer && allowCancel) {
|
||
return null;
|
||
}
|
||
if (answer === '1') {
|
||
return 'one_date';
|
||
}
|
||
return 'series';
|
||
}
|
||
|
||
function showModal(content) {
|
||
document.getElementById('modal-body').innerHTML = content;
|
||
document.getElementById('modal').style.display = 'block';
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal').style.display = 'none';
|
||
}
|
||
|
||
function buildScopeSelector(item) {
|
||
if (!item.repeat_weekly) {
|
||
return '';
|
||
}
|
||
return `
|
||
<div class="form-group full">
|
||
<label>Куда применить изменение</label>
|
||
<div class="scope-row">
|
||
<label class="scope-card">
|
||
<input type="radio" name="edit-scope" value="series" checked>
|
||
<span>
|
||
<strong>Ко всей серии</strong><br>
|
||
Изменение сразу применяется ко всем будущим одинаковым занятиям или задачам.
|
||
</span>
|
||
</label>
|
||
<label class="scope-card">
|
||
<input type="radio" name="edit-scope" value="one_date">
|
||
<span>
|
||
<strong>Только к этому дню</strong><br>
|
||
Создается исключение, а остальная серия остается без изменений.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function showTaskModal(initial = {}, item = null) {
|
||
const isEdit = Boolean(item);
|
||
showModal(`
|
||
<h2 class="modal-title">${isEdit ? 'Изменить задачу' : 'Новая задача'}</h2>
|
||
<p class="modal-subtitle">У задач нет времени. Для цикличной задачи достаточно включить еженедельный повтор, и она будет появляться автоматически в каждом запросе расписания.</p>
|
||
<form id="task-form">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label>Дата</label>
|
||
<input type="date" id="task-date" value="${escapeAttribute(initial.date || getMoscowDateString())}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Тип</label>
|
||
<div class="checkbox-card">
|
||
<label><input type="checkbox" id="task-repeat-weekly" ${initial.repeat_weekly ? 'checked' : ''}> Повторять каждую неделю</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group full">
|
||
<label>Название</label>
|
||
<textarea id="task-title" required>${escapeHtml(initial.title || '')}</textarea>
|
||
</div>
|
||
${isEdit ? buildScopeSelector(item) : ''}
|
||
</div>
|
||
<div class="actions-row">
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Сохранить' : 'Добавить'}</button>
|
||
<button type="button" class="btn btn-secondary" id="cancel-task">Отмена</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
|
||
document.getElementById('cancel-task').addEventListener('click', closeModal);
|
||
document.getElementById('task-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const payload = {
|
||
date: document.getElementById('task-date').value,
|
||
title: document.getElementById('task-title').value.trim(),
|
||
repeat_weekly: document.getElementById('task-repeat-weekly').checked
|
||
};
|
||
|
||
if (isEdit) {
|
||
payload.occurrence_date = item.source_date || item.date;
|
||
payload.scope = item.repeat_weekly ? document.querySelector('input[name="edit-scope"]:checked').value : null;
|
||
await saveItemUpdate('task', item.id, payload);
|
||
} else {
|
||
await createItem('task', payload);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showEventModal(initial = {}, item = null) {
|
||
const isEdit = Boolean(item);
|
||
const duration = initial.duration_min || 60;
|
||
showModal(`
|
||
<h2 class="modal-title">${isEdit ? 'Изменить занятие' : 'Новое занятие'}</h2>
|
||
<p class="modal-subtitle">Занятия можно сделать цикличными на каждый выбранный день недели. Дальше серия будет достраиваться автоматически по диапазону, который вы открываете в календаре.</p>
|
||
<form id="event-form">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label>Дата</label>
|
||
<input type="date" id="event-date" value="${escapeAttribute(initial.date || getMoscowDateString())}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время начала</label>
|
||
<input type="time" id="event-start-time" step="900" min="08:00" max="20:00" value="${escapeAttribute(initial.start_time || '09:00')}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Длительность, минут</label>
|
||
<select id="event-duration">
|
||
${[15, 30, 45, 60, 75, 90, 120, 150, 180].map((value) => `<option value="${value}" ${value === duration ? 'selected' : ''}>${value}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Тип</label>
|
||
<div class="checkbox-card">
|
||
<label><input type="checkbox" id="event-repeat-weekly" ${initial.repeat_weekly ? 'checked' : ''}> Повторять каждую неделю</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group full">
|
||
<label>Название</label>
|
||
<textarea id="event-title" required>${escapeHtml(initial.title || '')}</textarea>
|
||
</div>
|
||
${isEdit ? buildScopeSelector(item) : ''}
|
||
</div>
|
||
<div class="actions-row">
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Сохранить' : 'Добавить'}</button>
|
||
<button type="button" class="btn btn-secondary" id="cancel-event">Отмена</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
|
||
document.getElementById('cancel-event').addEventListener('click', closeModal);
|
||
document.getElementById('event-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const payload = {
|
||
date: document.getElementById('event-date').value,
|
||
start_time: document.getElementById('event-start-time').value,
|
||
duration_min: Number(document.getElementById('event-duration').value),
|
||
title: document.getElementById('event-title').value.trim(),
|
||
repeat_weekly: document.getElementById('event-repeat-weekly').checked
|
||
};
|
||
|
||
if (isEdit) {
|
||
payload.occurrence_date = item.source_date || item.date;
|
||
payload.scope = item.repeat_weekly ? document.querySelector('input[name="edit-scope"]:checked').value : null;
|
||
await saveItemUpdate('event', item.id, payload);
|
||
} else {
|
||
await createItem('event', payload);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function createItem(kind, payload) {
|
||
const response = await fetch(`/api/events?kind=${kind}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
alert(error.detail || 'Не удалось создать запись');
|
||
return;
|
||
}
|
||
|
||
closeModal();
|
||
await loadSchedule();
|
||
}
|
||
|
||
async function saveItemUpdate(kind, id, payload) {
|
||
const response = await fetch(`/api/events/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
alert(error.detail || 'Не удалось изменить запись');
|
||
return;
|
||
}
|
||
|
||
closeModal();
|
||
await loadSchedule();
|
||
}
|
||
|
||
async function openEditModal(kind, id, occurrenceDate) {
|
||
const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`);
|
||
if (!item) {
|
||
return;
|
||
}
|
||
if (kind === 'task') {
|
||
showTaskModal(item, item);
|
||
} else {
|
||
showEventModal(item, item);
|
||
}
|
||
}
|
||
|
||
async function deleteItem(kind, id, occurrenceDate) {
|
||
const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`);
|
||
if (!item) {
|
||
return;
|
||
}
|
||
|
||
let scope = null;
|
||
if (item.repeat_weekly) {
|
||
scope = promptRecurringScope('Удалить только это вхождение или всю серию?', true);
|
||
if (!scope) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const url = new URL(`/api/events/${id}`, window.location.origin);
|
||
if (scope) {
|
||
url.searchParams.set('scope', scope);
|
||
}
|
||
if (item.repeat_weekly) {
|
||
url.searchParams.set('occurrence_date', occurrenceDate);
|
||
}
|
||
|
||
const response = await fetch(url.toString(), { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}));
|
||
alert(error.detail || 'Не удалось удалить запись');
|
||
return;
|
||
}
|
||
await loadSchedule();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function escapeAttribute(value) {
|
||
return escapeHtml(value);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
currentRangeStart = startOfWeek();
|
||
|
||
document.getElementById('prev-week').addEventListener('click', async () => {
|
||
currentRangeStart = addDays(currentRangeStart, -7);
|
||
await loadSchedule();
|
||
});
|
||
|
||
document.getElementById('next-week').addEventListener('click', async () => {
|
||
currentRangeStart = addDays(currentRangeStart, 7);
|
||
await loadSchedule();
|
||
});
|
||
|
||
document.getElementById('today-btn').addEventListener('click', async () => {
|
||
currentRangeStart = startOfWeek();
|
||
await loadSchedule();
|
||
});
|
||
|
||
document.getElementById('add-task-btn').addEventListener('click', () => showTaskModal());
|
||
document.getElementById('add-event-btn').addEventListener('click', () => showEventModal());
|
||
document.querySelector('.close').addEventListener('click', closeModal);
|
||
window.addEventListener('click', (event) => {
|
||
if (event.target.id === 'modal') {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
try {
|
||
await loadSchedule();
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert(error.message);
|
||
}
|
||
});
|