feat(calendar): add frontend components for calendar management (Phase 3 - Part 1)

Added API Integration:
- calendarsApi.js: Complete API wrapper for /api/v1/calendar/calendars/
  * CRUD operations (create, read, update, delete)
  * Subscribe/unsubscribe endpoints
  * My subscriptions endpoint
  * Cache invalidation support

Added UI Components:
- calendarManager.js: Calendar list management component
  * Displays available calendars with color indicators
  * Toggle calendar visibility (checkboxes)
  * Subscribe/unsubscribe buttons
  * Edit calendar button (for owners)
  * Real-time calendar filtering

- calendarManageModal.js: Calendar creation/editing modal
  * Create personal/public/department calendars
  * Edit calendar properties (title, description, color)
  * Delete calendar (owner only)
  * Department selection for department calendars
  * Auto-subscription settings

Added Styles:
- calendar-manager.scss: BEM-based styling
  * Calendar list with glassmorphism design
  * Color indicators and icons
  * Hover states and animations
  * Responsive design for mobile
  * Empty state styling

Updated Templates:
- calendar_desktop.html: Added calendar list UI
  * Replaced dropdown with collapsible calendar list
  * Added manage calendar button
  * Updated create event button icon

- calendar_modal_manage.html: New modal template
  * Form fields for calendar properties
  * Radio buttons for calendar types
  * Department selector
  * Auto-subscription checkboxes
  * Delete button for owners

- rightbar_calendar.html: Included new modal

Updated Imports:
- main.scss: Added calendar-manager component import

Status: Phase 3 Part 1 complete (UI components ready)
Next: Part 2 - Integration with calendarWidget.js for event filtering
This commit is contained in:
admin i 2026-02-11 09:42:18 +03:00
parent a29aa69d16
commit 826298ff08
8 changed files with 1284 additions and 18 deletions

View File

@ -0,0 +1,312 @@
/**
* @fileoverview Calendars API - работа с календарями (CRUD + подписки)
* @module api/calendarsApi
*/
import { dataManager } from '../managers/dataManager.js';
/**
* Получить токен авторизации
* @returns {string}
* @private
*/
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const token = meta?.getAttribute('content')?.trim();
if (token) return token;
try {
return localStorage.getItem('api.access') || '';
} catch {
return '';
}
}
/**
* Получить CSRF токен
* @returns {string}
* @private
*/
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name="csrf-token"]')?.content ||
getCookie('csrftoken') || '';
}
/**
* Получить cookie по имени
* @param {string} name
* @returns {string}
* @private
*/
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
/**
* Создать заголовки с авторизацией
* @param {boolean} [includeContentType=true] - Добавить Content-Type
* @returns {Object}
* @private
*/
function authHeaders(includeContentType = true) {
const token = getAccessToken();
const headers = {
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
'X-CSRFToken': getCsrfToken()
};
if (includeContentType) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
/**
* Получить список доступных календарей текущего пользователя
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Array>} Массив календарей
*/
export async function getMyCalendars(ttl = 60000) {
const key = 'calendars:my';
return dataManager.fetch(
key,
async () => {
const url = new URL('/api/v1/calendar/calendars/', window.location.origin);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
});
if (response.status === 401) {
console.warn('[CalendarsAPI] 401 Unauthorized');
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.results || [];
},
ttl
);
}
/**
* Получить календарь по ID
* @param {number} calendarId - ID календаря
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Object>} Календарь
*/
export async function getCalendar(calendarId, ttl = 60000) {
const key = `calendars:${calendarId}`;
return dataManager.fetch(
key,
async () => {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
ttl
);
}
/**
* Создать новый календарь
* @param {Object} calendarData - Данные календаря
* @param {string} calendarData.title - Название
* @param {string} [calendarData.description] - Описание
* @param {string} [calendarData.color] - Цвет (#RRGGBB)
* @param {string} [calendarData.icon] - Иконка
* @param {string} [calendarData.visibility] - Видимость (public/private/department/custom)
* @param {number} [calendarData.owner_user] - ID владельца-пользователя
* @param {number} [calendarData.owner_department] - ID владельца-отдела
* @returns {Promise<Object>} Созданный календарь
*/
export async function createCalendar(calendarData) {
const url = new URL('/api/v1/calendar/calendars/', window.location.origin);
const response = await fetch(url.toString(), {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(calendarData)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш списка календарей
dataManager.invalidate('calendars:my');
return response.json();
}
/**
* Обновить календарь
* @param {number} calendarId - ID календаря
* @param {Object} updates - Обновления
* @returns {Promise<Object>} Обновленный календарь
*/
export async function updateCalendar(calendarId, updates) {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const response = await fetch(url.toString(), {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
return response.json();
}
/**
* Удалить календарь
* @param {number} calendarId - ID календаря
* @returns {Promise<void>}
*/
export async function deleteCalendar(calendarId) {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const response = await fetch(url.toString(), {
method: 'DELETE',
headers: authHeaders(false)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
}
/**
* Подписаться на календарь
* @param {number} calendarId - ID календаря
* @param {Object} [options] - Опции подписки
* @param {boolean} [options.can_edit] - Право редактирования
* @param {boolean} [options.can_manage] - Право управления
* @returns {Promise<Object>} Данные подписки
*/
export async function subscribeToCalendar(calendarId, options = {}) {
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/subscribe/`,
window.location.origin
);
const response = await fetch(url.toString(), {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(options)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate('subscriptions:my');
return response.json();
}
/**
* Отписаться от календаря
* @param {number} calendarId - ID календаря
* @returns {Promise<void>}
*/
export async function unsubscribeFromCalendar(calendarId) {
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/unsubscribe/`,
window.location.origin
);
const response = await fetch(url.toString(), {
method: 'POST',
headers: authHeaders(false)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate('subscriptions:my');
}
/**
* Получить мои подписки на календари
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Array>} Массив подписок
*/
export async function getMySubscriptions(ttl = 60000) {
const key = 'subscriptions:my';
return dataManager.fetch(
key,
async () => {
const url = new URL('/api/v1/calendar/subscriptions/', window.location.origin);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
});
if (response.status === 401) {
console.warn('[CalendarsAPI] 401 Unauthorized');
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.results || [];
},
ttl
);
}
/**
* Инвалидировать весь кеш календарей
*/
export function invalidateCalendarsCache() {
dataManager.invalidatePattern(/^calendars:/);
dataManager.invalidatePattern(/^subscriptions:/);
}

View File

@ -0,0 +1,272 @@
/**
* @fileoverview Calendar Manage Modal - модальное окно создания/редактирования календаря
* @module components/calendarManageModal
*/
import { createCalendar, updateCalendar, deleteCalendar } from '../api/calendarsApi.js';
import { getMyDepartments } from '../api/departmentsApi.js';
/**
* Инициализация модального окна управления календарём
* @param {Object} options - Параметры
* @param {Function} options.onSuccess - Callback при успешном сохранении/удалении
* @returns {Object} API модального окна
*/
export function initCalendarManageModal(options = {}) {
const { onSuccess = () => {} } = options;
const modal = document.getElementById('calendarManageModal');
if (!modal) {
console.warn('[CalendarManageModal] Modal element not found');
return null;
}
const bsModal = new bootstrap.Modal(modal);
// Elements
const form = document.getElementById('calendarManageForm');
const titleEl = document.getElementById('calendarManageModalTitle');
const calendarIdInput = document.getElementById('calendarId');
const titleInput = document.getElementById('calendarTitle');
const descInput = document.getElementById('calendarDescription');
const colorInput = document.getElementById('calendarColor');
const colorTextInput = document.getElementById('calendarColorText');
const typeInputs = document.querySelectorAll('input[name="calendar_type"]');
const deptSelect = document.getElementById('calendarDepartment');
const deptSelectWrapper = document.getElementById('calendarDepartmentSelect');
const autoSubscribeDeptWrapper = document.getElementById('calendarAutoSubscribeDeptWrapper');
const saveBtn = document.getElementById('calendarSaveBtn');
const deleteBtn = document.getElementById('calendarDeleteBtn');
const errorEl = document.getElementById('calendarManageError');
let currentCalendar = null;
/**
* Открыть модальное окно для создания календаря
*/
function openForCreate() {
currentCalendar = null;
titleEl.textContent = 'Создать календарь';
deleteBtn.classList.add('d-none');
resetForm();
bsModal.show();
}
/**
* Открыть модальное окно для редактирования календаря
* @param {Object} calendar - Данные календаря
*/
function openForEdit(calendar) {
currentCalendar = calendar;
titleEl.textContent = 'Редактировать календарь';
deleteBtn.classList.remove('d-none');
// Заполнить форму
calendarIdInput.value = calendar.id;
titleInput.value = calendar.title;
descInput.value = calendar.description || '';
colorInput.value = calendar.color || '#0d6efd';
colorTextInput.value = calendar.color || '#0d6efd';
// Определить тип
let type = 'personal';
if (calendar.is_global) {
type = 'public';
} else if (calendar.is_department) {
type = 'department';
}
typeInputs.forEach(input => {
input.checked = input.value === type;
});
// Если отдел, выбрать его
if (calendar.owner_department) {
deptSelect.value = calendar.owner_department;
}
// Настройки
document.getElementById('calendarAutoSubscribeNewUsers').checked =
calendar.auto_subscribe_new_users || false;
document.getElementById('calendarAutoSubscribeDept').checked =
calendar.auto_subscribe_department_members || false;
updateTypeVisibility();
bsModal.show();
}
/**
* Сбросить форму
*/
function resetForm() {
form.reset();
calendarIdInput.value = '';
colorInput.value = '#0d6efd';
colorTextInput.value = '#0d6efd';
errorEl.classList.add('d-none');
updateTypeVisibility();
}
/**
* Обновить видимость полей в зависимости от типа
*/
function updateTypeVisibility() {
const selectedType = document.querySelector('input[name="calendar_type"]:checked')?.value;
if (selectedType === 'department') {
deptSelectWrapper.classList.remove('d-none');
autoSubscribeDeptWrapper.classList.remove('d-none');
} else {
deptSelectWrapper.classList.add('d-none');
autoSubscribeDeptWrapper.classList.add('d-none');
}
}
/**
* Загрузить список отделов
*/
async function loadDepartments() {
try {
const departments = await getMyDepartments();
// Заполнить select
deptSelect.innerHTML = '<option value="">Выберите отдел...</option>' +
departments.map(dept =>
`<option value="${dept.id}">${dept.name}</option>`
).join('');
} catch (error) {
console.error('[CalendarManageModal] Failed to load departments:', error);
}
}
/**
* Собрать данные формы
* @returns {Object}
*/
function getFormData() {
const data = {
title: titleInput.value.trim(),
description: descInput.value.trim(),
color: colorInput.value,
auto_subscribe_new_users: document.getElementById('calendarAutoSubscribeNewUsers').checked,
auto_subscribe_department_members: document.getElementById('calendarAutoSubscribeDept').checked
};
const type = document.querySelector('input[name="calendar_type"]:checked')?.value;
if (type === 'personal') {
data.visibility = 'private';
// owner_user будет установлен автоматически на backend
} else if (type === 'public') {
data.visibility = 'public';
} else if (type === 'department') {
data.visibility = 'department';
data.owner_department = parseInt(deptSelect.value);
if (!data.owner_department) {
throw new Error('Выберите отдел');
}
}
return data;
}
/**
* Сохранить календарь
*/
async function save() {
try {
errorEl.classList.add('d-none');
saveBtn.disabled = true;
const data = getFormData();
if (currentCalendar) {
// Обновление
await updateCalendar(currentCalendar.id, data);
} else {
// Создание
await createCalendar(data);
}
bsModal.hide();
onSuccess();
} catch (error) {
console.error('[CalendarManageModal] Save failed:', error);
errorEl.textContent = error.message || 'Не удалось сохранить календарь';
errorEl.classList.remove('d-none');
} finally {
saveBtn.disabled = false;
}
}
/**
* Удалить календарь
*/
async function remove() {
if (!currentCalendar) return;
if (!confirm(`Удалить календарь "${currentCalendar.title}"?\n\nВсе события этого календаря будут удалены.`)) {
return;
}
try {
deleteBtn.disabled = true;
await deleteCalendar(currentCalendar.id);
bsModal.hide();
onSuccess();
} catch (error) {
console.error('[CalendarManageModal] Delete failed:', error);
errorEl.textContent = error.message || 'Не удалось удалить календарь';
errorEl.classList.remove('d-none');
} finally {
deleteBtn.disabled = false;
}
}
// Event listeners
// Синхронизация цвета
colorInput.addEventListener('input', (e) => {
colorTextInput.value = e.target.value;
});
colorTextInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
colorInput.value = value;
}
});
// Изменение типа календаря
typeInputs.forEach(input => {
input.addEventListener('change', updateTypeVisibility);
});
// Сохранить
saveBtn.addEventListener('click', save);
// Удалить
deleteBtn.addEventListener('click', remove);
// Сброс при закрытии
modal.addEventListener('hidden.bs.modal', resetForm);
// Слушать событие редактирования из других компонентов
document.addEventListener('calendar:edit', (e) => {
openForEdit(e.detail);
});
// Инициализация
loadDepartments();
// Public API
return {
openForCreate,
openForEdit,
show: () => bsModal.show(),
hide: () => bsModal.hide()
};
}

View File

@ -0,0 +1,289 @@
/**
* @fileoverview Calendar Manager Component - управление списком календарей
* @module components/calendarManager
*/
import {
getMyCalendars,
subscribeToCalendar,
unsubscribeFromCalendar,
invalidateCalendarsCache
} from '../api/calendarsApi.js';
/**
* Инициализация менеджера календарей
* @param {Object} options - Параметры
* @param {string} options.containerId - ID контейнера для списка календарей
* @param {Function} options.onCalendarToggle - Callback при переключении видимости календаря
* @param {Function} options.onCalendarsChange - Callback при изменении списка календарей
* @returns {Object} API менеджера
*/
export function initCalendarManager(options = {}) {
const {
containerId = 'calendarListContainer',
onCalendarToggle = () => {},
onCalendarsChange = () => {}
} = options;
const container = document.getElementById(containerId);
if (!container) {
console.warn(`[CalendarManager] Container #${containerId} not found`);
return null;
}
// Состояние
let calendars = [];
let visibleCalendarIds = new Set();
/**
* Загрузить список календарей
*/
async function loadCalendars() {
try {
calendars = await getMyCalendars();
// По умолчанию все календари видимы
visibleCalendarIds = new Set(calendars.map(cal => cal.id));
render();
onCalendarsChange(calendars, Array.from(visibleCalendarIds));
} catch (error) {
console.error('[CalendarManager] Failed to load calendars:', error);
container.innerHTML = `
<div class="alert alert-danger alert-sm">
<i class="bi-exclamation-triangle me-2"></i>
Не удалось загрузить календари
</div>
`;
}
}
/**
* Переключить видимость календаря
* @param {number} calendarId
*/
function toggleCalendarVisibility(calendarId) {
if (visibleCalendarIds.has(calendarId)) {
visibleCalendarIds.delete(calendarId);
} else {
visibleCalendarIds.add(calendarId);
}
render();
onCalendarToggle(calendarId, visibleCalendarIds.has(calendarId));
}
/**
* Подписаться на календарь
* @param {number} calendarId
*/
async function subscribe(calendarId) {
try {
await subscribeToCalendar(calendarId);
await loadCalendars(); // Перезагрузить список
} catch (error) {
console.error('[CalendarManager] Subscribe failed:', error);
alert('Не удалось подписаться на календарь');
}
}
/**
* Отписаться от календаря
* @param {number} calendarId
*/
async function unsubscribe(calendarId) {
if (!confirm('Отписаться от этого календаря?')) return;
try {
await unsubscribeFromCalendar(calendarId);
await loadCalendars(); // Перезагрузить список
} catch (error) {
console.error('[CalendarManager] Unsubscribe failed:', error);
alert('Не удалось отписаться от календаря');
}
}
/**
* Получить иконку для типа календаря
* @param {Object} calendar
* @returns {string} HTML иконки
*/
function getCalendarIcon(calendar) {
if (calendar.is_personal) {
return '<i class="bi-person-fill"></i>';
} else if (calendar.is_department) {
return '<i class="bi-building"></i>';
} else if (calendar.is_global) {
return '<i class="bi-globe"></i>';
}
return '<i class="bi-calendar3"></i>';
}
/**
* Получить бейдж владельца
* @param {Object} calendar
* @returns {string} HTML бейджа
*/
function getOwnerBadge(calendar) {
if (calendar.is_personal && calendar.owner_user_name) {
return `<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill ms-auto">${calendar.owner_user_name}</span>`;
} else if (calendar.is_department && calendar.owner_department_name) {
return `<span class="badge bg-info-subtle text-info-emphasis rounded-pill ms-auto">${calendar.owner_department_name}</span>`;
} else if (calendar.is_global) {
return `<span class="badge bg-primary-subtle text-primary-emphasis rounded-pill ms-auto">Общий</span>`;
}
return '';
}
/**
* Рендер списка календарей
*/
function render() {
if (calendars.length === 0) {
container.innerHTML = `
<div class="text-muted text-center py-3">
<i class="bi-calendar3 fs-4 d-block mb-2"></i>
<small>Нет доступных календарей</small>
</div>
`;
return;
}
const html = calendars.map(calendar => {
const isVisible = visibleCalendarIds.has(calendar.id);
const canEdit = calendar.user_can_edit;
const isSubscribed = calendar.is_subscribed;
return `
<div class="calendar-list-item" data-calendar-id="${calendar.id}">
<div class="form-check">
<input
class="form-check-input calendar-visibility-toggle"
type="checkbox"
id="cal_${calendar.id}"
${isVisible ? 'checked' : ''}
data-calendar-id="${calendar.id}"
>
<label class="form-check-label d-flex align-items-center gap-2 w-100" for="cal_${calendar.id}">
<span class="calendar-color-indicator" style="background-color: ${calendar.color}"></span>
<span class="calendar-icon text-muted">${getCalendarIcon(calendar)}</span>
<span class="calendar-title flex-grow-1">${calendar.title}</span>
${getOwnerBadge(calendar)}
</label>
</div>
<div class="calendar-list-item-actions">
${canEdit ? `
<button
class="btn btn-sm btn-outline-secondary calendar-edit-btn"
data-calendar-id="${calendar.id}"
title="Редактировать"
>
<i class="bi-pencil"></i>
</button>
` : ''}
${!isSubscribed ? `
<button
class="btn btn-sm btn-outline-primary calendar-subscribe-btn"
data-calendar-id="${calendar.id}"
title="Подписаться"
>
<i class="bi-plus-lg"></i>
</button>
` : `
<button
class="btn btn-sm btn-outline-danger calendar-unsubscribe-btn"
data-calendar-id="${calendar.id}"
title="Отписаться"
>
<i class="bi-dash-lg"></i>
</button>
`}
</div>
</div>
`;
}).join('');
container.innerHTML = html;
attachEventListeners();
}
/**
* Прикрепить обработчики событий
*/
function attachEventListeners() {
// Переключение видимости
container.querySelectorAll('.calendar-visibility-toggle').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const calendarId = parseInt(e.target.dataset.calendarId);
toggleCalendarVisibility(calendarId);
});
});
// Подписка
container.querySelectorAll('.calendar-subscribe-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const calendarId = parseInt(e.currentTarget.dataset.calendarId);
subscribe(calendarId);
});
});
// Отписка
container.querySelectorAll('.calendar-unsubscribe-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const calendarId = parseInt(e.currentTarget.dataset.calendarId);
unsubscribe(calendarId);
});
});
// Редактирование (делегируем наверх)
container.querySelectorAll('.calendar-edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const calendarId = parseInt(e.currentTarget.dataset.calendarId);
const calendar = calendars.find(c => c.id === calendarId);
if (calendar) {
// Dispatch custom event для открытия модального окна
document.dispatchEvent(new CustomEvent('calendar:edit', { detail: calendar }));
}
});
});
}
/**
* Обновить список календарей
*/
async function refresh() {
invalidateCalendarsCache();
await loadCalendars();
}
/**
* Получить ID видимых календарей
* @returns {Array<number>}
*/
function getVisibleCalendarIds() {
return Array.from(visibleCalendarIds);
}
/**
* Установить видимость календарей
* @param {Array<number>} calendarIds
*/
function setVisibleCalendars(calendarIds) {
visibleCalendarIds = new Set(calendarIds);
render();
onCalendarsChange(calendars, Array.from(visibleCalendarIds));
}
// Инициализация
loadCalendars();
// Public API
return {
refresh,
getVisibleCalendarIds,
setVisibleCalendars,
loadCalendars
};
}

View File

@ -0,0 +1,186 @@
// =============================================================================
// Calendar Manager Component Styles
// =============================================================================
// Стили для компонента управления списком календарей
// ──────────────────────────────────────────────────────────────
// Calendar List Container
// ──────────────────────────────────────────────────────────────
.calendar-list-container {
max-height: 400px;
overflow-y: auto;
padding: var(--space-2);
// Кастомный scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
// ──────────────────────────────────────────────────────────────
// Calendar List Item
// ──────────────────────────────────────────────────────────────
.calendar-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-md);
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
// Чекбокс и лейбл
.form-check {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.form-check-label {
cursor: pointer;
user-select: none;
min-width: 0;
}
// Цветовой индикатор
.calendar-color-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.2);
}
// Иконка календаря
.calendar-icon {
flex-shrink: 0;
font-size: 0.9rem;
}
// Название календаря
.calendar-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Действия
.calendar-list-item-actions {
display: flex;
gap: var(--space-1);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .calendar-list-item-actions {
opacity: 1;
}
// Кнопки действий
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1;
i {
font-size: 0.875rem;
}
}
}
// ──────────────────────────────────────────────────────────────
// Calendar Chooser Button (в header)
// ──────────────────────────────────────────────────────────────
#calendarChooserBtn {
position: relative;
&::after {
margin-left: 0.5rem;
}
}
// ──────────────────────────────────────────────────────────────
// Calendar Header Actions
// ──────────────────────────────────────────────────────────────
.calendar-header-actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
// ──────────────────────────────────────────────────────────────
// Empty State
// ──────────────────────────────────────────────────────────────
.calendar-list-empty {
text-align: center;
padding: var(--space-4);
color: var(--bs-secondary);
i {
font-size: 2rem;
display: block;
margin-bottom: var(--space-2);
opacity: 0.5;
}
small {
font-size: 0.875rem;
}
}
// ──────────────────────────────────────────────────────────────
// Модификаторы
// ──────────────────────────────────────────────────────────────
// Компактный вид
.calendar-list-item--compact {
padding: var(--space-1) var(--space-2);
.calendar-color-indicator {
width: 10px;
height: 10px;
}
.calendar-icon {
font-size: 0.8rem;
}
}
// Отключённый календарь
.calendar-list-item--disabled {
opacity: 0.5;
pointer-events: none;
}
// ──────────────────────────────────────────────────────────────
// Responsive
// ──────────────────────────────────────────────────────────────
@media (max-width: 991.98px) {
.calendar-list-container {
max-height: 300px;
}
.calendar-list-item-actions {
opacity: 1; // На мобильных всегда показываем кнопки
}
}

View File

@ -73,6 +73,7 @@
// Календарь и rightbar
@import 'components/rightbar-calendar';
@import 'components/calendar-manager';
// =============================================================================
// 4. UTILITIES - Вспомогательные классы

View File

@ -4,29 +4,58 @@
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="d-flex align-items-center gap-2 flex-shrink-1 min-w-0">
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
<h6 class="card-title fw-semibold mb-0">Календари</h6>
{# Выпадающий выбор: Компания / Отделы / Личный #}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle text-truncate" type="button"
id="calendarChooserBtn" data-bs-toggle="dropdown" aria-expanded="false"
style="max-width: 150px;">
Компания
</button>
<ul class="dropdown-menu" aria-labelledby="calendarChooserBtn" id="calendarChooserMenu">
<li><button class="dropdown-item" type="button" data-cal="personal">Личный</button></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
<li><hr class="dropdown-divider"></li>
{# пункты отделов добавим из JS по data-dept-id #}
</ul>
</div>
{# Кнопка управления календарями #}
<button
class="btn btn-sm btn-outline-secondary"
type="button"
data-bs-toggle="collapse"
data-bs-target="#calendarListCollapse"
aria-expanded="false"
aria-controls="calendarListCollapse"
title="Управление календарями"
>
<i class="bi-list"></i>
</button>
</div>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="card-sub text-muted d-none d-xl-inline">События</span>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#eventCreateModal"
aria-label="Создать событие"><i class="bi-plus-lg"></i></button>
<button
class="btn btn-sm btn-outline-primary"
type="button"
data-bs-toggle="modal"
data-bs-target="#calendarManageModal"
aria-label="Создать календарь"
title="Создать календарь"
>
<i class="bi-plus-lg"></i>
</button>
<button
class="btn btn-sm btn-primary"
type="button"
data-bs-toggle="modal"
data-bs-target="#eventCreateModal"
aria-label="Создать событие"
title="Создать событие"
>
<i class="bi-calendar-plus"></i>
</button>
</div>
</div>
{# Список календарей (сворачиваемый) #}
<div class="collapse" id="calendarListCollapse">
<div class="card-body border-bottom">
<div id="calendarListContainer" class="calendar-list-container">
{# Здесь будет JS-генерируемый список календарей #}
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,176 @@
{# Модальное окно управления календарём #}
<div class="modal fade" id="calendarManageModal" tabindex="-1" aria-labelledby="calendarManageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="calendarManageModalLabel">
<i class="bi-calendar3 me-2"></i>
<span id="calendarManageModalTitle">Создать календарь</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<form id="calendarManageForm">
<input type="hidden" id="calendarId" name="calendar_id" value="">
{# Название #}
<div class="mb-3">
<label for="calendarTitle" class="form-label">
Название <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
id="calendarTitle"
name="title"
required
placeholder="Например: Мой календарь"
maxlength="200"
>
</div>
{# Описание #}
<div class="mb-3">
<label for="calendarDescription" class="form-label">Описание</label>
<textarea
class="form-control"
id="calendarDescription"
name="description"
rows="3"
placeholder="Краткое описание календаря..."
></textarea>
</div>
{# Цвет #}
<div class="mb-3">
<label for="calendarColor" class="form-label">Цвет</label>
<div class="input-group">
<input
type="color"
class="form-control form-control-color"
id="calendarColor"
name="color"
value="#0d6efd"
title="Выберите цвет календаря"
>
<input
type="text"
class="form-control"
id="calendarColorText"
value="#0d6efd"
pattern="^#[0-9A-Fa-f]{6}$"
maxlength="7"
>
</div>
<div class="form-text">Используется для выделения событий</div>
</div>
{# Тип календаря #}
<div class="mb-3">
<label class="form-label">Тип календаря <span class="text-danger">*</span></label>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePersonal"
value="personal"
checked
>
<label class="form-check-label" for="calendarTypePersonal">
<i class="bi-person-fill me-1"></i>
Личный календарь
</label>
<div class="form-text ms-4">Только вы можете видеть и редактировать</div>
</div>
<div class="form-check mt-2">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePublic"
value="public"
>
<label class="form-check-label" for="calendarTypePublic">
<i class="bi-globe me-1"></i>
Публичный календарь
</label>
<div class="form-text ms-4">Все пользователи могут видеть</div>
</div>
<div class="form-check mt-2">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypeDepartment"
value="department"
>
<label class="form-check-label" for="calendarTypeDepartment">
<i class="bi-building me-1"></i>
Календарь отдела
</label>
<div class="form-text ms-4">Члены отдела могут видеть</div>
</div>
</div>
{# Выбор отдела (показывается только для типа "department") #}
<div class="mb-3 d-none" id="calendarDepartmentSelect">
<label for="calendarDepartment" class="form-label">
Отдел <span class="text-danger">*</span>
</label>
<select class="form-select" id="calendarDepartment" name="owner_department">
<option value="">Выберите отдел...</option>
{# Будет заполнено из JS #}
</select>
</div>
{# Дополнительные настройки #}
<div class="mb-3">
<label class="form-label">Дополнительные настройки</label>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeNewUsers"
name="auto_subscribe_new_users"
>
<label class="form-check-label" for="calendarAutoSubscribeNewUsers">
Автоматически подписывать новых пользователей
</label>
</div>
<div class="form-check form-switch mt-2 d-none" id="calendarAutoSubscribeDeptWrapper">
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeDept"
name="auto_subscribe_department_members"
>
<label class="form-check-label" for="calendarAutoSubscribeDept">
Автоматически подписывать членов отдела
</label>
</div>
</div>
{# Сообщение об ошибке #}
<div id="calendarManageError" class="alert alert-danger d-none" role="alert"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger d-none" id="calendarDeleteBtn">
<i class="bi-trash me-1"></i> Удалить
</button>
<button type="button" class="btn btn-primary" id="calendarSaveBtn">
<i class="bi-check-lg me-1"></i> Сохранить
</button>
</div>
</div>
</div>
</div>

View File

@ -10,6 +10,7 @@
{# Модальные окна #}
{% include "includes/components/calendar_modal_create.html" %}
{% include "includes/components/calendar_modal_details.html" %}
{% include "includes/components/calendar_modal_manage.html" %}
{% include "includes/components/calendar_context_menu.html" %}
{# JavaScript инициализация #}