scheduleSon/frontend/admin/static/script.js

603 lines
23 KiB
JavaScript
Raw Normal View History

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;
2026-03-22 12:48:20 +03:00
let currentRangeStart = null;
let scheduleItems = [];
let itemIndex = new Map();
2026-03-22 12:48:20 +03:00
function getMoscowDateString() {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
2026-03-22 12:48:20 +03:00
timeZone: MOSCOW_TIMEZONE,
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();
}
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 || 'Не удалось загрузить расписание');
}
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);
});
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');
}
}
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;
}
2026-03-22 12:48:20 +03:00
const payload = JSON.parse(raw);
const item = itemIndex.get(`event:${payload.id}:${payload.occurrenceDate}`);
if (!item) {
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) {
return;
}
}
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
});
}
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;
}
2026-03-22 12:48:20 +03:00
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';
}
2026-03-22 12:48:20 +03:00
function buildScopeSelector(item) {
if (!item.repeat_weekly) {
return '';
}
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>
</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>
</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>
</div>
2026-03-22 12:48:20 +03:00
${isEdit ? buildScopeSelector(item) : ''}
</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>
</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);
}
});
}
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>
</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('')}
</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) : ''}
</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>
</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);
}
});
}
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();
}
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;
}
2026-03-22 12:48:20 +03:00
closeModal();
await loadSchedule();
}
2026-03-22 12:48:20 +03:00
async function openEditModal(kind, id, occurrenceDate) {
const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`);
if (!item) {
return;
}
2026-03-22 12:48:20 +03:00
if (kind === 'task') {
showTaskModal(item, item);
} else {
2026-03-22 12:48:20 +03:00
showEventModal(item, item);
}
}
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;
}
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('&', '&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();
});
2026-03-22 12:48:20 +03:00
document.getElementById('next-week').addEventListener('click', async () => {
currentRangeStart = addDays(currentRangeStart, 7);
await loadSchedule();
});
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());
document.querySelector('.close').addEventListener('click', closeModal);
2026-03-22 12:48:20 +03:00
window.addEventListener('click', (event) => {
if (event.target.id === 'modal') {
closeModal();
}
});
2026-03-22 12:48:20 +03:00
try {
await loadSchedule();
} catch (error) {
console.error(error);
alert(error.message);
}
});