Реализация системы schedule (django-scheduler) с тестовыми данными

- Добавлен CustomToolbar для BigCalendar со стрелками навигации
- Выпадающий список для выбора вида календаря
- Management command для создания тестовых пользователей и календарей
- Создано 3 тестовых пользователя (anna_ivanova, petr_petrov, maria_sidorova)
- Создано 5 календарей с 11 событиями
- Убраны подписки (они для старой системы calendar_app)
- Исправлены стили тулбара BigCalendar
- Модалы календаря рендерятся на корневом уровне AppShell
This commit is contained in:
igor_nadein 2026-02-24 20:24:39 +03:00
parent 74a29fde28
commit 0dd3ac7cc1
20 changed files with 3159 additions and 95 deletions

View File

@ -0,0 +1 @@
# backend/api/v1/schedule/management/__init__.py

View File

@ -0,0 +1 @@
# backend/api/v1/schedule/management/commands/__init__.py

View File

@ -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')

View File

@ -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'])

View File

@ -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):

View File

@ -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} событий")

View File

@ -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()}")

View File

@ -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')

View File

@ -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")

View File

@ -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",

View File

@ -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",

View File

@ -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;
}
}

View File

@ -0,0 +1,12 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { BigCalendar } from "@/components/BigCalendar";
export default function CalendarPage() {
return (
<AppShell>
<BigCalendar />
</AppShell>
);
}

View File

@ -1329,7 +1329,7 @@ export default function MessageDialogPage() {
{mediaPreview ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
onClick={() => setMediaPreview(null)}
>
<button
@ -1354,7 +1354,7 @@ export default function MessageDialogPage() {
{expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? (
<div
data-actions-menu="true"
className="fixed z-[90] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
className="fixed z-[60] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
style={{
left: actionsMenuAnchor.x,
top: actionsMenuAnchor.y - 6,
@ -1425,20 +1425,20 @@ export default function MessageDialogPage() {
) : null}
{reactionPickerForMessageId ? (
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/30 p-4" data-reaction-picker="true">
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-800">Выберите реакцию</p>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4" data-reaction-picker="true">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Выберите реакцию</h3>
<button
type="button"
onClick={() => setReactionPickerForMessageId(null)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100"
className="rounded-full p-1 hover:bg-gray-100"
aria-label="Закрыть"
>
<X size={14} />
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="grid max-h-[55vh] grid-cols-8 gap-1 overflow-y-auto">
<div className="grid max-h-[55vh] grid-cols-8 gap-2 overflow-y-auto">
{ALL_REACTIONS.map((emoji) => (
<button
key={`picker-${emoji}`}

View File

@ -677,12 +677,12 @@ export default function Home() {
</div>
{createPostOpen ? (
<div className="fixed inset-0 z-[95]">
<div className="fixed inset-0 z-[100]">
<button
type="button"
aria-label="Закрыть создание публикации"
onClick={() => setCreatePostOpen(false)}
className="absolute inset-0 bg-black/45"
className="absolute inset-0 bg-black/50"
/>
<div className="absolute inset-x-3 top-4 bottom-4 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:inset-x-6">
@ -799,12 +799,12 @@ export default function Home() {
) : null}
{commentsOpen && activePost && (
<div className="fixed inset-0 z-[90]">
<div className="fixed inset-0 z-[100]">
<button
type="button"
aria-label="Закрыть комментарии"
onClick={() => setCommentsOpen(false)}
className="absolute inset-0 bg-black/45"
className="absolute inset-0 bg-black/50"
/>
<div className="absolute inset-x-3 top-4 bottom-4 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:inset-x-6">

View File

@ -1,11 +1,13 @@
"use client";
import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X } from "lucide-react";
import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Plus, Search, Trash2, Users, Wallet, X } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { CalendarProvider, useCalendar } from "@/contexts/CalendarContext";
import { CalendarModal } from "@/components/CalendarModal";
type AppShellProps = {
children: ReactNode;
@ -30,6 +32,7 @@ type LeftNavContentProps = {
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon },
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
{ href: "/calendar", label: "Календарь", icon: CalendarDays },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/requests", label: "Заявления", icon: FileSignature },
@ -100,7 +103,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
};
return (
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
<header className="sticky top-0 z-[40] border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-14 items-center justify-between gap-3">
<button
@ -165,7 +168,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
) : null}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
@ -229,7 +232,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
) : null}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
@ -287,47 +290,25 @@ function LeftNav() {
);
}
function CalendarCard() {
function CalendarCard({
onOpenCalendarModal,
onOpenEventModal
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
}) {
const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar();
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [events, setEvents] = useState<SidebarCalendarEvent[]>([]);
const [calendars, setCalendars] = useState<Array<{ id: number; name: string }>>([]);
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Загрузка списка календарей при монтировании
useEffect(() => {
let cancelled = false;
async function loadCalendars() {
try {
const result = await apiClient.getCalendars();
if (!cancelled) {
const cals = Array.isArray(result) ? result : [];
setCalendars(cals);
// Автоматически выбираем первый календарь
if (cals.length > 0 && selectedCalendarId === null) {
setSelectedCalendarId(cals[0].id);
}
}
} catch (err) {
if (!cancelled) {
console.error("Ошибка загрузки календарей:", err);
}
}
}
loadCalendars();
return () => {
cancelled = true;
};
}, []);
// Загрузка событий выбранно календаря
// Загрузка событий выбранного календаря
useEffect(() => {
if (selectedCalendarId === null) {
setEvents([]);
@ -353,11 +334,12 @@ function CalendarCard() {
const result = await apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId,
calendar: selectedCalendarId ?? undefined,
});
if (!cancelled) {
setEvents(Array.isArray(result) ? result : []);
const eventsList = Array.isArray(result) ? result : (result?.results || []);
setEvents(eventsList);
}
} catch (err) {
if (!cancelled) {
@ -391,6 +373,43 @@ function CalendarCard() {
return label.charAt(0).toUpperCase() + label.slice(1);
}, [monthDate]);
// Обработчики календарей
const handleCreateCalendar = () => {
onOpenCalendarModal({ name: "" });
};
// Обработчики событий
const handleDayClick = (date: Date) => {
if (!selectedCalendarId) {
alert("Сначала выберите календарь");
return;
}
setSelectedDate(date);
const startDate = new Date(date);
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(date);
endDate.setHours(11, 0, 0, 0);
const newEvent = {
title: "",
description: "",
start: startDate.toISOString(),
end: endDate.toISOString(),
calendar: selectedCalendarId,
color_event: "#3498db",
};
onOpenEventModal(newEvent, date);
};
const handleEventClick = async (eventId: number) => {
try {
const event = await apiClient.getEvent(eventId);
onOpenEventModal(event);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
};
const eventDays = useMemo(() => {
const days = new Set<string>();
events.forEach((ev) => {
@ -418,6 +437,7 @@ function CalendarCard() {
return {
key,
day: date.getDate(),
date,
inCurrentMonth: date.getMonth() === monthDate.getMonth(),
isToday: sameDate(date, today),
hasEvents: eventDays.has(key),
@ -444,21 +464,58 @@ function CalendarCard() {
return (
<>
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500">Календарь</p>
{calendars.length === 0 ? (
<p className="text-xs text-gray-500">Календарей нет</p>
) : (
<select
value={selectedCalendarId || ""}
onChange={(e) => setSelectedCalendarId(Number(e.target.value))}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-2 text-xs text-gray-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Календарь</p>
<button
onClick={handleCreateCalendar}
className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-gray-100 transition"
title="Создать календарь"
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
<Plus size={14} className="text-gray-600" />
</button>
</div>
{calendars.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3">
<p className="mb-2 text-xs font-medium text-gray-700">Календарей пока нет</p>
<button
onClick={handleCreateCalendar}
className="w-full rounded-md bg-sky-500 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-600"
>
Создать первый календарь
</button>
</div>
) : (
<div className="flex items-center gap-2">
<select
value={selectedCalendarId || ""}
onChange={(e) => setSelectedCalendarId(Number(e.target.value))}
className="flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-2 text-xs text-gray-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
{selectedCalendarId && (
<button
onClick={() => {
const cal = calendars.find(c => c.id === selectedCalendarId);
if (cal) {
onOpenCalendarModal(cal);
}
}}
className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-gray-100 transition"
title="Настройки календаря"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
)}
</div>
)}
</div>
@ -494,21 +551,24 @@ function CalendarCard() {
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{days.map((day) => (
<div
<button
key={day.key}
className={`relative flex h-9 items-center justify-center rounded-full ${
type="button"
onClick={() => day.inCurrentMonth && handleDayClick(day.date)}
disabled={!day.inCurrentMonth}
className={`relative flex h-9 items-center justify-center rounded-full transition ${
day.isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: day.inCurrentMonth
? "hover:bg-sky-50"
: "text-gray-300"
? "hover:bg-sky-50 cursor-pointer"
: "text-gray-300 cursor-default"
}`}
>
{day.day}
{day.hasEvents && day.inCurrentMonth ? (
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-sky-500" />
) : null}
</div>
</button>
))}
</div>
</div>
@ -536,10 +596,15 @@ function CalendarCard() {
: "Без даты";
return (
<div key={`${event.id}-${event.start || ""}`} className="rounded-lg bg-gray-50 px-2.5 py-2">
<button
key={`${event.id}-${event.start || ""}`}
type="button"
onClick={() => handleEventClick(event.id)}
className="w-full rounded-lg bg-gray-50 px-2.5 py-2 text-left transition hover:bg-gray-100"
>
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
</div>
</button>
);
})}
</div>
@ -549,11 +614,20 @@ function CalendarCard() {
);
}
function Calendar() {
function Calendar({
onOpenCalendarModal,
onOpenEventModal
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
}) {
return (
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
<div className="space-y-4 lg:pb-2">
<CalendarCard />
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<div className="sticky top-22 space-y-4 lg:pb-2 max-h-[calc(100vh-5.5rem)] overflow-y-auto">
<CalendarCard
onOpenCalendarModal={onOpenCalendarModal}
onOpenEventModal={onOpenEventModal}
/>
</div>
</aside>
);
@ -585,6 +659,23 @@ export function AppShell({ children }: AppShellProps) {
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
// Состояния модалов календаря
const [showCalendarModal, setShowCalendarModal] = useState(false);
const [showEventModal, setShowEventModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
const [editingEvent, setEditingEvent] = useState<any>(null);
// Обработчики модалов
const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => {
setEditingCalendar(calendar || { name: "" });
setShowCalendarModal(true);
};
const handleOpenEventModal = (event: any, date?: Date) => {
setEditingEvent(event);
setShowEventModal(true);
};
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
if (!loading && !user) {
@ -644,15 +735,19 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<CalendarProvider>
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-6 sm:px-8 lg:py-8">
<LeftNav />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<Calendar />
<Calendar
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
/>
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
@ -679,7 +774,7 @@ export function AppShell({ children }: AppShellProps) {
</div>
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
@ -702,9 +797,161 @@ export function AppShell({ children }: AppShellProps) {
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard />
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
/>
</div>
</div>
</div>
{/* Модалы календаря - рендерятся на верхнем уровне */}
<CalendarModal
isOpen={showCalendarModal}
onClose={() => {
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
/>
{/* Модальное окно события */}
{showEventModal && editingEvent && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
</h3>
<button
onClick={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Название
</label>
<input
type="text"
value={editingEvent.title}
onChange={(e) => setEditingEvent({ ...editingEvent, title: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Название события"
autoFocus
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Описание
</label>
<textarea
value={editingEvent.description || ""}
onChange={(e) => setEditingEvent({ ...editingEvent, description: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Описание (необязательно)"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Начало</label>
<input
type="datetime-local"
value={editingEvent.start ? new Date(editingEvent.start).toISOString().slice(0, 16) : ""}
onChange={(e) => setEditingEvent({ ...editingEvent, start: new Date(e.target.value).toISOString() })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Конец</label>
<input
type="datetime-local"
value={editingEvent.end ? new Date(editingEvent.end).toISOString().slice(0, 16) : ""}
onChange={(e) => setEditingEvent({ ...editingEvent, end: new Date(e.target.value).toISOString() })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Цвет
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={editingEvent.color_event || "#3498db"}
onChange={(e) => setEditingEvent({ ...editingEvent, color_event: e.target.value })}
className="h-10 w-20 rounded-lg border border-gray-300 cursor-pointer"
/>
<span className="text-xs text-gray-500">{editingEvent.color_event || "#3498db"}</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={async () => {
if (!editingEvent.title.trim()) return;
try {
if (editingEvent.id) {
await apiClient.updateEvent(editingEvent.id, {
title: editingEvent.title,
description: editingEvent.description,
start: editingEvent.start,
end: editingEvent.end,
color_event: editingEvent.color_event,
});
} else {
await apiClient.createEvent(editingEvent);
}
setShowEventModal(false);
setEditingEvent(null);
// Обновление событий через контекст будет автоматическим
} catch (err) {
console.error("Ошибка сохранения события:", err);
alert("Не удалось сохранить событие");
}
}}
disabled={!editingEvent.title?.trim()}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{editingEvent.id ? "Сохранить" : "Создать"}
</button>
{editingEvent.id && (
<button
onClick={async () => {
if (!confirm(`Удалить событие "${editingEvent.title}"?`)) return;
try {
await apiClient.deleteEvent(editingEvent.id);
setShowEventModal(false);
setEditingEvent(null);
} catch (err) {
console.error("Ошибка удаления события:", err);
alert("Не удалось удалить событие");
}
}}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
)} </div>
</div>
</div>
</div>
)}
</div>
</CalendarProvider>
);
}

View File

@ -0,0 +1,483 @@
"use client";
import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from "react-big-calendar";
import { format, parse, startOfWeek, getDay } from "date-fns";
import { ru } from "date-fns/locale";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react";
import { CalendarModal } from "@/components/CalendarModal";
import "react-big-calendar/lib/css/react-big-calendar.css";
import "../app/calendar/calendar.css";
// Настройка локализации для react-big-calendar
const locales = {
ru: ru,
};
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { locale: ru }),
getDay,
locales,
});
// Типы для событий
type CalendarEvent = {
id: number;
title: string;
description?: string;
start: Date;
end: Date;
calendar: number;
color_event?: string;
rule?: number;
rule_description?: string;
};
// Кастомные сообщения на русском
const messages = {
allDay: "Весь день",
previous: "Назад",
next: "Вперёд",
today: "Сегодня",
month: "Месяц",
week: "Неделя",
day: "День",
agenda: "Список",
date: "Дата",
time: "Время",
event: "Событие",
noEventsInRange: "В этом диапазоне нет событий",
showMore: (total: number) => `+${total} ещё`,
};
// Кастомный тулбар с улучшенным UI
const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<CalendarEvent, object>) => {
const viewLabels = {
month: "Месяц",
week: "Неделя",
day: "День",
agenda: "Список",
};
// Форматируем дату в именительном падеже (Февраль 2026 вместо февраля 2026)
const formattedLabel = format(date, "LLLL yyyy", { locale: ru });
const capitalizedLabel = formattedLabel.charAt(0).toUpperCase() + formattedLabel.slice(1);
return (
<div className="mb-4 space-y-3">
{/* Навигация */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => onNavigate("TODAY")}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
Сегодня
</button>
<button
onClick={() => onNavigate("PREV")}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-300 text-gray-700 transition hover:bg-gray-50"
title="Назад"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => onNavigate("NEXT")}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-300 text-gray-700 transition hover:bg-gray-50"
title="Вперёд"
>
<ChevronRight size={18} />
</button>
</div>
{/* Текущая дата */}
<span className="text-lg font-semibold text-gray-800">{capitalizedLabel}</span>
</div>
{/* Выбор вида */}
<div>
<select
value={view}
onChange={(e) => onView(e.target.value as View)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="month">{viewLabels.month}</option>
<option value="week">{viewLabels.week}</option>
<option value="day">{viewLabels.day}</option>
<option value="agenda">{viewLabels.agenda}</option>
</select>
</div>
</div>
);
};
export function BigCalendar() {
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [currentView, setCurrentView] = useState<View>("month");
const [currentDate, setCurrentDate] = useState(new Date());
// Модальные окна
const [showEventModal, setShowEventModal] = useState(false);
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent> | null>(null);
const [showCalendarModal, setShowCalendarModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
// Загрузка событий
const loadEvents = useCallback(async () => {
if (!selectedCalendarId) {
setEvents([]);
return;
}
try {
setLoading(true);
// Вычисляем диапазон для загрузки (месяц ±1 неделя)
const start = new Date(currentDate);
start.setDate(1);
start.setDate(start.getDate() - 7);
const end = new Date(currentDate);
end.setMonth(end.getMonth() + 1);
end.setDate(7);
const result = await apiClient.getCalendarEvents({
calendar: selectedCalendarId,
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
});
// Обработка пагинированного ответа
const eventsList = Array.isArray(result) ? result : (result?.results || []);
const parsedEvents = eventsList.map((evt: any) => ({
...evt,
start: new Date(evt.start),
end: new Date(evt.end),
}));
setEvents(parsedEvents);
} catch (err) {
console.error("Ошибка загрузки событий:", err);
} finally {
setLoading(false);
}
}, [selectedCalendarId, currentDate]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
// Обработчики календаря
const handleSelectSlot = useCallback(
({ start, end }: { start: Date; end: Date }) => {
if (!selectedCalendarId) {
alert("Сначала выберите календарь");
return;
}
setEditingEvent({
title: "",
description: "",
start,
end,
calendar: selectedCalendarId,
color_event: "#3498db",
});
setShowEventModal(true);
},
[selectedCalendarId]
);
const handleSelectEvent = useCallback((event: CalendarEvent) => {
setEditingEvent(event);
setShowEventModal(true);
}, []);
const handleSaveEvent = async () => {
if (!editingEvent || !editingEvent.title?.trim()) return;
try {
const eventData = {
title: editingEvent.title!,
description: editingEvent.description,
start: editingEvent.start!.toISOString(),
end: editingEvent.end!.toISOString(),
calendar: editingEvent.calendar!,
color_event: editingEvent.color_event || "#3498db",
};
if (editingEvent.id) {
await apiClient.updateEvent(editingEvent.id, eventData);
} else {
await apiClient.createEvent(eventData);
}
await loadEvents();
setShowEventModal(false);
setEditingEvent(null);
} catch (err) {
console.error("Ошибка сохранения события:", err);
alert("Не удалось сохранить событие");
}
};
const handleDeleteEvent = async () => {
if (!editingEvent?.id || !confirm(`Удалить событие "${editingEvent.title}"?`)) return;
try {
await apiClient.deleteEvent(editingEvent.id);
await loadEvents();
setShowEventModal(false);
setEditingEvent(null);
} catch (err) {
console.error("Ошибка удаления события:", err);
alert("Не удалось удалить событие");
}
};
// Стилизация событий по цвету
const eventStyleGetter = (event: CalendarEvent) => {
const backgroundColor = event.color_event || "#3498db";
return {
style: {
backgroundColor,
borderRadius: "6px",
opacity: 0.9,
color: "white",
border: "none",
display: "block",
},
};
};
return (
<>
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100">
{/* Панель выбора календаря */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3 flex-1">
<label className="text-sm font-medium text-gray-700">Календарь:</label>
{calendars.length === 0 ? (
<div className="flex items-center gap-2">
<p className="text-sm text-gray-500">Календарей нет.</p>
<button
onClick={() => {
setEditingCalendar({ name: "" });
setShowCalendarModal(true);
}}
className="text-sm text-sky-500 hover:text-sky-600 font-medium transition-colors"
>
Создать календарь
</button>
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<select
value={selectedCalendarId || ""}
onChange={(e) => setSelectedCalendarId(Number(e.target.value))}
className="flex-1 max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
<button
onClick={() => {
setEditingCalendar({ name: "" });
setShowCalendarModal(true);
}}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-300 text-gray-700 transition hover:bg-gray-50"
title="Создать календарь"
>
<Plus size={16} />
</button>
{selectedCalendarId && (
<button
onClick={() => {
const cal = calendars.find((c) => c.id === selectedCalendarId);
if (cal) {
setEditingCalendar({ id: cal.id, name: cal.name });
setShowCalendarModal(true);
}
}}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-300 text-gray-700 transition hover:bg-gray-50"
title="Настройки календаря"
>
<Settings size={16} />
</button>
)}
</div>
)}
</div>
{loading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-sky-500"></div>
<span>Загрузка...</span>
</div>
)}
</div>
{/* Календарь */}
<div className="overflow-hidden px-6 pb-6" style={{ height: "750px" }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
messages={messages}
culture="ru"
view={currentView}
onView={(view: View) => setCurrentView(view)}
date={currentDate}
onNavigate={(date: Date) => setCurrentDate(date)}
onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent}
selectable
eventPropGetter={eventStyleGetter}
popup
components={{
toolbar: CustomToolbar,
}}
/>
</div>
</div>
{/* Модальное окно события */}
{showEventModal && editingEvent && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
</h3>
<button
onClick={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Название</label>
<input
type="text"
value={editingEvent.title || ""}
onChange={(e) => setEditingEvent({ ...editingEvent, title: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Название события"
autoFocus
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Описание</label>
<textarea
value={editingEvent.description || ""}
onChange={(e) => setEditingEvent({ ...editingEvent, description: e.target.value })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Описание (необязательно)"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Начало</label>
<input
type="datetime-local"
value={
editingEvent.start
? format(editingEvent.start, "yyyy-MM-dd'T'HH:mm")
: ""
}
onChange={(e) =>
setEditingEvent({ ...editingEvent, start: new Date(e.target.value) })
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Конец</label>
<input
type="datetime-local"
value={
editingEvent.end ? format(editingEvent.end, "yyyy-MM-dd'T'HH:mm") : ""
}
onChange={(e) =>
setEditingEvent({ ...editingEvent, end: new Date(e.target.value) })
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Цвет</label>
<div className="flex items-center gap-2">
<input
type="color"
value={editingEvent.color_event || "#3498db"}
onChange={(e) =>
setEditingEvent({ ...editingEvent, color_event: e.target.value })
}
className="h-10 w-20 cursor-pointer rounded-lg border border-gray-300"
/>
<span className="text-xs text-gray-500">
{editingEvent.color_event || "#3498db"}
</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveEvent}
disabled={!editingEvent.title?.trim()}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{editingEvent.id ? "Сохранить" : "Создать"}
</button>
{editingEvent.id && (
<button
onClick={handleDeleteEvent}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* Модальное окно управления календарем */}
<CalendarModal
isOpen={showCalendarModal}
onClose={() => {
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
/>
</>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from "react";
import { X, Trash2 } from "lucide-react";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
type Calendar = {
id?: number;
name: string;
slug?: string;
};
type CalendarModalProps = {
isOpen: boolean;
onClose: () => void;
calendar?: Calendar | null;
};
export function CalendarModal({ isOpen, onClose, calendar }: CalendarModalProps) {
const { reloadCalendars, setSelectedCalendarId } = useCalendar();
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen && calendar) {
setName(calendar.name);
} else if (isOpen) {
setName("");
}
}, [isOpen, calendar]);
if (!isOpen) return null;
const handleSave = async () => {
if (!name.trim()) return;
try {
setSaving(true);
if (calendar?.id) {
// Обновление
await apiClient.updateCalendar(calendar.id, { name: name.trim() });
} else {
// Создание
const newCal = await apiClient.createCalendar({ name: name.trim() });
setSelectedCalendarId(newCal.id);
}
await reloadCalendars();
onClose();
} catch (err) {
console.error("Ошибка сохранения календаря:", err);
alert("Не удалось сохранить календарь");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!calendar?.id || !confirm(`Удалить календарь "${calendar.name}"?`)) return;
try {
setSaving(true);
console.log("Удаление календаря ID:", calendar.id);
await apiClient.deleteCalendar(calendar.id);
await reloadCalendars();
onClose();
} catch (err) {
console.error("Ошибка удаления календаря:", err);
const errorMessage = err instanceof Error ? err.message : "Неизвестная ошибка";
alert(`Не удалось удалить календарь: ${errorMessage}`);
} finally {
setSaving(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
onClose();
}
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{calendar?.id ? "Редактировать календарь" : "Создать календарь"}
</h3>
<button
onClick={onClose}
disabled={saving}
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
{!calendar?.id && (
<p className="text-sm text-gray-600">
Создайте календарь для организации событий. Вы сможете добавлять встречи, задачи и напоминания.
</p>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Название<span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
placeholder="Например: Рабочий календарь"
autoFocus
disabled={saving}
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Сохранение..." : calendar?.id ? "Сохранить" : "Создать"}
</button>
{calendar?.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить календарь"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,301 @@
"use client";
import { useState, useEffect } from "react";
import { X, Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api";
interface Calendar {
id: number;
name: string;
title?: string;
color: string;
is_subscribed?: boolean;
subscriber_count?: number;
visibility?: string;
owner_user?: { id: number; username: string; full_name: string } | null;
owner_department?: { id: number; name: string } | null;
}
interface Subscription {
id: number;
calendar: number;
calendar_title: string;
calendar_color: string;
is_visible: boolean;
color_override: string | null;
notify_on_new_event: boolean;
notify_on_event_change: boolean;
can_edit: boolean;
can_manage: boolean;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onUpdate?: () => void;
}
export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props) {
const [availableCalendars, setAvailableCalendars] = useState<Calendar[]>([]);
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<"available" | "subscribed">("available");
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
const loadData = async () => {
setLoading(true);
try {
const [calendarsData, subsData] = await Promise.all([
apiClient.getCalendars(),
apiClient.getCalendarSubscriptions(),
]);
const calendarsList = Array.isArray(calendarsData)
? calendarsData
: calendarsData?.results || [];
const subsList = Array.isArray(subsData)
? subsData
: subsData?.results || [];
setAvailableCalendars(calendarsList);
setSubscriptions(subsList);
} catch (err) {
console.error("Ошибка загрузки данных:", err);
} finally {
setLoading(false);
}
};
const handleSubscribe = async (calendarId: number) => {
try {
await apiClient.subscribeToCalendar(calendarId);
await loadData();
onUpdate?.();
} catch (err) {
console.error("Ошибка подписки:", err);
alert("Не удалось подписаться на календарь");
}
};
const handleUnsubscribe = async (calendarId: number) => {
if (!confirm("Отписаться от календаря?")) return;
try {
await apiClient.unsubscribeFromCalendar(calendarId);
await loadData();
onUpdate?.();
} catch (err) {
console.error("Ошибка отписки:", err);
alert("Не удалось отписаться от календаря");
}
};
const toggleVisibility = async (sub: Subscription) => {
try {
await apiClient.updateSubscription(sub.id, {
is_visible: !sub.is_visible,
});
await loadData();
onUpdate?.();
} catch (err) {
console.error("Ошибка обновления:", err);
}
};
const toggleNotifications = async (sub: Subscription) => {
try {
await apiClient.updateSubscription(sub.id, {
notify_on_new_event: !sub.notify_on_new_event,
notify_on_event_change: !sub.notify_on_event_change,
});
await loadData();
} catch (err) {
console.error("Ошибка обновления:", err);
}
};
const getCalendarOwner = (calendar: Calendar) => {
if (calendar.owner_user) {
return calendar.owner_user.full_name || calendar.owner_user.username;
}
if (calendar.owner_department) {
return calendar.owner_department.name;
}
return "Общий";
};
const subscribedCalendarIds = new Set(subscriptions.map(s => s.calendar));
const unsubscribedCalendars = availableCalendars.filter(
cal => !subscribedCalendarIds.has(cal.id)
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
<h3 className="text-lg font-semibold text-gray-900">Подписки на календари</h3>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setActiveTab("available")}
className={`flex-1 px-6 py-3 text-sm font-medium transition ${
activeTab === "available"
? "border-b-2 border-sky-500 text-sky-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
Доступные ({unsubscribedCalendars.length})
</button>
<button
onClick={() => setActiveTab("subscribed")}
className={`flex-1 px-6 py-3 text-sm font-medium transition ${
activeTab === "subscribed"
? "border-b-2 border-sky-500 text-sky-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
Подписки ({subscriptions.length})
</button>
</div>
{/* Content */}
<div className="max-h-[500px] overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-gray-400" />
</div>
) : activeTab === "available" ? (
<div className="space-y-2">
{unsubscribedCalendars.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">
Нет доступных календарей для подписки
</p>
) : (
unsubscribedCalendars.map((cal) => (
<div
key={cal.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-4 transition hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: cal.color }}
/>
<div>
<p className="font-medium text-gray-900">{cal.name || cal.title}</p>
<p className="text-xs text-gray-500">{getCalendarOwner(cal)}</p>
</div>
</div>
<button
onClick={() => handleSubscribe(cal.id)}
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600"
>
Подписаться
</button>
</div>
))
)}
</div>
) : (
<div className="space-y-2">
{subscriptions.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">
У вас нет подписок на календари
</p>
) : (
subscriptions.map((sub) => (
<div
key={sub.id}
className="rounded-lg border border-gray-200 p-4"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: sub.color_override || sub.calendar_color }}
/>
<div>
<p className="font-medium text-gray-900">{sub.calendar_title}</p>
<div className="mt-1 flex gap-2">
{sub.can_edit && (
<span className="rounded bg-green-100 px-2 py-0.5 text-xs text-green-700">
Редактирование
</span>
)}
{sub.can_manage && (
<span className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
Управление
</span>
)}
</div>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => toggleVisibility(sub)}
className="rounded-lg border border-gray-300 p-2 transition hover:bg-gray-50"
title={sub.is_visible ? "Скрыть" : "Показать"}
>
{sub.is_visible ? (
<Eye size={16} className="text-gray-600" />
) : (
<EyeOff size={16} className="text-gray-400" />
)}
</button>
<button
onClick={() => toggleNotifications(sub)}
className="rounded-lg border border-gray-300 p-2 transition hover:bg-gray-50"
title={sub.notify_on_new_event ? "Отключить уведомления" : "Включить уведомления"}
>
{sub.notify_on_new_event ? (
<Bell size={16} className="text-gray-600" />
) : (
<BellOff size={16} className="text-gray-400" />
)}
</button>
<button
onClick={() => handleUnsubscribe(sub.calendar)}
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 transition hover:bg-red-100"
>
Отписаться
</button>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end border-t border-gray-100 px-6 py-4">
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
Закрыть
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from "react";
import { apiClient } from "@/lib/api";
type Calendar = {
id: number;
name: string;
slug: string;
events_count?: number;
};
type CalendarContextType = {
calendars: Calendar[];
selectedCalendarId: number | null;
setSelectedCalendarId: (id: number | null) => void;
loading: boolean;
error: string | null;
reloadCalendars: () => Promise<void>;
};
const CalendarContext = createContext<CalendarContextType | undefined>(undefined);
export function CalendarProvider({ children }: { children: ReactNode }) {
const [calendars, setCalendars] = useState<Calendar[]>([]);
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoadDone = useRef(false);
const loadCalendars = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await apiClient.getCalendars();
// API возвращает пагинированный ответ: {count, results}
const cals = Array.isArray(result) ? result : (result?.results || []);
console.log("Загружено календарей:", cals.length, cals);
setCalendars(cals);
// Автоматически выбираем первый календарь только при первой загрузке
if (!initialLoadDone.current && cals.length > 0 && selectedCalendarId === null) {
setSelectedCalendarId(cals[0].id);
}
initialLoadDone.current = true;
} catch (err) {
console.error("Ошибка загрузки календарей:", err);
setError("Не удалось загрузить календари");
} finally {
setLoading(false);
}
}, [selectedCalendarId]);
useEffect(() => {
loadCalendars();
}, []);
return (
<CalendarContext.Provider
value={{
calendars,
selectedCalendarId,
setSelectedCalendarId,
loading,
error,
reloadCalendars: loadCalendars,
}}
>
{children}
</CalendarContext.Provider>
);
}
export function useCalendar() {
const context = useContext(CalendarContext);
if (context === undefined) {
throw new Error("useCalendar must be used within CalendarProvider");
}
return context;
}