2026-03-22 12:48:20 +03:00
|
|
|
|
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;
|
2025-12-30 12:23:42 +03:00
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
let currentRangeStart = null;
|
|
|
|
|
|
let scheduleItems = [];
|
|
|
|
|
|
let itemIndex = new Map();
|
2025-12-30 12:23:42 +03:00
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
function getMoscowDateString() {
|
2025-12-30 12:23:42 +03:00
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
2026-03-22 12:48:20 +03:00
|
|
|
|
timeZone: MOSCOW_TIMEZONE,
|
2025-12-30 12:23:42 +03:00
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
});
|
|
|
|
|
|
return formatter.format(now);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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();
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
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 || 'Не удалось загрузить расписание');
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-30 13:07:17 +03:00
|
|
|
|
});
|
2026-03-22 12:48:20 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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;
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
const payload = JSON.parse(raw);
|
|
|
|
|
|
const item = itemIndex.get(`event:${payload.id}:${payload.occurrenceDate}`);
|
|
|
|
|
|
if (!item) {
|
2025-12-30 13:07:17 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-12-30 13:07:17 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
await saveItemUpdate('event', item.id, {
|
|
|
|
|
|
occurrence_date: item.source_date || item.date,
|
|
|
|
|
|
date: newDate,
|
|
|
|
|
|
start_time: newTime,
|
|
|
|
|
|
duration_min: item.duration_min,
|
|
|
|
|
|
scope
|
2025-12-30 12:23:42 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
function promptRecurringScope(message, allowCancel = false) {
|
|
|
|
|
|
const answer = window.prompt(`${message}\nВведите "1" для одного вхождения, "2" для всей серии.${allowCancel ? ' Оставьте пустым для отмены.' : ''}`, '2');
|
|
|
|
|
|
if (!answer && allowCancel) {
|
|
|
|
|
|
return null;
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
if (answer === '1') {
|
|
|
|
|
|
return 'one_date';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'series';
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showModal(content) {
|
|
|
|
|
|
document.getElementById('modal-body').innerHTML = content;
|
|
|
|
|
|
document.getElementById('modal').style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeModal() {
|
|
|
|
|
|
document.getElementById('modal').style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
function buildScopeSelector(item) {
|
|
|
|
|
|
if (!item.repeat_weekly) {
|
|
|
|
|
|
return '';
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
</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>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
<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>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
${isEdit ? buildScopeSelector(item) : ''}
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
<div class="actions-row">
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">${isEdit ? 'Сохранить' : 'Добавить'}</button>
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" id="cancel-task">Отмена</button>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
<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('')}
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
<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) : ''}
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
<div class="actions-row">
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">${isEdit ? 'Сохранить' : 'Добавить'}</button>
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" id="cancel-event">Отмена</button>
|
2025-12-30 12:23:42 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
2026-03-22 12:48:20 +03:00
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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();
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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;
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
await loadSchedule();
|
2025-12-30 12:23:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
async function openEditModal(kind, id, occurrenceDate) {
|
|
|
|
|
|
const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`);
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
return;
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
if (kind === 'task') {
|
|
|
|
|
|
showTaskModal(item, item);
|
2025-12-30 13:07:17 +03:00
|
|
|
|
} else {
|
2026-03-22 12:48:20 +03:00
|
|
|
|
showEventModal(item, item);
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
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;
|
2025-12-30 13:07:17 +03:00
|
|
|
|
}
|
2026-03-22 12:48:20 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2025-12-30 12:23:42 +03:00
|
|
|
|
});
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
document.getElementById('next-week').addEventListener('click', async () => {
|
|
|
|
|
|
currentRangeStart = addDays(currentRangeStart, 7);
|
|
|
|
|
|
await loadSchedule();
|
2025-12-30 12:23:42 +03:00
|
|
|
|
});
|
2026-03-22 12:48:20 +03:00
|
|
|
|
|
|
|
|
|
|
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());
|
2025-12-30 12:23:42 +03:00
|
|
|
|
document.querySelector('.close').addEventListener('click', closeModal);
|
2026-03-22 12:48:20 +03:00
|
|
|
|
window.addEventListener('click', (event) => {
|
|
|
|
|
|
if (event.target.id === 'modal') {
|
2025-12-30 12:23:42 +03:00
|
|
|
|
closeModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-22 12:48:20 +03:00
|
|
|
|
try {
|
|
|
|
|
|
await loadSchedule();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
alert(error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|