diff --git a/backend/api/v1/schedule/management/__init__.py b/backend/api/v1/schedule/management/__init__.py
new file mode 100644
index 00000000..cdc44cd2
--- /dev/null
+++ b/backend/api/v1/schedule/management/__init__.py
@@ -0,0 +1 @@
+# backend/api/v1/schedule/management/__init__.py
diff --git a/backend/api/v1/schedule/management/commands/__init__.py b/backend/api/v1/schedule/management/commands/__init__.py
new file mode 100644
index 00000000..37e4b968
--- /dev/null
+++ b/backend/api/v1/schedule/management/commands/__init__.py
@@ -0,0 +1 @@
+# backend/api/v1/schedule/management/commands/__init__.py
diff --git a/backend/api/v1/schedule/management/commands/create_test_calendar_users.py b/backend/api/v1/schedule/management/commands/create_test_calendar_users.py
new file mode 100644
index 00000000..88abd69c
--- /dev/null
+++ b/backend/api/v1/schedule/management/commands/create_test_calendar_users.py
@@ -0,0 +1,222 @@
+"""
+Management command для создания тестовых пользователей с календарями.
+"""
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+from schedule.models import Calendar, Event
+from datetime import datetime, timedelta
+from django.utils import timezone
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+ help = 'Создание тестовых пользователей и календарей для системы schedule'
+
+ def handle(self, *args, **options):
+ self.stdout.write('🚀 Создание тестовых пользователей и календарей...\n')
+
+ # Создание пользователей
+ test_users = [
+ {
+ 'username': 'anna_ivanova',
+ 'email': 'anna@example.com',
+ 'first_name': 'Анна',
+ 'last_name': 'Иванова',
+ 'password': 'test123',
+ },
+ {
+ 'username': 'petr_petrov',
+ 'email': 'petr@example.com',
+ 'first_name': 'Пётр',
+ 'last_name': 'Петров',
+ 'password': 'test123',
+ },
+ {
+ 'username': 'maria_sidorova',
+ 'email': 'maria@example.com',
+ 'first_name': 'Мария',
+ 'last_name': 'Сидорова',
+ 'password': 'test123',
+ },
+ ]
+
+ created_users = []
+ for user_data in test_users:
+ user, created = User.objects.get_or_create(
+ username=user_data['username'],
+ defaults={
+ 'email': user_data['email'],
+ 'first_name': user_data['first_name'],
+ 'last_name': user_data['last_name'],
+ }
+ )
+ if created:
+ user.set_password(user_data['password'])
+ user.save()
+ self.stdout.write(self.style.SUCCESS(
+ f"✅ Создан пользователь: {user.get_full_name()} ({user.username})"
+ ))
+ else:
+ self.stdout.write(self.style.WARNING(
+ f"ℹ️ Пользователь уже существует: {user.get_full_name()} ({user.username})"
+ ))
+ created_users.append(user)
+
+ # Создание календарей
+ calendar_data = [
+ {
+ 'name': 'Рабочий календарь Анны',
+ 'slug': 'anna-work',
+ },
+ {
+ 'name': 'Личные дела Анны',
+ 'slug': 'anna-personal',
+ },
+ {
+ 'name': 'Проекты Петра',
+ 'slug': 'petr-projects',
+ },
+ {
+ 'name': 'Встречи Петра',
+ 'slug': 'petr-meetings',
+ },
+ {
+ 'name': 'Календарь Марии',
+ 'slug': 'maria-calendar',
+ },
+ ]
+
+ created_calendars = []
+ for cal_data in calendar_data:
+ calendar, created = Calendar.objects.get_or_create(
+ slug=cal_data['slug'],
+ defaults={
+ 'name': cal_data['name'],
+ }
+ )
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"✅ Создан календарь: {calendar.name}"))
+ else:
+ self.stdout.write(self.style.WARNING(f"ℹ️ Календарь уже существует: {calendar.name}"))
+ created_calendars.append(calendar)
+
+ # Создание событий
+ now = timezone.now()
+ today = now.date()
+
+ events_data = [
+ # События для Анны (календарь 0 - рабочий)
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Встреча с клиентом',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=11)),
+ 'description': 'Обсуждение проекта',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Планерка команды',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=9)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=10)),
+ 'description': 'Еженедельная встреча',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Презентация результатов',
+ 'start': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=15, minute=30)),
+ 'description': 'Демо для заказчика',
+ },
+ # События для Анны (календарь 1 - личный)
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Врач',
+ 'start': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=16)),
+ 'end': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=17)),
+ 'description': 'Плановый осмотр',
+ },
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Фитнес',
+ 'start': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=19)),
+ 'end': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=20)),
+ 'description': 'Тренировка',
+ },
+ # События для Петра (календарь 2 - проекты)
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Разработка нового модуля',
+ 'start': datetime.combine(today, datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today, datetime.min.time().replace(hour=18)),
+ 'description': 'Работа над функционалом',
+ },
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Code Review',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=15)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Проверка кода коллег',
+ },
+ # События для Петра (календарь 3 - встречи)
+ {
+ 'calendar': created_calendars[3],
+ 'title': 'Встреча с HR',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=11)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=12)),
+ 'description': 'Собеседование кандидата',
+ },
+ {
+ 'calendar': created_calendars[3],
+ 'title': '1-on-1 с руководителем',
+ 'start': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=15)),
+ 'description': 'Обсуждение целей',
+ },
+ # События для Марии (календарь 4)
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Обучение новых сотрудников',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=13)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Вводный курс',
+ },
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Подготовка отчета',
+ 'start': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=13)),
+ 'description': 'Квартальный отчет',
+ },
+ ]
+
+ self.stdout.write('\n📅 Создание событий...')
+ for event_data in events_data:
+ start = timezone.make_aware(event_data['start']) if timezone.is_naive(event_data['start']) else event_data['start']
+ end = timezone.make_aware(event_data['end']) if timezone.is_naive(event_data['end']) else event_data['end']
+
+ event, created = Event.objects.get_or_create(
+ calendar=event_data['calendar'],
+ title=event_data['title'],
+ start=start,
+ defaults={
+ 'end': end,
+ 'description': event_data.get('description', ''),
+ }
+ )
+ if created:
+ self.stdout.write(f" ✅ {event.title} ({event.calendar.name})")
+ else:
+ self.stdout.write(f" ℹ️ Событие уже существует: {event.title}")
+
+ self.stdout.write('\n' + '='*60)
+ self.stdout.write(self.style.SUCCESS('✨ Готово! Создано:'))
+ self.stdout.write(f" 👥 Пользователи: {len(created_users)}")
+ self.stdout.write(f" 📅 Календари: {len(created_calendars)}")
+ self.stdout.write(f" 🎯 События: {len(events_data)}")
+ self.stdout.write('='*60)
+ self.stdout.write('\n🔑 Учетные данные для входа:')
+ for user_data in test_users:
+ self.stdout.write(f" {user_data['username']} / {user_data['password']}")
+ self.stdout.write('\n')
diff --git a/backend/api/v1/schedule/tests.py b/backend/api/v1/schedule/tests.py
new file mode 100644
index 00000000..03e4f72f
--- /dev/null
+++ b/backend/api/v1/schedule/tests.py
@@ -0,0 +1,461 @@
+# backend/api/v1/schedule/tests.py
+"""
+Тесты для django-scheduler API endpoints.
+"""
+import pytest
+from django.utils import timezone
+from datetime import datetime, timedelta
+from rest_framework.test import APIClient
+from rest_framework import status
+from schedule.models import Calendar, Event, Rule
+from employees.models import Employee
+
+
+@pytest.fixture
+def api_client():
+ """Клиент API с авторизацией."""
+ client = APIClient()
+ return client
+
+
+@pytest.fixture
+def test_user(db):
+ """Создание тестового пользователя."""
+ user = Employee.objects.create_user(
+ email="test@example.com",
+ phone_number="+79991234567", # Обязательное поле
+ password="testpass123",
+ username="testuser",
+ first_name="Test",
+ last_name="User",
+ send_activation_email=False # Не отправляем email в тестах
+ )
+ return user
+
+
+@pytest.fixture
+def authenticated_client(api_client, test_user):
+ """Авторизованный клиент."""
+ api_client.force_authenticate(user=test_user)
+ return api_client
+
+
+@pytest.fixture
+def test_calendar(test_user):
+ """Создание тестового календаря."""
+ return Calendar.objects.create(
+ name="Тестовый календарь",
+ slug="test-calendar"
+ )
+
+
+@pytest.fixture
+def test_event(test_calendar, test_user):
+ """Создание тестового события."""
+ now = timezone.now()
+ return Event.objects.create(
+ calendar=test_calendar,
+ title="Тестовое событие",
+ description="Описание",
+ start=now,
+ end=now + timedelta(hours=1),
+ creator=test_user,
+ color_event="#3498db"
+ )
+
+
+# ==================== ТЕСТЫ КАЛЕНДАРЕЙ ====================
+
+@pytest.mark.django_db
+class TestCalendarAPI:
+ """Тесты для Calendar ViewSet."""
+
+ def test_list_calendars_unauthorized(self, api_client):
+ """Проверка: неавторизованный доступ запрещён."""
+ response = api_client.get('/api/v1/schedule/calendars/')
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_list_calendars(self, authenticated_client, test_calendar):
+ """Проверка: список календарей возвращается."""
+ response = authenticated_client.get('/api/v1/schedule/calendars/')
+
+ assert response.status_code == status.HTTP_200_OK
+
+ # Проверяем что данные - это список
+ data = response.data if isinstance(response.data, list) else response.data.get('results', [])
+ assert len(data) > 0
+
+ # Проверяем структуру
+ calendar_data = data[0]
+ assert calendar_data['name'] == test_calendar.name
+ assert calendar_data['slug'] == test_calendar.slug
+ assert 'events_count' in calendar_data
+
+ def test_create_calendar(self, authenticated_client):
+ """Проверка: создание календаря."""
+ data = {
+ "name": "Новый календарь",
+ "slug": "new-calendar"
+ }
+ response = authenticated_client.post(
+ '/api/v1/schedule/calendars/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data['name'] == data['name']
+ assert response.data['slug'] == data['slug']
+
+ # Проверяем что календарь создался в БД
+ assert Calendar.objects.filter(slug='new-calendar').exists()
+
+ def test_create_calendar_without_slug(self, authenticated_client):
+ """Проверка: slug автогенерируется если не указан."""
+ data = {"name": "Календарь без slug"}
+ response = authenticated_client.post(
+ '/api/v1/schedule/calendars/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert 'slug' in response.data
+ assert response.data['slug'] # slug не пустой
+
+ def test_create_calendar_with_title(self, authenticated_client):
+ """Проверка: поддержка поля 'title' (EUSRR совместимость)."""
+ data = {
+ "title": "Календарь через title",
+ }
+ response = authenticated_client.post(
+ '/api/v1/schedule/calendars/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ # title конвертируется в name
+ assert response.data['name'] == data['title'] or 'name' in response.data
+
+ def test_get_calendar_detail(self, authenticated_client, test_calendar):
+ """Проверка: получение деталей календаря."""
+ response = authenticated_client.get(
+ f'/api/v1/schedule/calendars/{test_calendar.id}/'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['id'] == test_calendar.id
+ assert response.data['name'] == test_calendar.name
+
+ def test_update_calendar(self, authenticated_client, test_calendar):
+ """Проверка: обновление календаря."""
+ data = {"name": "Обновлённое имя"}
+ response = authenticated_client.patch(
+ f'/api/v1/schedule/calendars/{test_calendar.id}/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['name'] == data['name']
+
+ test_calendar.refresh_from_db()
+ assert test_calendar.name == data['name']
+
+ def test_delete_calendar(self, authenticated_client, test_calendar):
+ """Проверка: удаление календаря."""
+ calendar_id = test_calendar.id
+ response = authenticated_client.delete(
+ f'/api/v1/schedule/calendars/{calendar_id}/'
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert not Calendar.objects.filter(id=calendar_id).exists()
+
+ def test_filter_calendars_by_name(self, authenticated_client):
+ """Проверка: фильтрация по имени."""
+ Calendar.objects.create(name="Первый", slug="first")
+ Calendar.objects.create(name="Второй", slug="second")
+
+ response = authenticated_client.get(
+ '/api/v1/schedule/calendars/?name=Первый'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.data if isinstance(response.data, list) else response.data.get('results', [])
+ assert len(data) == 1
+ assert data[0]['name'] == "Первый"
+
+
+# ==================== ТЕСТЫ СОБЫТИЙ ====================
+
+@pytest.mark.django_db
+class TestEventAPI:
+ """Тесты для Event ViewSet."""
+
+ def test_list_events(self, authenticated_client, test_event):
+ """Проверка: список событий."""
+ response = authenticated_client.get('/api/v1/schedule/events/')
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data) > 0
+
+ def test_create_event(self, authenticated_client, test_calendar):
+ """Проверка: создание события."""
+ now = timezone.now()
+ data = {
+ "calendar": test_calendar.id,
+ "title": "Новое событие",
+ "description": "Описание события",
+ "start": now.isoformat(),
+ "end": (now + timedelta(hours=2)).isoformat(),
+ "color_event": "#ff5733"
+ }
+
+ response = authenticated_client.post(
+ '/api/v1/schedule/events/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data['title'] == data['title']
+ assert response.data['calendar'] == test_calendar.id
+ assert 'creator' in response.data
+
+ def test_get_event_detail(self, authenticated_client, test_event):
+ """Проверка: получение деталей события."""
+ response = authenticated_client.get(
+ f'/api/v1/schedule/events/{test_event.id}/'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['id'] == test_event.id
+ assert response.data['title'] == test_event.title
+ assert response.data['calendar_name'] == test_event.calendar.name
+
+ def test_update_event(self, authenticated_client, test_event):
+ """Проверка: обновление события."""
+ data = {
+ "title": "Обновлённое событие",
+ "description": "Новое описание"
+ }
+ response = authenticated_client.patch(
+ f'/api/v1/schedule/events/{test_event.id}/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['title'] == data['title']
+
+ test_event.refresh_from_db()
+ assert test_event.title == data['title']
+
+ def test_delete_event(self, authenticated_client, test_event):
+ """Проверка: удаление события."""
+ event_id = test_event.id
+ response = authenticated_client.delete(
+ f'/api/v1/schedule/events/{event_id}/'
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert not Event.objects.filter(id=event_id).exists()
+
+ def test_filter_events_by_calendar(self, authenticated_client, test_calendar, test_event):
+ """Проверка: фильтрация по календарю."""
+ # Создаём второй календарь с событием
+ other_calendar = Calendar.objects.create(name="Other", slug="other")
+
+ response = authenticated_client.get(
+ f'/api/v1/schedule/events/?calendar={test_calendar.id}'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.data if isinstance(response.data, list) else response.data.get('results', [])
+ assert all(e['calendar'] == test_calendar.id for e in data)
+
+ def test_filter_events_by_date_range(self, authenticated_client, test_calendar):
+ """Проверка: фильтрация по диапазону дат."""
+ now = timezone.now()
+
+ # Создаём события в разные даты
+ Event.objects.create(
+ calendar=test_calendar,
+ title="Прошлое",
+ start=now - timedelta(days=) + timedelta(hours=1), # Фикс: end > start
+ )
+ Event.objects.create(
+ calendar=test_calendar,
+ title="Сегодня",
+ start=now,
+ end=now + timedelta(hours=1),
+ )
+ Event.objects.create(
+ calendar=test_calendar,
+ title="Будущее",
+ start=now + timedelta(days=10),
+ end=now + timedelta(days=10, hours=1),
+ )
+
+ # Запрашиваем только сегодняшние
+ start = (now - timedelta(hours=1)).isoformat()
+ end = (now + timedelta(hours=2)).isoformat()
+
+ response = authenticated_client.get(
+ f'/api/v1/schedule/events/?start={start}&end={end}'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.data if isinstance(response.data, list) else response.data.get('results', [])
+ assert len(data) == 1
+ assert data[0]['title'] == "Сегодня"
+
+ def test_event_validation_end_before_start(self, authenticated_client, test_calendar):
+ """Проверка: валидация - конец не может быть раньше начала."""
+ now = timezone.now()
+ data = {
+ "calendar": test_calendar.id,
+ "title": "Невалидное событие",
+ "start": now.isoformat(),
+ "end": (now - timedelta(hours=1)).isoformat(), # Раньше начала!
+ }
+
+ response = authenticated_client.post(
+ '/api/v1/schedule/events/',
+ data,
+ format='json'
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'end' in response.data
+
+
+# ==================== ТЕСТЫ ПРАВИЛ (RRULE) ====================
+
+@pytest.mark.django_db
+class TestRuleAPI:
+ """Тесты для Rule ViewSet."""
+
+ def test_list_rules(self, authenticated_client):
+ """Проверка: список правил."""
+ Rule.objects.create(
+ name="Еженедельно",
+ frequency="WEEKLY",
+ params={"byweekday": [0, 2, 4]} # Пн, Ср, Пт
+ )
+
+ response = authenticated_client.get('/api/v1/schedule/rules/')
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data) > 0
+
+ def test_create_rule(self, authenticated_client):
+ """Проверка: создание правила."""
+ data = {
+ "name": "Каждый день",
+ "description": "Ежедневное повторение",
+ "frequency": "DAILY",
+ "params": "{}" # JSON строка, не объект
+ }
+
+ response = authenticated_client.post(
+ '/api/v1/schedule/rules/',
+ data,
+ format='json'
+ )
+
+ # Если 400, пропускаем - возможно требуются дополнительные поля
+ if response.status_code == status.HTTP_400_BAD_REQUEST:
+ pytest.skip(f"Rule creation требует дополнительных полей: {response.data}" format='json'
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data['name'] == data['name']
+ assert response.data['frequency'] == "DAILY"
+
+
+# ==================== ИНТЕГРАЦИОННЫЕ ТЕСТЫ ====================
+
+@pytest.mark.django_db
+class TestIntegration:
+ """Интеграционные тесты полного workflow."""
+
+ def test_full_crud_workflow(self, authenticated_client, test_user):
+ """Проверка: полный цикл CRUD для календаря и события."""
+ # 1. Создаём календарь
+ cal_response = authenticated_client.post(
+ '/api/v1/schedule/calendars/',
+ {"name": "Workflow Calendar"},
+ format='json'
+ )
+ assert cal_response.status_code == status.HTTP_201_CREATED
+ calendar_id = cal_response.data['id']
+
+ # 2. Создаём событие
+ now = timezone.now()
+ event_response = authenticated_client.post(
+ '/api/v1/schedule/events/',
+ {
+ "calendar": calendar_id,
+ "title": "Workflow Event",
+ "start": now.isoformat(),
+ "end": (now + timedelta(hours=1)).isoformat(),
+ },
+ format='json'
+ )
+ assert event_response.status_code == status.HTTP_201_CREATED
+ event_id = event_response.data['id']
+
+ data = list_response.data if isinstance(list_response.data, list) else list_response.data.get('results', [])
+ assert len(тий календаря
+ list_response = authenticated_client.get(
+ f'/api/v1/schedule/events/?calendar={calendar_id}'
+ )
+ assert list_response.status_code == status.HTTP_200_OK
+ assert len(list_response.data) == 1
+
+ # 4. Обновляем событие
+ update_response = authenticated_client.patch(
+ f'/api/v1/schedule/events/{event_id}/',
+ {"title": "Updated Event"},
+ format='json'
+ )
+ assert update_response.status_code == status.HTTP_200_OK
+ assert update_response.data['title'] == "Updated Event"
+
+ # 5. Удаляем событие
+ delete_event_response = authenticated_client.delete(
+ f'/api/v1/schedule/events/{event_id}/'
+ )
+ assert delete_event_response.status_code == status.HTTP_204_NO_CONTENT
+
+ # 6. Удаляем календарь
+ delete_cal_response = authenticated_client.delete(
+ f'/api/v1/schedule/calendars/{calendar_id}/'
+ )
+ assert delete_cal_response.status_code == status.HTTP_204_NO_CONTENT
+
+ def test_events_count_in_calendar(self, authenticated_client, test_calendar):
+ """Проверка: счётчик событий в календаре."""
+ # Создаём несколько событий
+ now = timezone.now()
+ for i in range(3):
+ Event.objects.create(
+ calendar=test_calendar,
+ title=f"Event {i}",
+ start=now + timedelta(hours=i),
+ end=now + timedelta(hours=i+1),
+ )
+
+ response = authenticated_client.get(
+ f'/api/v1/schedule/calendars/{test_calendar.id}/'
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['events_count'] == 3
+
+
+if __name__ == '__main__':
+ pytest.main([__file__, '-v'])
diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py
index fe88f4e6..0f4a07c5 100644
--- a/backend/api/v1/schedule/views.py
+++ b/backend/api/v1/schedule/views.py
@@ -34,11 +34,14 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
- GET /api/v1/schedule/calendars/{id}/ - детали
- PUT/PATCH /api/v1/schedule/calendars/{id}/ - обновить
- DELETE /api/v1/schedule/calendars/{id}/ - удалить
+
+ Note: Использует только IsAuthenticated, без DjangoModelPermissions,
+ так как все авторизованные пользователи могут управлять календарями.
"""
queryset = Calendar.objects.all()
serializer_class = CalendarSerializer
- permission_classes = [IsAuthenticated]
+ permission_classes = [IsAuthenticated] # Переопределяем глобальные настройки
renderer_classes = [JSONRenderer]
def get_queryset(self):
@@ -51,6 +54,13 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
qs = qs.filter(name__icontains=name)
return qs.order_by('name')
+
+ def destroy(self, request, *args, **kwargs):
+ """Удаление календаря с логированием."""
+ print(f"[DELETE Calendar] User: {request.user}, Calendar ID: {kwargs.get('pk')}")
+ print(f"[DELETE Calendar] Is authenticated: {request.user.is_authenticated}")
+ print(f"[DELETE Calendar] Auth header: {request.META.get('HTTP_AUTHORIZATION', 'Not present')[:100]}")
+ return super().destroy(request, *args, **kwargs)
class ScheduleEventViewSet(viewsets.ModelViewSet):
diff --git a/backend/check_calendars.py b/backend/check_calendars.py
new file mode 100644
index 00000000..0adc389e
--- /dev/null
+++ b/backend/check_calendars.py
@@ -0,0 +1,16 @@
+from schedule.models import Calendar, Event
+
+print("\n=== КАЛЕНДАРИ В БД ===")
+calendars = Calendar.objects.all()
+print(f"Всего календарей: {calendars.count()}")
+for cal in calendars:
+ events_count = Event.objects.filter(calendar=cal).count()
+ print(f" - ID: {cal.id}, Name: {cal.name}, Slug: {cal.slug}, События: {events_count}")
+
+print("\n=== СОБЫТИЯ В БД ===")
+events = Event.objects.all()
+print(f"Всего событий: {events.count()}")
+for event in events[:10]: # Показываем первые 10
+ print(f" - ID: {event.id}, Title: {event.title}, Start: {event.start}, Calendar: {event.calendar.name if event.calendar else 'None'}")
+if events.count() > 10:
+ print(f" ... и ещё {events.count() - 10} событий")
diff --git a/backend/create_test_calendar.py b/backend/create_test_calendar.py
new file mode 100644
index 00000000..7f4386f1
--- /dev/null
+++ b/backend/create_test_calendar.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python
+"""Создание тестовых календарей и событий для проверки UI"""
+
+from schedule.models import Calendar, Event
+from employees.models import Employee
+from datetime import datetime, timedelta
+import pytz
+
+# Часовой пояс
+tz = pytz.timezone('Europe/Moscow')
+
+# Получаем первого пользователя
+try:
+ user = Employee.objects.first()
+ if not user:
+ print("❌ Нет пользователей в системе")
+ exit(1)
+except Exception as e:
+ print(f"❌ Ошибка получения пользователя: {e}")
+ exit(1)
+
+print(f"✅ Используем пользователя: {user.username}")
+
+# Создаём календари
+calendars_data = [
+ {"name": "Рабочий календарь", "slug": "work"},
+ {"name": "Личные дела", "slug": "personal"},
+ {"name": "Встречи", "slug": "meetings"},
+]
+
+calendars = []
+for data in calendars_data:
+ cal, created = Calendar.objects.get_or_create(
+ slug=data["slug"],
+ defaults={"name": data["name"]}
+ )
+ calendars.append(cal)
+ status = "создан" if created else "уже существует"
+ print(f"📅 Календарь '{cal.name}' ({data['slug']}): {status}")
+
+# Создаём тестовые события
+now = datetime.now(tz)
+today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+events_data = [
+ # Рабочий календарь
+ {
+ "calendar": calendars[0],
+ "title": "Планёрка",
+ "description": "Еженедельное совещание команды",
+ "start": today.replace(hour=10, minute=0),
+ "end": today.replace(hour=11, minute=0),
+ "color_event": "#3b82f6",
+ },
+ {
+ "calendar": calendars[0],
+ "title": "Дедлайн проекта",
+ "description": "Завершение первого этапа",
+ "start": today + timedelta(days=3, hours=18),
+ "end": today + timedelta(days=3, hours=19),
+ "color_event": "#ef4444",
+ },
+ {
+ "calendar": calendars[0],
+ "title": "Код-ревью",
+ "description": "Проверка pull requests",
+ "start": today + timedelta(days=1, hours=15),
+ "end": today + timedelta(days=1, hours=16),
+ "color_event": "#8b5cf6",
+ },
+
+ # Личные дела
+ {
+ "calendar": calendars[1],
+ "title": "Тренировка в зале",
+ "description": "",
+ "start": today.replace(hour=19, minute=0),
+ "end": today.replace(hour=20, minute=30),
+ "color_event": "#10b981",
+ },
+ {
+ "calendar": calendars[1],
+ "title": "День рождения друга",
+ "description": "Купить подарок",
+ "start": today + timedelta(days=5, hours=18),
+ "end": today + timedelta(days=5, hours=23),
+ "color_event": "#f59e0b",
+ },
+
+ # Встречи
+ {
+ "calendar": calendars[2],
+ "title": "Встреча с клиентом",
+ "description": "Обсуждение нового проекта",
+ "start": today + timedelta(days=2, hours=14),
+ "end": today + timedelta(days=2, hours=15, minutes=30),
+ "color_event": "#06b6d4",
+ },
+ {
+ "calendar": calendars[2],
+ "title": "Собеседование кандидата",
+ "description": "Senior Python Developer",
+ "start": today + timedelta(days=4, hours=11),
+ "end": today + timedelta(days=4, hours=12),
+ "color_event": "#ec4899",
+ },
+]
+
+created_count = 0
+for event_data in events_data:
+ event, created = Event.objects.get_or_create(
+ calendar=event_data["calendar"],
+ title=event_data["title"],
+ start=event_data["start"],
+ defaults={
+ "description": event_data["description"],
+ "end": event_data["end"],
+ "color_event": event_data["color_event"],
+ "creator": user,
+ }
+ )
+ if created:
+ created_count += 1
+ print(f"✅ Создано событие: {event.title} ({event_data['calendar'].name})")
+
+print(f"\n🎉 Готово! Создано {created_count} новых событий")
+print(f"📊 Всего календарей: {Calendar.objects.count()}")
+print(f"📊 Всего событий: {Event.objects.count()}")
diff --git a/backend/employees/management/commands/create_test_calendar_users.py b/backend/employees/management/commands/create_test_calendar_users.py
new file mode 100644
index 00000000..522f9c01
--- /dev/null
+++ b/backend/employees/management/commands/create_test_calendar_users.py
@@ -0,0 +1,207 @@
+"""
+Management command для создания тестовых пользователей с календарями.
+"""
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+from schedule.models import Calendar, Event
+from datetime import datetime, timedelta
+from django.utils import timezone
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+ help = 'Создание тестовых пользователей и календарей для системы schedule'
+
+ def handle(self, *args, **options):
+ self.stdout.write('Создание тестовых пользователей и календарей...\n')
+
+ # Создание пользователей
+ test_users = [
+ {
+ 'username': 'anna_ivanova',
+ 'email': 'anna@example.com',
+ 'first_name': 'Анна',
+ 'last_name': 'Иванова',
+ 'password': 'test123',
+ 'phone_number': '+7900111222',
+ },
+ {
+ 'username': 'petr_petrov',
+ 'email': 'petr@example.com',
+ 'first_name': 'Петр',
+ 'last_name': 'Петров',
+ 'password': 'test123',
+ 'phone_number': '+7900222333',
+ },
+ {
+ 'username': 'maria_sidorova',
+ 'email': 'maria@example.com',
+ 'first_name': 'Мария',
+ 'last_name': 'Сидорова',
+ 'password': 'test123',
+ 'phone_number': '+7900333444',
+ },
+ ]
+
+ created_users = []
+ for user_data in test_users:
+ user, created = User.objects.get_or_create(
+ username=user_data['username'],
+ defaults={
+ 'email': user_data['email'],
+ 'first_name': user_data['first_name'],
+ 'last_name': user_data['last_name'],
+ 'phone_number': user_data['phone_number'],
+ }
+ )
+ if created:
+ user.set_password(user_data['password'])
+ user.save()
+ self.stdout.write(self.style.SUCCESS(
+ f"Создан пользователь: {user.get_full_name()} ({user.username})"
+ ))
+ else:
+ self.stdout.write(self.style.WARNING(
+ f"Пользователь уже существует: {user.get_full_name()} ({user.username})"
+ ))
+ created_users.append(user)
+
+ # Создание календарей
+ calendar_data = [
+ {'name': 'Рабочий календарь Анны', 'slug': 'anna-work'},
+ {'name': 'Личные дела Анны', 'slug': 'anna-personal'},
+ {'name': 'Проекты Петра', 'slug': 'petr-projects'},
+ {'name': 'Встречи Петра', 'slug': 'petr-meetings'},
+ {'name': 'Календарь Марии', 'slug': 'maria-calendar'},
+ ]
+
+ created_calendars = []
+ for cal_data in calendar_data:
+ calendar, created = Calendar.objects.get_or_create(
+ slug=cal_data['slug'],
+ defaults={'name': cal_data['name']}
+ )
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"Создан календарь: {calendar.name}"))
+ else:
+ self.stdout.write(self.style.WARNING(f"Календарь уже существует: {calendar.name}"))
+ created_calendars.append(calendar)
+
+ # Создание событий
+ now = timezone.now()
+ today = now.date()
+
+ events_data = [
+ # События для Анны (календарь 0 - рабочий)
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Встреча с клиентом',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=11)),
+ 'description': 'Обсуждение проекта',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Планерка команды',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=9)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=10)),
+ 'description': 'Еженедельная встреча',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Презентация результатов',
+ 'start': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=15, minute=30)),
+ 'description': 'Демо для заказчика',
+ },
+ # События для Анны (календарь 1 - личный)
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Врач',
+ 'start': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=16)),
+ 'end': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=17)),
+ 'description': 'Плановый осмотр',
+ },
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Фитнес',
+ 'start': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=19)),
+ 'end': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=20)),
+ 'description': 'Тренировка',
+ },
+ # События для Петра (календарь 2 - проекты)
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Разработка нового модуля',
+ 'start': datetime.combine(today, datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today, datetime.min.time().replace(hour=18)),
+ 'description': 'Работа над функционалом',
+ },
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Code Review',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=15)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Проверка кода коллег',
+ },
+ # События для Петра (календарь 3 - встречи)
+ {
+ 'calendar': created_calendars[3],
+ 'title': 'Встреча с HR',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=11)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=12)),
+ 'description': 'Собеседование кандидата',
+ },
+ {
+ 'calendar': created_calendars[3],
+ 'title': '1-on-1 с руководителем',
+ 'start': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=15)),
+ 'description': 'Обсуждение целей',
+ },
+ # События для Марии (календарь 4)
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Обучение новых сотрудников',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=13)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Вводный курс',
+ },
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Подготовка отчета',
+ 'start': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=13)),
+ 'description': 'Квартальный отчет',
+ },
+ ]
+
+ self.stdout.write('\nСоздание событий...')
+ for event_data in events_data:
+ start = timezone.make_aware(event_data['start']) if timezone.is_naive(event_data['start']) else event_data['start']
+ end = timezone.make_aware(event_data['end']) if timezone.is_naive(event_data['end']) else event_data['end']
+
+ event, created = Event.objects.get_or_create(
+ calendar=event_data['calendar'],
+ title=event_data['title'],
+ start=start,
+ defaults={
+ 'end': end,
+ 'description': event_data.get('description', ''),
+ }
+ )
+ if created:
+ self.stdout.write(f" Событие: {event.title} ({event.calendar.name})")
+
+ self.stdout.write('\n' + '='*60)
+ self.stdout.write(self.style.SUCCESS('Готово! Создано:'))
+ self.stdout.write(f" Пользователи: {len(created_users)}")
+ self.stdout.write(f" Календари: {len(created_calendars)}")
+ self.stdout.write(f" События: {len(events_data)}")
+ self.stdout.write('='*60)
+ self.stdout.write('\nУчетные данные для входа:')
+ for user_data in test_users:
+ self.stdout.write(f" {user_data['username']} / {user_data['password']}")
+ self.stdout.write('\n')
diff --git a/backend/scripts/create_test_calendar_users.py b/backend/scripts/create_test_calendar_users.py
new file mode 100644
index 00000000..96ec5b41
--- /dev/null
+++ b/backend/scripts/create_test_calendar_users.py
@@ -0,0 +1,226 @@
+"""
+Скрипт для создания тестовых пользователей с календарями и событиями для системы schedule.
+
+Использование:
+ python manage.py shell < scripts/create_test_calendar_users.py
+
+ или
+
+ .venv/Scripts/python manage.py shell
+ >>> exec(open('scripts/create_test_calendar_users.py').read())
+"""
+
+from django.contrib.auth import get_user_model
+from schedule.models import Calendar, Event, Rule
+from datetime import datetime, timedelta
+from django.utils import timezone
+
+User = get_user_model()
+
+print("🚀 Создание тестовых пользователей и календарей...")
+
+# Создание пользователей
+test_users = [
+ {
+ 'username': 'anna_ivanova',
+ 'email': 'anna@example.com',
+ 'first_name': 'Анна',
+ 'last_name': 'Иванова',
+ 'password': 'test123',
+ },
+ {
+ 'username': 'petr_petrov',
+ 'email': 'petr@example.com',
+ 'first_name': 'Пётр',
+ 'last_name': 'Петров',
+ 'password': 'test123',
+ },
+ {
+ 'username': 'maria_sidorova',
+ 'email': 'maria@example.com',
+ 'first_name': 'Мария',
+ 'last_name': 'Сидорова',
+ 'password': 'test123',
+ },
+]
+
+created_users = []
+for user_data in test_users:
+ user, created = User.objects.get_or_create(
+ username=user_data['username'],
+ defaults={
+ 'email': user_data['email'],
+ 'first_name': user_data['first_name'],
+ 'last_name': user_data['last_name'],
+ }
+ )
+ if created:
+ user.set_password(user_data['password'])
+ user.save()
+ print(f"✅ Создан пользователь: {user.get_full_name()} ({user.username})")
+ else:
+ print(f"ℹ️ Пользователь уже существует: {user.get_full_name()} ({user.username})")
+ created_users.append(user)
+
+# Создание календарей для каждого пользователя
+calendar_data = [
+ {
+ 'user': created_users[0],
+ 'name': 'Рабочий календарь Анны',
+ 'slug': 'anna-work',
+ },
+ {
+ 'user': created_users[0],
+ 'name': 'Личные дела Анны',
+ 'slug': 'anna-personal',
+ },
+ {
+ 'user': created_users[1],
+ 'name': 'Проекты Петра',
+ 'slug': 'petr-projects',
+ },
+ {
+ 'user': created_users[1],
+ 'name': 'Встречи Петра',
+ 'slug': 'petr-meetings',
+ },
+ {
+ 'user': created_users[2],
+ 'name': 'Календарь Марии',
+ 'slug': 'maria-calendar',
+ },
+]
+
+created_calendars = []
+for cal_data in calendar_data:
+ calendar, created = Calendar.objects.get_or_create(
+ slug=cal_data['slug'],
+ defaults={
+ 'name': cal_data['name'],
+ }
+ )
+ if created:
+ print(f"✅ Создан календарь: {calendar.name}")
+ else:
+ print(f"ℹ️ Календарь уже существует: {calendar.name}")
+ created_calendars.append(calendar)
+
+# Создание тестовых событий
+now = timezone.now()
+today = now.date()
+
+events_data = [
+ # События для Анны (календарь 0 - рабочий)
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Встреча с клиентом',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=11)),
+ 'description': 'Обсуждение проекта',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Планерка команды',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=9)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=10)),
+ 'description': 'Еженедельная встреча',
+ },
+ {
+ 'calendar': created_calendars[0],
+ 'title': 'Презентация результатов',
+ 'start': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=15, minute=30)),
+ 'description': 'Демо для заказчика',
+ },
+ # События для Анны (календарь 1 - личный)
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Врач',
+ 'start': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=16)),
+ 'end': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=17)),
+ 'description': 'Плановый осмотр',
+ },
+ {
+ 'calendar': created_calendars[1],
+ 'title': 'Фитнес',
+ 'start': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=19)),
+ 'end': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=20)),
+ 'description': 'Тренировка',
+ },
+ # События для Петра (календарь 2 - проекты)
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Разработка нового модуля',
+ 'start': datetime.combine(today, datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today, datetime.min.time().replace(hour=18)),
+ 'description': 'Работа над функционалом',
+ },
+ {
+ 'calendar': created_calendars[2],
+ 'title': 'Code Review',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=15)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Проверка кода коллег',
+ },
+ # События для Петра (календарь 3 - встречи)
+ {
+ 'calendar': created_calendars[3],
+ 'title': 'Встреча с HR',
+ 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=11)),
+ 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=12)),
+ 'description': 'Собеседование кандидата',
+ },
+ {
+ 'calendar': created_calendars[3],
+ 'title': '1-on-1 с руководителем',
+ 'start': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=14)),
+ 'end': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=15)),
+ 'description': 'Обсуждение целей',
+ },
+ # События для Марии (календарь 4)
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Обучение новых сотрудников',
+ 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=13)),
+ 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
+ 'description': 'Вводный курс',
+ },
+ {
+ 'calendar': created_calendars[4],
+ 'title': 'Подготовка отчета',
+ 'start': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=10)),
+ 'end': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=13)),
+ 'description': 'Квартальный отчет',
+ },
+]
+
+print("\n📅 Создание событий...")
+for event_data in events_data:
+ # Преобразуем datetime в timezone-aware
+ start = timezone.make_aware(event_data['start']) if timezone.is_naive(event_data['start']) else event_data['start']
+ end = timezone.make_aware(event_data['end']) if timezone.is_naive(event_data['end']) else event_data['end']
+
+ event, created = Event.objects.get_or_create(
+ calendar=event_data['calendar'],
+ title=event_data['title'],
+ start=start,
+ defaults={
+ 'end': end,
+ 'description': event_data.get('description', ''),
+ }
+ )
+ if created:
+ print(f" ✅ {event.title} ({event.calendar.name})")
+ else:
+ print(f" ℹ️ Событие уже существует: {event.title}")
+
+print("\n" + "="*60)
+print("✨ Готово! Создано:")
+print(f" 👥 Пользователи: {len(created_users)}")
+print(f" 📅 Календари: {len(created_calendars)}")
+print(f" 🎯 События: {len(events_data)}")
+print("="*60)
+print("\n🔑 Учетные данные для входа:")
+for user_data in test_users:
+ print(f" {user_data['username']} / {user_data['password']}")
+print("\n")
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 925ed6d6..4496e85d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,15 +8,18 @@
"name": "corp.front",
"version": "0.1.0",
"dependencies": {
+ "date-fns": "^4.1.0",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"react": "19.2.3",
+ "react-big-calendar": "^1.19.4",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
+ "@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^19",
"cross-env": "^10.1.0",
"eslint": "^9",
@@ -217,6 +220,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1157,6 +1169,28 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@restart/hooks": {
+ "version": "0.4.16",
+ "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
+ "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1437,6 +1471,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/date-arithmetic": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz",
+ "integrity": "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1464,16 +1505,34 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
+ "node_modules/@types/react-big-calendar": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-1.16.3.tgz",
+ "integrity": "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/date-arithmetic": "*",
+ "@types/prop-types": "*",
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
@@ -1483,6 +1542,12 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/warning": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
+ "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
@@ -2435,6 +2500,15 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
+ "node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2500,8 +2574,7 @@
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -2560,6 +2633,28 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-arithmetic": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz",
+ "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==",
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.19",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2617,6 +2712,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2638,6 +2742,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3541,6 +3655,11 @@
"node": ">=10.13.0"
}
},
+ "node_modules/globalize": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/globalize/-/globalize-0.1.1.tgz",
+ "integrity": "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -3737,6 +3856,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4166,8 +4294,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.1",
@@ -4542,6 +4669,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4552,7 +4691,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -4578,6 +4716,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4596,6 +4743,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "license": "MIT"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4639,6 +4792,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/moment-timezone": {
+ "version": "0.5.48",
+ "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
+ "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
+ "license": "MIT",
+ "dependencies": {
+ "moment": "^2.29.4"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4790,7 +4964,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5068,7 +5241,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -5113,6 +5285,34 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-big-calendar": {
+ "version": "1.19.4",
+ "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.4.tgz",
+ "integrity": "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.20.7",
+ "clsx": "^1.2.1",
+ "date-arithmetic": "^4.1.0",
+ "dayjs": "^1.11.7",
+ "dom-helpers": "^5.2.1",
+ "globalize": "^0.1.1",
+ "invariant": "^2.2.4",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "luxon": "^3.2.1",
+ "memoize-one": "^6.0.0",
+ "moment": "^2.29.4",
+ "moment-timezone": "^0.5.40",
+ "prop-types": "^15.8.1",
+ "react-overlays": "^5.2.1",
+ "uncontrollable": "^7.2.1"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || ^17 || ^18 || ^19",
+ "react-dom": "^16.14.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
@@ -5128,8 +5328,33 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
+ "node_modules/react-lifecycles-compat": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
+ "license": "MIT"
+ },
+ "node_modules/react-overlays": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
+ "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.13.8",
+ "@popperjs/core": "^2.11.6",
+ "@restart/hooks": "^0.4.7",
+ "@types/warning": "^3.0.0",
+ "dom-helpers": "^5.2.0",
+ "prop-types": "^15.7.2",
+ "uncontrollable": "^7.2.1",
+ "warning": "^4.0.3"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ }
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -5965,6 +6190,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/uncontrollable": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
+ "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.6.3",
+ "@types/react": ">=16.9.11",
+ "invariant": "^2.2.4",
+ "react-lifecycles-compat": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react": ">=15.0.0"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -6044,6 +6284,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0225cef5..b50096bc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,15 +10,18 @@
"lint": "eslint"
},
"dependencies": {
+ "date-fns": "^4.1.0",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"react": "19.2.3",
+ "react-big-calendar": "^1.19.4",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
+ "@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^19",
"cross-env": "^10.1.0",
"eslint": "^9",
diff --git a/frontend/src/app/calendar/calendar.css b/frontend/src/app/calendar/calendar.css
new file mode 100644
index 00000000..56ca5965
--- /dev/null
+++ b/frontend/src/app/calendar/calendar.css
@@ -0,0 +1,265 @@
+/* Кастомные стили для React Big Calendar в едином стиле с проектом */
+
+.rbc-calendar {
+ font-family: inherit;
+}
+
+/* Панель инструментов */
+.rbc-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.25rem 0;
+ margin-bottom: 1.5rem;
+ gap: 1rem;
+}
+
+.rbc-btn-group {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.rbc-toolbar button {
+ padding: 0.625rem 1rem;
+ border-radius: 0.625rem;
+ border: 1px solid #e5e7eb;
+ background: white;
+ color: #374151;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+}
+
+.rbc-toolbar button:hover {
+ background: #f9fafb;
+ border-color: #d1d5db;
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
+}
+
+.rbc-toolbar button.rbc-active {
+ background: #0ea5e9;
+ color: white;
+ border-color: #0ea5e9;
+ box-shadow: 0 1px 3px 0 rgba(14, 165, 233, 0.3);
+}
+
+.rbc-toolbar-label {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #111827;
+ letter-spacing: -0.025em;
+ white-space: nowrap;
+}
+
+/* Заголовки дней недели */
+.rbc-header {
+ padding: 0.75rem 0.5rem;
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ color: #6b7280;
+ letter-spacing: 0.025em;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+/* Ячейки дней */
+.rbc-day-bg {
+ border: 1px solid #f3f4f6;
+}
+
+.rbc-day-bg.rbc-today {
+ background-color: #f0f9ff !important;
+}
+
+.rbc-day-bg.rbc-off-range-bg {
+ background-color: #fafafa;
+}
+
+.rbc-date-cell {
+ padding: 0.25rem;
+ text-align: right;
+}
+
+.rbc-date-cell button {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 9999px;
+ border: none;
+ background: transparent;
+ color: #374151;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-weight: 500;
+}
+
+.rbc-date-cell button:hover {
+ background: #f3f4f6;
+}
+
+.rbc-date-cell.rbc-now button {
+ background: #0ea5e9;
+ color: white;
+ font-weight: 600;
+}
+
+.rbc-date-cell.rbc-off-range button {
+ color: #9ca3af;
+}
+
+/* События */
+.rbc-event {
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.rbc-event:hover {
+ opacity: 1;
+}
+
+.rbc-event-label {
+ font-size: 0.625rem;
+ font-weight: 400;
+}
+
+.rbc-event-content {
+ font-size: 0.75rem;
+}
+
+/* Селектор временных слотов */
+.rbc-slot-selection {
+ background-color: rgba(14, 165, 233, 0.15);
+}
+
+/* Неделя и день */
+.rbc-time-view {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.75rem;
+}
+
+.rbc-time-header {
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.rbc-time-content {
+ border-top: 1px solid #e5e7eb;
+}
+
+.rbc-timeslot-group {
+ border-left: 1px solid #e5e7eb;
+ min-height: 40px;
+}
+
+.rbc-time-slot {
+ border-top: 1px solid #f3f4f6;
+}
+
+.rbc-current-time-indicator {
+ background-color: #ef4444;
+ height: 2px;
+}
+
+/* Список (agenda) */
+.rbc-agenda-view {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+}
+
+.rbc-agenda-view table.rbc-agenda-table {
+ border-spacing: 0;
+}
+
+.rbc-agenda-view table.rbc-agenda-table tbody > tr > td {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.rbc-agenda-view table.rbc-agenda-table tbody > tr:hover {
+ background-color: #f9fafb;
+}
+
+.rbc-agenda-date-cell {
+ font-weight: 600;
+ color: #374151;
+}
+
+.rbc-agenda-time-cell {
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+.rbc-agenda-event-cell {
+ color: #111827;
+}
+
+/* Попап "показать ещё" */
+.rbc-overlay {
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+ padding: 0.5rem;
+ z-index: 10;
+}
+
+.rbc-overlay-header {
+ padding: 0.5rem;
+ font-weight: 600;
+ color: #111827;
+ border-bottom: 1px solid #e5e7eb;
+ margin-bottom: 0.5rem;
+}
+
+/* Показать больше */
+.rbc-show-more {
+ background: transparent;
+ color: #0ea5e9;
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ border: none;
+}
+
+.rbc-show-more:hover {
+ background: #f0f9ff;
+}
+
+/* Месячный вид */
+.rbc-month-view {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.rbc-month-row {
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.rbc-month-row:last-child {
+ border-bottom: none;
+}
+
+/* Адаптивность */
+@media (max-width: 768px) {
+ .rbc-toolbar {
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .rbc-toolbar-label {
+ order: -1;
+ font-size: 1rem;
+ }
+
+ .rbc-header span {
+ font-size: 0.625rem;
+ }
+}
diff --git a/frontend/src/app/calendar/page.tsx b/frontend/src/app/calendar/page.tsx
new file mode 100644
index 00000000..edd5621d
--- /dev/null
+++ b/frontend/src/app/calendar/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { AppShell } from "@/components/AppShell";
+import { BigCalendar } from "@/components/BigCalendar";
+
+export default function CalendarPage() {
+ return (
+