scheduleSon/frontend/admin/static/script.js
2026-03-22 12:48:20 +03:00

602 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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);
}
});