fix(calendar): timezone handling для datetime-local inputs

- Добавлена функция toLocalDateTimeString для корректной конвертации UTC в локальное время
- Исправлено отображение времени в модальном окне событий (было 21:00 UTC вместо 00:00 MSK)
- Упрощена логика datetime-local inputs (удалена сложная условная логика)
- Добавлен allDayAccessor в react-big-calendar для корректного отображения событий
- Унифицирован EventModal для обоих календарей (~718 строк удалено)
This commit is contained in:
igor_nadein 2026-02-25 00:38:54 +03:00
parent 0dd3ac7cc1
commit 45870fba86
8 changed files with 2674 additions and 580 deletions

View File

@ -4,21 +4,72 @@ Serializers для django-scheduler.
Чистые, проверенные временем модели.
"""
from rest_framework import serializers
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation
from django.contrib.contenttypes.models import ContentType
import pytz
class CalendarRelationSerializer(serializers.ModelSerializer):
"""Сериализатор для CalendarRelation (участники календаря)."""
user_id = serializers.IntegerField(write_only=True, required=False)
user = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CalendarRelation
fields = ['id', 'calendar', 'user_id', 'user', 'distinction', 'inheritable']
read_only_fields = ['id']
def get_user(self, obj):
"""Информация о связанном пользователе."""
if obj.content_object:
return {
'id': obj.content_object.id,
'username': getattr(obj.content_object, 'username', str(obj.content_object)),
'first_name': getattr(obj.content_object, 'first_name', ''),
'last_name': getattr(obj.content_object, 'last_name', ''),
}
return None
def create(self, validated_data):
"""Создать связь календаря с пользователем."""
from django.contrib.auth import get_user_model
User = get_user_model()
user_id = validated_data.pop('user_id')
calendar = validated_data['calendar']
distinction = validated_data.get('distinction', 'viewer')
inheritable = validated_data.get('inheritable', True)
user = User.objects.get(id=user_id)
# Используем встроенный метод create_relation
calendar.create_relation(user, distinction=distinction, inheritable=inheritable)
# Возвращаем созданную relation
ct = ContentType.objects.get_for_model(User)
return CalendarRelation.objects.get(
calendar=calendar,
content_type=ct,
object_id=user_id,
distinction=distinction
)
class CalendarSerializer(serializers.ModelSerializer):
"""Сериализатор для Calendar (django-scheduler) с совместимостью EUSRR."""
# Совместимость с EUSRR API - принимаем title, конвертируем в name
title = serializers.CharField(source='name', required=False)
events_count = serializers.SerializerMethodField()
owner = serializers.SerializerMethodField()
participants = serializers.SerializerMethodField()
user_role = serializers.SerializerMethodField()
class Meta:
model = Calendar
fields = ['id', 'name', 'title', 'slug', 'events_count']
read_only_fields = ['id']
fields = ['id', 'name', 'title', 'slug', 'events_count', 'owner', 'participants', 'user_role']
read_only_fields = ['id', 'owner', 'participants', 'user_role']
extra_kwargs = {
'slug': {'required': False}, # Сделаем auto-generated если не указан
}
@ -27,6 +78,69 @@ class CalendarSerializer(serializers.ModelSerializer):
"""Количество событий в календаре."""
return obj.event_set.count()
def get_owner(self, obj):
"""Получить владельца календаря."""
try:
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
owner_relation = CalendarRelation.objects.filter(
calendar=obj,
content_type=ct,
distinction='owner'
).first()
if owner_relation and owner_relation.content_object:
return {
'id': owner_relation.content_object.id,
'username': getattr(owner_relation.content_object, 'username', str(owner_relation.content_object)),
'full_name': str(owner_relation.content_object),
}
except Exception:
pass
return None
def get_participants(self, obj):
"""Список всех участников календаря."""
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
relations = CalendarRelation.objects.filter(
calendar=obj,
content_type=ct
).select_related('content_type')
participants = []
for rel in relations:
if rel.content_object:
participants.append({
'id': rel.content_object.id,
'username': getattr(rel.content_object, 'username', str(rel.content_object)),
'full_name': str(rel.content_object),
'role': rel.distinction,
})
return participants
def get_user_role(self, obj):
"""Роль текущего пользователя для этого календаря."""
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return None
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
relation = CalendarRelation.objects.filter(
calendar=obj,
content_type=ct,
object_id=request.user.id
).first()
return relation.distinction if relation else None
def create(self, validated_data):
"""Создать календарь с автогенерацией slug из name/title."""
from django.utils.text import slugify
@ -53,10 +167,39 @@ class CalendarSerializer(serializers.ModelSerializer):
class RuleSerializer(serializers.ModelSerializer):
"""Сериализатор для Rule (rrule RFC 5545)."""
# Удобные поля для frontend
frequency_display = serializers.CharField(source='get_frequency_display', read_only=True)
class Meta:
model = Rule
fields = ['id', 'name', 'description', 'frequency', 'params']
read_only_fields = ['id']
fields = ['id', 'name', 'description', 'frequency', 'frequency_display', 'params']
read_only_fields = ['id', 'frequency_display']
def to_representation(self, instance):
"""Десериализация params из JSON-строки в dict."""
import json
data = super().to_representation(instance)
# Парсим params из строки в объект для frontend
if data.get('params'):
try:
if isinstance(data['params'], str):
data['params'] = json.loads(data['params'])
except (json.JSONDecodeError, TypeError):
data['params'] = {}
else:
data['params'] = {}
return data
def validate_frequency(self, value):
"""Валидация frequency."""
valid_frequencies = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
if value not in valid_frequencies:
raise serializers.ValidationError(
f'Invalid frequency. Must be one of: {", ".join(valid_frequencies)}'
)
return value
class EventSerializer(serializers.ModelSerializer):
@ -105,11 +248,14 @@ class EventListSerializer(EventSerializer):
fields = [
'id',
'title',
'description',
'start',
'end',
'calendar',
'calendar_name',
'color_event',
'rule',
'end_recurring_period',
]
@ -144,6 +290,10 @@ class EventRelationSerializer(serializers.ModelSerializer):
event_title = serializers.CharField(source='event.title', read_only=True)
user_name = serializers.SerializerMethodField()
content_type = serializers.PrimaryKeyRelatedField(
queryset=ContentType.objects.all(),
required=False # Делаем необязательным
)
class Meta:
model = EventRelation
@ -153,15 +303,21 @@ class EventRelationSerializer(serializers.ModelSerializer):
'event_title',
'content_type',
'object_id',
'content_object',
'user_name',
'distinction',
'created_on',
'user_name',
]
read_only_fields = ['id', 'created_on']
read_only_fields = ['id']
def get_user_name(self, obj):
"""Имя связанного объекта."""
if obj.content_object:
return str(obj.content_object)
return None
def create(self, validated_data):
"""Автоматически устанавливает content_type для User."""
if 'content_type' not in validated_data:
from django.contrib.auth import get_user_model
User = get_user_model()
validated_data['content_type'] = ContentType.objects.get_for_model(User)
return super().create(validated_data)

View File

@ -8,15 +8,20 @@ from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
from django.http import HttpResponse
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation
from schedule.periods import Period
from schedule.feeds import CalendarICalendar # Правильный импорт
from datetime import datetime
from django.utils.dateparse import parse_datetime
from .serializers import (
CalendarSerializer,
CalendarRelationSerializer,
EventSerializer,
EventListSerializer,
RuleSerializer,
@ -26,27 +31,47 @@ from .serializers import (
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
"""CRUD для django-scheduler Calendar.
"""CRUD для django-scheduler Calendar с системой прав доступа.
Endpoints:
- GET /api/v1/schedule/calendars/ - список
- POST /api/v1/schedule/calendars/ - создать
- GET /api/v1/schedule/calendars/ - список календарей (только доступные пользователю)
- POST /api/v1/schedule/calendars/ - создать (автоматически owner)
- GET /api/v1/schedule/calendars/{id}/ - детали
- PUT/PATCH /api/v1/schedule/calendars/{id}/ - обновить
- DELETE /api/v1/schedule/calendars/{id}/ - удалить
Note: Использует только IsAuthenticated, без DjangoModelPermissions,
так как все авторизованные пользователи могут управлять календарями.
Дополнительные actions:
- POST /api/v1/schedule/calendars/{id}/add_participant/ - добавить участника
- DELETE /api/v1/schedule/calendars/{id}/remove_participant/ - удалить участника
- GET /api/v1/schedule/calendars/{id}/participants/ - список участников
- GET /api/v1/schedule/calendars/{id}/export_ical/ - экспорт в .ics
"""
queryset = Calendar.objects.all()
serializer_class = CalendarSerializer
permission_classes = [IsAuthenticated] # Переопределяем глобальные настройки
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def get_queryset(self):
"""Фильтрация календарей."""
"""Фильтрация календарей по доступу пользователя."""
from django.contrib.auth import get_user_model
User = get_user_model()
qs = super().get_queryset()
user = self.request.user
# Админы видят все
if user.is_staff or user.is_superuser:
return qs.order_by('name')
# Остальные видят только календари, где они участники
ct = ContentType.objects.get_for_model(User)
calendar_ids = CalendarRelation.objects.filter(
content_type=ct,
object_id=user.id
).values_list('calendar_id', flat=True)
qs = qs.filter(id__in=calendar_ids)
# Фильтр по имени
name = self.request.query_params.get('name')
@ -55,12 +80,221 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
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)
def perform_create(self, serializer):
"""При создании календаря автоматически добавляем creator как owner."""
calendar = serializer.save()
# Создаем CalendarRelation с distinction='owner'
calendar.create_relation(self.request.user, distinction='owner', inheritable=True)
@action(detail=True, methods=['post'], url_path='add-participant')
def add_participant(self, request, pk=None):
"""Добавить участника к календарю.
Body:
{
"user_id": 123,
"role": "viewer" # owner, editor, viewer
}
"""
calendar = self.get_object()
# Проверка прав (только owner может добавлять участников)
if not self._is_owner(calendar, request.user):
return Response(
{'detail': 'Только владелец может добавлять участников'},
status=status.HTTP_403_FORBIDDEN
)
serializer = CalendarRelationSerializer(data={
'calendar': calendar.id,
'user_id': request.data.get('user_id'),
'distinction': request.data.get('role', 'viewer'),
'inheritable': True
})
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['delete'], url_path='remove-participant/(?P<user_id>[0-9]+)')
def remove_participant(self, request, pk=None, user_id=None):
"""Удалить участника из календаря."""
calendar = self.get_object()
# Проверка прав
if not self._is_owner(calendar, request.user):
return Response(
{'detail': 'Только владелец может удалять участников'},
status=status.HTTP_403_FORBIDDEN
)
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
# Нельзя удалить owner
relation = CalendarRelation.objects.filter(
calendar=calendar,
content_type=ct,
object_id=user_id
).first()
if not relation:
return Response(
{'detail': 'Участник не найден'},
status=status.HTTP_404_NOT_FOUND
)
if relation.distinction == 'owner':
return Response(
{'detail': 'Нельзя удалить владельца календаря'},
status=status.HTTP_400_BAD_REQUEST
)
relation.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get'])
def participants(self, request, pk=None):
"""Получить список всех участников календаря."""
calendar = self.get_object()
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
relations = CalendarRelation.objects.filter(
calendar=calendar,
content_type=ct
)
serializer = CalendarRelationSerializer(relations, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='export-ical')
def export_ical(self, request, pk=None):
"""Экспорт календаря в формат iCalendar (.ics).
Использует встроенный CalendarICalendar из django-scheduler.
"""
calendar = self.get_object()
# Создаем feed для экспорта
feed = CalendarICalendar()
response = feed(request, pk)
# Устанавливает имя файла
response['Content-Disposition'] = f'attachment; filename="{calendar.slug}.ics"'
return response
@action(detail=True, methods=['post'], url_path='import-ical')
def import_ical(self, request, pk=None):
"""Импорт событий из файла iCalendar (.ics) в календарь.
Ожидает файл в request.FILES['file'].
"""
from icalendar import Calendar as ICalendar
from django.utils import timezone
calendar = self.get_object()
# Проверка, что пользователь - владелец
if not self._is_owner(calendar, request.user):
return Response(
{'error': 'Только владелец может импортировать события в календарь'},
status=status.HTTP_403_FORBIDDEN
)
# Проверка наличия файла
if 'file' not in request.FILES:
return Response(
{'error': 'Файл не найден. Отправьте файл с ключом "file"'},
status=status.HTTP_400_BAD_REQUEST
)
ics_file = request.FILES['file']
try:
# Парсим iCalendar файл
ical = ICalendar.from_ical(ics_file.read())
imported_count = 0
skipped_count = 0
errors = []
# Проходим по всем событиям в файле
for component in ical.walk():
if component.name == "VEVENT":
try:
# Извлекаем данные события
summary = str(component.get('summary', ''))
description = str(component.get('description', ''))
dtstart = component.get('dtstart')
dtend = component.get('dtend')
if not dtstart:
skipped_count += 1
continue
# Преобразуем даты
start_dt = dtstart.dt
if not timezone.is_aware(start_dt):
start_dt = timezone.make_aware(start_dt)
if dtend:
end_dt = dtend.dt
if not timezone.is_aware(end_dt):
end_dt = timezone.make_aware(end_dt)
else:
# Если нет времени окончания, делаем событие длиной 1 час
from datetime import timedelta
end_dt = start_dt + timedelta(hours=1)
# Создаем событие
Event.objects.create(
calendar=calendar,
title=summary or 'Без названия',
description=description,
start=start_dt,
end=end_dt,
creator=request.user
)
imported_count += 1
except Exception as e:
errors.append(f"Ошибка импорта события '{summary}': {str(e)}")
skipped_count += 1
return Response({
'success': True,
'imported': imported_count,
'skipped': skipped_count,
'errors': errors if errors else None
})
except Exception as e:
return Response(
{'error': f'Ошибка парсинга файла: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
def _is_owner(self, calendar, user):
"""Проверка, является ли пользователь владельцем календаря."""
if user.is_staff or user.is_superuser:
return True
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
return CalendarRelation.objects.filter(
calendar=calendar,
content_type=ct,
object_id=user.id,
distinction='owner'
).exists()
class ScheduleEventViewSet(viewsets.ModelViewSet):
@ -155,7 +389,7 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
)
# Используем schedule.periods.Period для получения вхождений
events = calendar.event_set.all()
events = Event.objects.filter(calendar=calendar)
period = Period(events, start_dt, end_dt)
# Результат
@ -164,11 +398,14 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
occurrences.append({
'id': occurrence.event.id,
'title': occurrence.title or occurrence.event.title,
'description': occurrence.event.description,
'start': occurrence.start.isoformat(),
'end': occurrence.end.isoformat(),
'color_event': occurrence.event.color_event,
'event_id': occurrence.event.id,
'is_recurring': occurrence.event.rule_id is not None,
'rule': occurrence.event.rule_id,
'end_recurring_period': occurrence.event.end_recurring_period.isoformat() if occurrence.event.end_recurring_period else None,
})
return Response(occurrences)

View File

@ -14,7 +14,61 @@ User = get_user_model()
class Command(BaseCommand):
help = 'Создание тестовых пользователей и календарей для системы schedule'
def add_arguments(self, parser):
parser.add_argument(
'--clean',
action='store_true',
help='Удалить существующих тестовых пользователей и календари перед созданием новых',
)
def handle(self, *args, **options):
# Удаление существующих данных если указан флаг --clean
if options['clean']:
self.stdout.write(self.style.WARNING('\nУдаление существующих тестовых данных...\n'))
# Список тестовых пользователей для удаления
test_usernames = ['anna_ivanova', 'petr_petrov', 'maria_sidorova']
for username in test_usernames:
try:
user = User.objects.get(username=username)
# Сначала удаляем события в календарях пользователя
from schedule.models import CalendarRelation
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(User)
calendar_relations = CalendarRelation.objects.filter(
content_type=ct,
object_id=user.id,
distinction='owner'
)
for rel in calendar_relations:
calendar = rel.calendar
events_count = calendar.event_set.count()
calendar.event_set.all().delete()
calendar.delete()
self.stdout.write(f" Удален календарь: {calendar.name} ({events_count} событий)")
user.delete()
self.stdout.write(self.style.SUCCESS(f" Удален пользователь: {username}"))
except User.DoesNotExist:
self.stdout.write(f" Пользователь {username} не найден (пропуск)")
# Удаляем календари по slug (если они остались)
calendar_slugs = ['anna-work', 'anna-personal', 'petr-projects', 'petr-meetings', 'maria-calendar']
for slug in calendar_slugs:
try:
calendar = Calendar.objects.get(slug=slug)
events_count = calendar.event_set.count()
calendar.event_set.all().delete()
calendar.delete()
self.stdout.write(f" Удален календарь: {slug} ({events_count} событий)")
except Calendar.DoesNotExist:
pass
self.stdout.write(self.style.SUCCESS('\nОчистка завершена!\n'))
self.stdout.write('Создание тестовых пользователей и календарей...\n')
# Создание пользователей
@ -58,6 +112,7 @@ class Command(BaseCommand):
)
if created:
user.set_password(user_data['password'])
user.email_verified = True # Автоматически подтверждаем email для тестовых пользователей
user.save()
self.stdout.write(self.style.SUCCESS(
f"Создан пользователь: {user.get_full_name()} ({user.username})"
@ -68,13 +123,13 @@ class Command(BaseCommand):
))
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'},
{'name': 'Рабочий календарь Анны', 'slug': 'anna-work', 'owner_idx': 0}, # anna_ivanova
{'name': 'Личные дела Анны', 'slug': 'anna-personal', 'owner_idx': 0},
{'name': 'Проекты Петра', 'slug': 'petr-projects', 'owner_idx': 1}, # petr_petrov
{'name': 'Встречи Петра', 'slug': 'petr-meetings', 'owner_idx': 1},
{'name': 'Календарь Марии', 'slug': 'maria-calendar', 'owner_idx': 2}, # maria_sidorova
]
created_calendars = []
@ -85,6 +140,10 @@ class Command(BaseCommand):
)
if created:
self.stdout.write(self.style.SUCCESS(f"Создан календарь: {calendar.name}"))
# Создаем CalendarRelation с owner
owner = created_users[cal_data['owner_idx']]
calendar.create_relation(owner, distinction='owner', inheritable=True)
self.stdout.write(f" Владелец: {owner.get_full_name()}")
else:
self.stdout.write(self.style.WARNING(f"Календарь уже существует: {calendar.name}"))
created_calendars.append(calendar)

View File

@ -0,0 +1,510 @@
# backend/tests/test_schedule_permissions.py
"""
Тесты для системы прав доступа календарей django-scheduler.
"""
import pytest
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from schedule.models import Calendar, CalendarRelation, Event, Rule
User = get_user_model()
@pytest.fixture
def api_client():
"""API клиент для тестов."""
return APIClient()
@pytest.fixture
def user1(db):
"""Первый тестовый пользователь."""
return User.objects.create_user(
username='user1',
email='user1@example.com',
password='testpass123',
first_name='User',
last_name='One',
phone_number='+79991234567' # Правильное имя параметра
)
@pytest.fixture
def user2(db):
"""Второй тестовый пользователь."""
return User.objects.create_user(
username='user2',
email='user2@example.com',
password='testpass123',
first_name='User',
last_name='Two',
phone_number='+79991234568'
)
@pytest.fixture
def user3(db):
"""Третий тестовый пользователь."""
return User.objects.create_user(
username='user3',
email='user3@example.com',
password='testpass123',
first_name='User',
last_name='Three',
phone_number='+79991234569'
)
@pytest.mark.django_db
class TestCalendarPermissions:
"""Тесты прав доступа к календарям."""
def test_create_calendar_creates_owner_relation(self, api_client, user1):
"""При создании календаря автоматически создается owner relation."""
api_client.force_authenticate(user=user1)
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
assert response.status_code == status.HTTP_201_CREATED
calendar_id = response.data['id']
# Проверяем, что создался CalendarRelation с owner
ct = ContentType.objects.get_for_model(User)
relation = CalendarRelation.objects.get(
calendar_id=calendar_id,
content_type=ct,
object_id=user1.id
)
assert relation.distinction == 'owner'
assert relation.inheritable is True
def test_get_calendars_shows_only_accessible(self, api_client, user1, user2):
"""Пользователь видит только свои календари."""
api_client.force_authenticate(user=user1)
# user1 создает календарь
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'User1 Calendar',
})
assert response.status_code == status.HTTP_201_CREATED
# user2 создает свой календарь
api_client.force_authenticate(user=user2)
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'User2 Calendar',
})
assert response.status_code == status.HTTP_201_CREATED
# user1 видит только свой календарь
api_client.force_authenticate(user=user1)
response = api_client.get('/api/v1/schedule/calendars/')
assert response.status_code == status.HTTP_200_OK
if isinstance(response.data, dict) and 'results' in response.data:
calendars = response.data['results']
else:
calendars = response.data
assert len(calendars) == 1
assert calendars[0]['name'] == 'User1 Calendar'
def test_add_participant_to_calendar(self, api_client, user1, user2):
"""Owner может добавить участника к календарю."""
api_client.force_authenticate(user=user1)
# Создаем календарь
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Shared Calendar',
})
calendar_id = response.data['id']
# Добавляем user2 как viewer
response = api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user2.id, 'role': 'viewer'}
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['user_info']['id'] == user2.id
assert response.data['distinction'] == 'viewer'
# Теперь user2 должен видеть календарь
api_client.force_authenticate(user=user2)
response = api_client.get('/api/v1/schedule/calendars/')
if isinstance(response.data, dict) and 'results' in response.data:
calendars = response.data['results']
else:
calendars = response.data
assert len(calendars) == 1
assert calendars[0]['id'] == calendar_id
def test_non_owner_cannot_add_participant(self, api_client, user1, user2, user3):
"""Не-owner не может добавлять участников."""
api_client.force_authenticate(user=user1)
# user1 создает календарь
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = response.data['id']
# Добавляем user2 как editor
api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user2.id, 'role': 'editor'}
)
# user2 пытается добавить user3 - должно быть запрещено
api_client.force_authenticate(user=user2)
response = api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user3.id, 'role': 'viewer'}
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_remove_participant_from_calendar(self, api_client, user1, user2):
"""Owner может удалить участника из календаря."""
api_client.force_authenticate(user=user1)
# Создаем календарь и добавляем user2
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = response.data['id']
api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user2.id, 'role': 'viewer'}
)
# Удаляем user2
response = api_client.delete(
f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user2.id}/'
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# user2 больше не видит календарь
api_client.force_authenticate(user=user2)
response = api_client.get('/api/v1/schedule/calendars/')
if isinstance(response.data, dict) and 'results' in response.data:
calendars = response.data['results']
else:
calendars = response.data
assert len(calendars) == 0
def test_cannot_remove_owner(self, api_client, user1):
"""Нельзя удалить owner из календаря."""
api_client.force_authenticate(user=user1)
# Создаем календарь
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = response.data['id']
# Пытаемся удалить себя (owner)
response = api_client.delete(
f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user1.id}/'
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_list_participants(self, api_client, user1, user2, user3):
"""Получение списка участников календаря."""
api_client.force_authenticate(user=user1)
# Создаем календарь
response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = response.data['id']
# Добавляем участников
api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user2.id, 'role': 'editor'}
)
api_client.post(
f'/api/v1/schedule/calendars/{calendar_id}/add-participant/',
{'user_id': user3.id, 'role': 'viewer'}
)
# Получаем список участников
response = api_client.get(
f'/api/v1/schedule/calendars/{calendar_id}/participants/'
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 3 # owner + 2 участника
# Проверяем роли
roles = {p['user_info']['id']: p['distinction'] for p in response.data}
assert roles[user1.id] == 'owner'
assert roles[user2.id] == 'editor'
assert roles[user3.id] == 'viewer'
@pytest.mark.django_db
class TestRecurringEvents:
"""Тесты повторяющихся событий."""
def test_create_rule(self, api_client, user1):
"""Создание правила повторения."""
api_client.force_authenticate(user=user1)
response = api_client.post('/api/v1/schedule/rules/', {
'name': 'Weekly',
'description': 'Every week on Monday',
'frequency': 'WEEKLY',
'params': 'byweekday:0' # Monday
})
assert response.status_code == status.HTTP_201_CREATED
assert response.data['frequency'] == 'WEEKLY'
assert response.data['frequency_display'] == 'Еженедельно' # Django локализация
def test_create_recurring_event(self, api_client, user1):
"""Создание события с повторением."""
api_client.force_authenticate(user=user1)
# Создаем календарь
cal_response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = cal_response.data['id']
# Создаем правило
rule_response = api_client.post('/api/v1/schedule/rules/', {
'name': 'Daily',
'description': 'Every day',
'frequency': 'DAILY',
'params': ''
})
rule_id = rule_response.data['id']
# Создаем событие с правилом
from datetime import datetime, timedelta
from django.utils import timezone
start = timezone.now()
end = start + timedelta(hours=1)
end_recurring = start + timedelta(days=30)
response = api_client.post('/api/v1/schedule/events/', {
'title': 'Daily Meeting',
'description': 'Team standup',
'start': start.isoformat(),
'end': end.isoformat(),
'calendar': calendar_id,
'rule': rule_id,
'end_recurring_period': end_recurring.isoformat(),
})
assert response.status_code == status.HTTP_201_CREATED
assert response.data['rule'] == rule_id
assert response.data['end_recurring_period'] is not None
def test_get_occurrences(self, api_client, user1):
"""Получение материализованных вхождений для recurring события."""
api_client.force_authenticate(user=user1)
# Создаем календарь
cal_response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = cal_response.data['id']
# Создаем правило - еженедельно
rule_response = api_client.post('/api/v1/schedule/rules/', {
'name': 'Weekly',
'description': 'Every week',
'frequency': 'WEEKLY',
'params': ''
})
rule_id = rule_response.data['id']
# Создаем событие
from datetime import datetime, timedelta
from django.utils import timezone
start = timezone.now()
end = start + timedelta(hours=1)
end_recurring = start + timedelta(weeks=4)
api_client.post('/api/v1/schedule/events/', {
'title': 'Weekly Meeting',
'start': start.isoformat(),
'end': end.isoformat(),
'calendar': calendar_id,
'rule': rule_id,
'end_recurring_period': end_recurring.isoformat(),
})
# Получаем occurrences
response = api_client.get(
'/api/v1/schedule/events/occurrences/',
{
'calendar': calendar_id,
'start': start.isoformat(),
'end': (start + timedelta(weeks=5)).isoformat(),
}
)
assert response.status_code == status.HTTP_200_OK
# Должно быть ~4 вхождения (еженедельно в течение 4 недель)
assert len(response.data) >= 4
@pytest.mark.django_db
class TestiCalendarExport:
"""Тесты экспорта в iCalendar формат."""
def test_export_calendar_to_ics(self, api_client, user1):
"""Экспорт календаря в .ics файл."""
api_client.force_authenticate(user=user1)
# Создаем календарь
cal_response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = cal_response.data['id']
# Добавляем событие
from datetime import datetime, timedelta
from django.utils import timezone
start = timezone.now()
end = start + timedelta(hours=2)
api_client.post('/api/v1/schedule/events/', {
'title': 'Test Event',
'description': 'Test Description',
'start': start.isoformat(),
'end': end.isoformat(),
'calendar': calendar_id,
})
# Экспортируем календарь
response = api_client.get(
f'/api/v1/schedule/calendars/{calendar_id}/export-ical/'
)
assert response.status_code == status.HTTP_200_OK
assert response['Content-Type'] == 'text/calendar'
assert 'Content-Disposition' in response
assert '.ics' in response['Content-Disposition']
# Проверяем содержимое
content = response.content.decode('utf-8')
assert 'BEGIN:VCALENDAR' in content
assert 'BEGIN:VEVENT' in content
assert 'Test Event' in content
assert 'END:VEVENT' in content
assert 'END:VCALENDAR' in content
@pytest.mark.django_db
class TestEventRelations:
"""Тесты участников событий."""
def test_add_participant_to_event(self, api_client, user1, user2):
"""Добавление участника к событию."""
api_client.force_authenticate(user=user1)
# Создаем календарь
cal_response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = cal_response.data['id']
# Создаем событие
from datetime import datetime, timedelta
from django.utils import timezone
start = timezone.now()
end = start + timedelta(hours=1)
event_response = api_client.post('/api/v1/schedule/events/', {
'title': 'Meeting',
'start': start.isoformat(),
'end': end.isoformat(),
'calendar': calendar_id,
})
event_id = event_response.data['id']
# Добавляем user2 как участника
ct = ContentType.objects.get_for_model(User)
response = api_client.post('/api/v1/schedule/relations/', {
'event': event_id,
'content_type': ct.id,
'object_id': user2.id,
'distinction': 'attendee',
})
assert response.status_code == status.HTTP_201_CREATED
assert response.data['event'] == event_id
def test_list_event_participants(self, api_client, user1, user2, user3):
"""Получение списка участников события."""
api_client.force_authenticate(user=user1)
# Создаем календарь и событие
cal_response = api_client.post('/api/v1/schedule/calendars/', {
'name': 'Test Calendar',
})
calendar_id = cal_response.data['id']
from datetime import datetime, timedelta
from django.utils import timezone
start = timezone.now()
end = start + timedelta(hours=1)
event_response = api_client.post('/api/v1/schedule/events/', {
'title': 'Team Meeting',
'start': start.isoformat(),
'end': end.isoformat(),
'calendar': calendar_id,
})
event_id = event_response.data['id']
# Добавляем участников
ct = ContentType.objects.get_for_model(User)
api_client.post('/api/v1/schedule/relations/', {
'event': event_id,
'content_type': ct.id,
'object_id': user2.id,
'distinction': 'attendee',
})
api_client.post('/api/v1/schedule/relations/', {
'event': event_id,
'content_type': ct.id,
'object_id': user3.id,
'distinction': 'attendee',
})
# Получаем список участников
response = api_client.get(
'/api/v1/schedule/relations/',
{'event': event_id}
)
assert response.status_code == status.HTTP_200_OK
if isinstance(response.data, dict) and 'results' in response.data:
relations = response.data['results']
else:
relations = response.data
assert len(relations) == 2

View File

@ -3,11 +3,13 @@
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 { ReactNode, useEffect, useMemo, useState, useRef, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { CalendarProvider, useCalendar } from "@/contexts/CalendarContext";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
import { EventModal } from "@/components/EventModal";
type AppShellProps = {
children: ReactNode;
@ -49,6 +51,8 @@ type SidebarCalendarEvent = {
end?: string | null;
allDay?: boolean;
color?: string | null;
rule?: number | null; // ID правила повторения
is_recurring?: boolean; // Флаг из occurrences API
};
const pad = (v: number) => String(v).padStart(2, "0");
@ -290,23 +294,31 @@ function LeftNav() {
);
}
function CalendarCard({
function CalendarCard({
onOpenCalendarModal,
onOpenEventModal
}: {
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => 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 [loading, setLoading] = useState(false);
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Загрузка событий выбранного календаря
useEffect(() => {
@ -331,15 +343,32 @@ function CalendarCard({
setLoading(true);
setError(null);
const result = await apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId ?? undefined,
});
// Загружаем обычные события и occurrences параллельно
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId ?? undefined,
}),
apiClient.getOccurrences({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId ?? undefined,
}),
]);
if (!cancelled) {
const eventsList = Array.isArray(result) ? result : (result?.results || []);
setEvents(eventsList);
// Обычные события (без правил повторения)
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList.filter((evt: any) => !evt.rule);
// Occurrences (развернутые повторяющиеся события)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
// Фильтруем только повторяющиеся события (избегаем дубликатов)
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
// Объединяем оба типа событий
setEvents([...regularEvents, ...recurringOccurrences]);
}
} catch (err) {
if (!cancelled) {
@ -363,7 +392,7 @@ function CalendarCard({
return () => {
cancelled = true;
};
}, [monthDate, selectedCalendarId]);
}, [monthDate, selectedCalendarId, eventsRefreshTrigger]);
const monthLabel = useMemo(() => {
const label = monthDate.toLocaleDateString("ru-RU", {
@ -389,7 +418,7 @@ function CalendarCard({
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(date);
endDate.setHours(11, 0, 0, 0);
const newEvent = {
title: "",
description: "",
@ -401,12 +430,51 @@ function CalendarCard({
onOpenEventModal(newEvent, date);
};
const handleEventClick = async (eventId: number) => {
try {
const event = await apiClient.getEvent(eventId);
const handleEventClick = async (event: any) => {
// Если это occurrence (повторяющееся событие), загружаем базовое событие
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
// Устанавливаем время из occurrence, но остальные данные из базового события
onOpenEventModal({
...fullEvent,
start: event.start,
end: event.end,
});
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
// Обычное событие - данные уже полные
onOpenEventModal(event);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
};
const handleImportCalendar = async (event: React.ChangeEvent<HTMLInputElement>) => {
console.log('handleImportCalendar called', event.target.files);
if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) {
console.log('Early return:', { selectedCalendarId, hasFiles: !!event.target.files });
return;
}
const file = event.target.files[0];
console.log('Importing file:', file.name, file.size);
try {
const result = await apiClient.importCalendarFromICS(selectedCalendarId, file);
console.log('Import result:', result);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
// Триггерим обновление событий
setEventsRefreshTrigger(prev => prev + 1);
} catch (error: any) {
console.error('Failed to import calendar:', error);
alert(`Ошибка импорта: ${error.message}`);
} finally {
// Сбрасываем input для возможности повторного импорта того же файла
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
@ -498,79 +566,172 @@ function CalendarCard({
))}
</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 className="relative">
<button
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
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>
{showCalendarMenu && (
<>
<div
className="fixed inset-0 z-[50]"
onClick={() => setShowCalendarMenu(false)}
/>
<div className="absolute right-0 top-full mt-1 w-48 rounded-lg bg-white shadow-lg ring-1 ring-black/5 z-[60]">
<div className="py-1">
<button
onClick={() => {
const cal = calendars.find(c => c.id === selectedCalendarId);
if (cal) {
onOpenParticipantsModal({ id: cal.id, name: cal.name, user_role: (cal as any).user_role });
}
setShowCalendarMenu(false);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
<Users size={16} />
Участники
</button>
<button
onClick={async () => {
try {
const blob = await apiClient.exportCalendarToICS(selectedCalendarId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `calendar-${selectedCalendarId}.ics`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to export calendar:', error);
alert('Не удалось экспортировать календарь');
}
setShowCalendarMenu(false);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
<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">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Экспорт .ics
</button>
<button
onClick={() => {
console.log('Import button clicked', fileInputRef.current);
fileInputRef.current?.click();
console.log('File input clicked');
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
<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">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Импорт .ics
</button>
<input
ref={fileInputRef}
type="file"
accept=".ics"
onChange={(e) => {
console.log('File input onChange triggered', e.target.files);
setShowCalendarMenu(false);
handleImportCalendar(e);
}}
className="hidden"
/>
<div className="my-1 border-t border-gray-100"></div>
<button
onClick={() => {
const cal = calendars.find(c => c.id === selectedCalendarId);
if (cal) {
onOpenCalendarModal(cal);
}
setShowCalendarMenu(false);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
<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">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Настройки
</button>
</div>
</div>
</>
)}
</div>
)}
</div>
)}
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Месяц</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Предыдущий месяц"
>
<ChevronLeft size={16} className="text-gray-600" />
</button>
<span className="min-w-28 text-center text-xs font-normal text-gray-500">{monthLabel}</span>
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Следующий месяц"
>
<ChevronRight size={16} className="text-gray-600" />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Месяц</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Предыдущий месяц"
>
<ChevronLeft size={16} className="text-gray-600" />
</button>
<span className="min-w-28 text-center text-xs font-normal text-gray-500">{monthLabel}</span>
<button
type="button"
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Следующий месяц"
>
<ChevronRight size={16} className="text-gray-600" />
</button>
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{days.map((day) => (
<button
key={day.key}
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 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}
</button>
))}
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{days.map((day) => (
<button
key={day.key}
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 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}
</button>
))}
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
@ -587,22 +748,27 @@ function CalendarCard({
const start = event.start ? new Date(event.start) : null;
const dateLabel = start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
return (
<button
key={`${event.id}-${event.start || ""}`}
type="button"
onClick={() => handleEventClick(event.id)}
onClick={() => handleEventClick(event)}
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>
<div className="flex items-center gap-1">
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
{(event.is_recurring || event.rule) && (
<span className="text-[10px] text-sky-600 flex-shrink-0" title="Повторяющееся событие">⟲</span>
)}
</div>
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
</button>
);
@ -614,19 +780,28 @@ function CalendarCard({
);
}
function Calendar({
function Calendar({
onOpenCalendarModal,
onOpenEventModal
}: {
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
}) {
return (
<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
<CalendarCard
onOpenCalendarModal={onOpenCalendarModal}
onOpenEventModal={onOpenEventModal}
onOpenParticipantsModal={onOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
/>
</div>
</aside>
@ -662,8 +837,11 @@ export function AppShell({ children }: AppShellProps) {
// Состояния модалов календаря
const [showCalendarModal, setShowCalendarModal] = useState(false);
const [showEventModal, setShowEventModal] = useState(false);
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
const [editingEvent, setEditingEvent] = useState<any>(null);
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
// Обработчики модалов
const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => {
@ -676,6 +854,16 @@ export function AppShell({ children }: AppShellProps) {
setShowEventModal(true);
};
const handleEventSaved = useCallback(() => {
// Обновляем список событий в CalendarCard
setEventsRefreshTrigger(prev => prev + 1);
}, []);
const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => {
setParticipantsCalendar(calendar);
setShowParticipantsModal(true);
};
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
if (!loading && !user) {
@ -738,219 +926,111 @@ export function AppShell({ children }: AppShellProps) {
<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
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
/>
</div>
<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"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
<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"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard
<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
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
/>
</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={`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"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
</h3>
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
onClick={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
className="rounded-full p-1 hover:bg-gray-100"
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-600" />
<X size={20} className="text-gray-700" />
</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>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
)}
<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"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
/>
</div>
</div>
{/* Модалы календаря - рендерятся на верхнем уровне */}
<CalendarModal
isOpen={showCalendarModal}
onClose={() => {
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
/>
{/* Модальное окно участников календаря */}
{participantsCalendar && (
<CalendarParticipantsModal
isOpen={showParticipantsModal}
onClose={() => {
setShowParticipantsModal(false);
setParticipantsCalendar(null);
}}
calendarId={participantsCalendar.id}
calendarName={participantsCalendar.name}
userRole={participantsCalendar.user_role}
/>
)}
{/* Модальное окно события */}
<EventModal
isOpen={showEventModal}
onClose={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
event={editingEvent}
onSave={handleEventSaved}
showParticipants={true}
/>
</div>
</CalendarProvider>
);

View File

@ -8,6 +8,7 @@ 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 { EventModal } from "@/components/EventModal";
import "react-big-calendar/lib/css/react-big-calendar.css";
import "../app/calendar/calendar.css";
@ -35,6 +36,9 @@ type CalendarEvent = {
color_event?: string;
rule?: number;
rule_description?: string;
isOccurrence?: boolean; // Флаг для повторяющихся событий
event_id?: number; // ID базового события для occurrence
allDay?: boolean; // Флаг целодневного события
};
// Кастомные сообщения на русском
@ -147,21 +151,58 @@ export function BigCalendar() {
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 startStr = start.toISOString().split("T")[0];
const endStr = 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),
}));
// Загружаем обычные события и occurrences параллельно
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
calendar: selectedCalendarId,
start: startStr,
end: endStr,
}),
apiClient.getOccurrences({
calendar: selectedCalendarId,
start: startStr,
end: endStr,
}),
]);
setEvents(parsedEvents);
// Обработка обычных событий (без правил повторения)
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList
.filter((evt: any) => !evt.rule) // Только события без правил
.map((evt: any) => {
const startDate = new Date(evt.start);
const endDate = new Date(evt.end);
return {
...evt,
start: startDate,
end: endDate,
allDay: false, // Явно указываем что событие с временем
};
});
// Обработка occurrences (повторяющихся событий)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
const occurrenceEvents = occurrencesList
.filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (избегаем дубликатов)
.map((occ: any) => ({
id: occ.id,
title: `⟲ ${occ.title}`, // Добавляем индикатор повторения
description: occ.description,
start: new Date(occ.start),
end: new Date(occ.end),
allDay: false, // Явно указываем что событие с временем
calendar: selectedCalendarId, // Календарь уже известен из запроса
color_event: occ.color_event || '#3498db',
event_id: occ.event_id,
isOccurrence: true, // Помечаем как occurrence для корректного отображения
}));
// Объединяем обычные события и occurrences
setEvents([...regularEvents, ...occurrenceEvents]);
} catch (err) {
console.error("Ошибка загрузки событий:", err);
} finally {
@ -181,11 +222,21 @@ export function BigCalendar() {
return;
}
// Если клик на весь день (00:00 - 00:00), устанавливаем разумное время
const startDate = new Date(start);
const endDate = new Date(end);
// Проверяем, если время 00:00 (клик на день в режиме месяца)
if (startDate.getHours() === 0 && startDate.getMinutes() === 0) {
startDate.setHours(10, 0, 0, 0); // 10:00
endDate.setHours(11, 0, 0, 0); // 11:00
}
setEditingEvent({
title: "",
description: "",
start,
end,
start: startDate,
end: endDate,
calendar: selectedCalendarId,
color_event: "#3498db",
});
@ -194,52 +245,27 @@ export function BigCalendar() {
[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);
const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => {
// Если это occurrence (повторяющееся событие), загружаем базовое событие
if (calEvent.isOccurrence && calEvent.event_id) {
try {
const fullEvent = await apiClient.getEvent(calEvent.event_id);
// Устанавливаем время из occurrence, но остальные данные из базового события
setEditingEvent({
...fullEvent,
start: calEvent.start,
end: calEvent.end,
});
setShowEventModal(true);
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
await loadEvents();
setShowEventModal(false);
setEditingEvent(null);
} catch (err) {
console.error("Ошибка сохранения события:", err);
alert("Не удалось сохранить событие");
} else {
// Обычное событие - данные уже полные
setEditingEvent(calEvent);
setShowEventModal(true);
}
};
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) => {
@ -262,213 +288,112 @@ export function BigCalendar() {
{/* Панель выбора календаря */}
<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 && (
<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={() => {
const cal = calendars.find((c) => c.id === selectedCalendarId);
if (cal) {
setEditingCalendar({ id: cal.id, name: cal.name });
setShowCalendarModal(true);
}
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="Настройки календаря"
title="Создать календарь"
>
<Settings size={16} />
<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>
{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 className="overflow-hidden px-6 pb-6" style={{ height: "750px" }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
allDayAccessor={(event: CalendarEvent) => event.allDay || false}
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>
{/* Календарь */}
<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>
)}
<EventModal
isOpen={showEventModal}
onClose={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
event={editingEvent}
onSave={() => {
loadEvents(); // ✅ Обновляем список событий
}}
showParticipants={true}
/>
{/* Модальное окно управления календарем */}
<CalendarModal
isOpen={showCalendarModal}

View File

@ -0,0 +1,402 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, UserPlus, Trash2 } from 'lucide-react';
import api from '@/lib/api';
interface Participant {
id: number;
user: {
id: number;
username: string;
first_name: string;
last_name: string;
};
distinction: string;
}
interface Employee {
id: number;
username?: string;
first_name: string;
last_name: string;
email?: string;
departments?: Array<{
id: number;
name: string;
}>;
}
interface Department {
id: number;
name: string;
}
interface CalendarParticipantsModalProps {
isOpen: boolean;
onClose: () => void;
calendarId: number;
calendarName: string;
userRole?: string; // owner, editor, viewer
}
export default function CalendarParticipantsModal({
isOpen,
onClose,
calendarId,
calendarName,
userRole,
}: CalendarParticipantsModalProps) {
const [participants, setParticipants] = useState<Participant[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [selectedRole, setSelectedRole] = useState<string>('viewer');
const [selectedDepartment, setSelectedDepartment] = useState<string>('');
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isOwner = userRole === 'owner';
useEffect(() => {
if (isOpen) {
loadParticipants();
loadEmployees();
loadDepartments();
}
}, [isOpen, calendarId]);
const loadParticipants = async () => {
try {
const data = await api.getCalendarParticipants(calendarId);
setParticipants(data);
} catch (error) {
console.error('Failed to load participants:', error);
}
};
const loadEmployees = async () => {
try {
const data = await api.getEmployees({ limit: 200 });
setEmployees(data.results || []);
} catch (error) {
console.error('Failed to load employees:', error);
}
};
const loadDepartments = async () => {
try {
const data = await api.getDepartments({ limit: 100 });
setDepartments(data.results || []);
} catch (error) {
console.error('Failed to load departments:', error);
}
};
const handleAddParticipants = async () => {
if (selectedUserIds.length === 0) {
alert('Выберите хотя бы одного сотрудника');
return;
}
setLoading(true);
try {
// Добавляем всех выбранных участников последовательно
for (const userId of selectedUserIds) {
await api.addCalendarParticipant(calendarId, userId, selectedRole);
}
await loadParticipants();
setSelectedUserIds([]);
setSearchQuery('');
setSelectedRole('viewer');
alert(`Успешно добавлено участников: ${selectedUserIds.length}`);
} catch (error) {
console.error('Failed to add participants:', error);
alert('Не удалось добавить участников');
} finally {
setLoading(false);
}
};
const toggleUserSelection = (userId: number) => {
setSelectedUserIds(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};
const selectAll = () => {
const availableUserIds = filteredAndDepartmentEmployees.map(emp => emp.id);
setSelectedUserIds(availableUserIds);
};
const selectAllFromDepartment = () => {
if (!selectedDepartment) return;
const departmentEmployees = filteredAndDepartmentEmployees.filter(emp =>
emp.departments?.some(d => d.id === parseInt(selectedDepartment))
);
const departmentUserIds = departmentEmployees.map(emp => emp.id);
setSelectedUserIds(prev => [...new Set([...prev, ...departmentUserIds])]);
};
const deselectAll = () => {
setSelectedUserIds([]);
};
const handleRemoveParticipant = async (userId: number) => {
if (!confirm('Удалить участника из календаря?')) return;
setLoading(true);
try {
await api.removeCalendarParticipant(calendarId, userId);
await loadParticipants();
} catch (error) {
console.error('Failed to remove participant:', error);
alert('Не удалось удалить участника');
} finally {
setLoading(false);
}
};
const filteredEmployees = employees.filter(emp => {
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase();
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
return matchesSearch;
});
const filteredAndDepartmentEmployees = filteredEmployees.filter(emp => {
// Если выбран отдел, фильтруем по нему
if (selectedDepartment) {
return emp.departments?.some(d => d.id === parseInt(selectedDepartment));
}
return true;
});
const availableEmployees = filteredAndDepartmentEmployees.filter(emp =>
!participants.find(p => p.user?.id === emp.id)
);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl rounded-2xl bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Участники календаря</h2>
<p className="text-sm text-gray-500">{calendarName}</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 hover:bg-gray-100 transition"
>
<X size={20} className="text-gray-500" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Add Participant Section - только для владельца */}
{isOwner && (
<div className="rounded-lg bg-gray-50 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">
Добавить участников
{selectedUserIds.length > 0 && (
<span className="ml-2 text-sky-600">
(выбрано: {selectedUserIds.length})
</span>
)}
</h3>
<div className="flex gap-2">
<button
onClick={selectAll}
disabled={availableEmployees.length === 0}
className="text-xs text-sky-600 hover:text-sky-700 disabled:opacity-50"
>
Выбрать всех
</button>
{selectedDepartment && (
<button
onClick={selectAllFromDepartment}
className="text-xs text-sky-600 hover:text-sky-700"
>
Добавить весь отдел
</button>
)}
<button
onClick={deselectAll}
disabled={selectedUserIds.length === 0}
className="text-xs text-gray-600 hover:text-gray-700 disabled:opacity-50"
>
Снять выбор
</button>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="Поиск сотрудника..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 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"
/>
<select
value={selectedDepartment}
onChange={(e) => {
setSelectedDepartment(e.target.value);
setSelectedUserIds([]); // Сбрасываем выбор при смене отдела
}}
className="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"
>
<option value="">Все отделы</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
{/* Employee list with checkboxes */}
{availableEmployees.length > 0 && (
<div className="max-h-60 overflow-y-auto rounded-lg border border-gray-200 bg-white">
{availableEmployees.map(emp => (
<label
key={emp.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 cursor-pointer transition"
>
<input
type="checkbox"
checked={selectedUserIds.includes(emp.id)}
onChange={() => toggleUserSelection(emp.id)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<span className="text-sm text-gray-900">
{emp.first_name} {emp.last_name}
</span>
{emp.email && (
<span className="text-xs text-gray-500">({emp.email})</span>
)}
</label>
))}
</div>
)}
{availableEmployees.length === 0 && (
<p className="text-sm text-gray-500 text-center py-4">
{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}
</p>
)}
<div className="flex gap-2">
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="flex-1 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"
>
<option value="viewer">Просмотр</option>
<option value="editor">Редактор</option>
<option value="owner">Владелец</option>
</select>
<button
onClick={handleAddParticipants}
disabled={selectedUserIds.length === 0 || loading}
className="flex items-center gap-2 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<UserPlus size={16} />
{loading ? 'Добавление...' : `Добавить (${selectedUserIds.length})`}
</button>
</div>
</div>
)}
{/* Participants List */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-3">
Текущие участники ({participants.length})
</h3>
<div className="space-y-2">
{participants.length === 0 ? (
<p className="text-sm text-gray-500 py-4 text-center">Нет участников</p>
) : (
participants.map((participant) => {
const user = participant.user || {};
const firstName = user.first_name || 'N';
const lastName = user.last_name || 'A';
const username = user.username || 'unknown';
return (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 hover:bg-gray-50 transition"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-sky-100 flex items-center justify-center">
<span className="text-sm font-medium text-sky-700">
{firstName[0]}{lastName[0]}
</span>
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{firstName} {lastName}
</div>
<div className="text-xs text-gray-500">@{username}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
participant.distinction === 'owner'
? 'bg-purple-100 text-purple-700'
: participant.distinction === 'editor'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}>
{participant.distinction === 'owner' ? 'Владелец' :
participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}
</span>
{isOwner && participant.distinction !== 'owner' && (
<button
onClick={() => handleRemoveParticipant(participant.user.id)}
disabled={loading}
className="rounded-lg p-2 hover:bg-red-50 text-red-600 transition disabled:opacity-50"
title="Удалить участника"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={onClose}
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200"
>
Закрыть
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,725 @@
"use client";
import { useState, useEffect } from "react";
import { X, Trash2 } from "lucide-react";
import { apiClient } from "@/lib/api";
interface EventModalProps {
isOpen: boolean;
onClose: () => void;
event: any | null;
onSave: () => void;
showParticipants?: boolean;
}
export function EventModal({
isOpen,
onClose,
event,
onSave,
showParticipants = false,
}: EventModalProps) {
const [editingEvent, setEditingEvent] = useState<any>(null);
const [eventParticipants, setEventParticipants] = useState<any[]>([]);
const [eventParticipantsLoading, setEventParticipantsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [ruleDetails, setRuleDetails] = useState<any>(null);
const [showAddParticipants, setShowAddParticipants] = useState(false);
const [availableEmployees, setAvailableEmployees] = useState<any[]>([]);
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loadingEmployees, setLoadingEmployees] = useState(false);
const [addingParticipants, setAddingParticipants] = useState(false);
const [pendingParticipantIds, setPendingParticipantIds] = useState<number[]>([]);
useEffect(() => {
if (isOpen && event) {
// Инициализируем все boolean поля дефолтными значениями
setEditingEvent({
...event,
isRecurring: event.isRecurring ?? false,
useCount: event.useCount ?? false,
});
// Загружаем участников для существующего события
if (event.id && showParticipants) {
loadEventParticipants(event.id);
} else {
setEventParticipants([]);
}
// Загружаем правило для повторяющегося события
if (event.id && event.rule) {
loadRule(event.rule);
} else {
setRuleDetails(null);
}
}
}, [isOpen, event, showParticipants]);
// Конвертирует Date в формат datetime-local (локальное время)
const toLocalDateTimeString = (date: Date | string): string => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const loadEventParticipants = async (eventId: number) => {
setEventParticipantsLoading(true);
try {
const data = await apiClient.getEventParticipants(eventId);
setEventParticipants(data.results || data || []);
} catch (error) {
console.error('Failed to load event participants:', error);
} finally {
setEventParticipantsLoading(false);
}
};
const loadRule = async (ruleId: number) => {
try {
const rule = await apiClient.getRule(ruleId);
setRuleDetails(rule);
} catch (error) {
console.error('Failed to load rule:', error);
}
};
const loadAvailableEmployees = async () => {
setLoadingEmployees(true);
try {
const data = await apiClient.getEmployees({ limit: 200 });
setAvailableEmployees(data.results || []);
} catch (error) {
console.error('Failed to load employees:', error);
} finally {
setLoadingEmployees(false);
}
};
const handleRemoveParticipant = async (relationId: number) => {
if (!confirm('Удалить участника из события?')) return;
try {
await apiClient.removeEventParticipant(relationId);
await loadEventParticipants(editingEvent.id);
} catch (error) {
console.error('Failed to remove participant:', error);
alert('Не удалось удалить участника');
}
};
const handleAddParticipants = async () => {
if (selectedEmployeeIds.length === 0) return;
// Если событие еще не создано - добавляем в pending список
if (!editingEvent.id) {
setPendingParticipantIds([...pendingParticipantIds, ...selectedEmployeeIds]);
setSelectedEmployeeIds([]);
setShowAddParticipants(false);
setSearchQuery('');
return;
}
// Если событие уже существует - добавляем сразу
setAddingParticipants(true);
try {
await Promise.all(
selectedEmployeeIds.map(userId =>
apiClient.addEventParticipant(editingEvent.id, userId, 'attendee')
)
);
await loadEventParticipants(editingEvent.id);
setSelectedEmployeeIds([]);
setShowAddParticipants(false);
setSearchQuery('');
} catch (error) {
console.error('Failed to add participants:', error);
alert('Не удалось добавить участников');
} finally {
setAddingParticipants(false);
}
};
const handleSave = async () => {
if (!editingEvent || !editingEvent.title?.trim()) return;
try {
setSaving(true);
let ruleId = editingEvent.rule || null;
// Если это повторяющееся событие и у него нет Rule - создаем правило
if (editingEvent.isRecurring && !ruleId) {
const params: any = {
interval: editingEvent.interval || 1,
};
// Добавляем byweekday для еженедельных событий
if (editingEvent.frequency === 'WEEKLY' && editingEvent.byweekday && editingEvent.byweekday.length > 0) {
params.byweekday = editingEvent.byweekday;
}
// Добавляем count если выбран этот вариант
if (editingEvent.useCount && editingEvent.count) {
params.count = editingEvent.count;
}
const rule = await apiClient.createRule({
name: `Rule for ${editingEvent.title}`,
description: `Recurring rule for ${editingEvent.title}`,
frequency: editingEvent.frequency || 'WEEKLY',
params,
});
ruleId = rule.id;
}
const eventData: any = {
title: editingEvent.title,
description: editingEvent.description,
start: typeof editingEvent.start === 'string'
? editingEvent.start
: editingEvent.start?.toISOString?.() || new Date(editingEvent.start).toISOString(),
end: typeof editingEvent.end === 'string'
? editingEvent.end
: editingEvent.end?.toISOString?.() || new Date(editingEvent.end).toISOString(),
calendar: editingEvent.calendar,
color_event: editingEvent.color_event || '#3498db',
rule: ruleId,
};
// Добавляем end_recurring_period только для повторяющихся событий (если не используется count)
if (editingEvent.isRecurring && !editingEvent.useCount && editingEvent.end_recurring_period) {
const endDate = new Date(editingEvent.end_recurring_period);
endDate.setHours(23, 59, 59, 999);
eventData.end_recurring_period = endDate.toISOString();
}
if (editingEvent.id) {
await apiClient.updateEvent(editingEvent.id, eventData);
} else {
const savedEvent = await apiClient.createEvent(eventData);
// Если есть ожидающие участники - добавляем их
if (pendingParticipantIds.length > 0 && savedEvent.id) {
try {
await Promise.all(
pendingParticipantIds.map(userId =>
apiClient.addEventParticipant(savedEvent.id, userId, 'attendee')
)
);
} catch (error) {
console.error('Failed to add participants to new event:', error);
}
}
}
setPendingParticipantIds([]);
onSave();
onClose();
} catch (err: any) {
console.error('❌ Ошибка сохранения события:', err);
alert('Не удалось сохранить событие:\n' + (err.message || 'Неизвестная ошибка'));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editingEvent?.id || !confirm(`Удалить событие "${editingEvent.title}"?`)) return;
try {
setSaving(true);
await apiClient.deleteEvent(editingEvent.id);
onSave();
onClose();
} catch (err) {
console.error('Ошибка удаления события:', err);
alert('Не удалось удалить событие');
} finally {
setSaving(false);
}
};
if (!isOpen || !editingEvent) 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-md rounded-2xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{editingEvent.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">
<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
? toLocalDateTimeString(editingEvent.start)
: ''
}
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
? toLocalDateTimeString(editingEvent.end)
: ''
}
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>
{/* Повторяющееся событие */}
{editingEvent.id && ruleDetails ? (
// Для существующих повторяющихся событий - readonly информация
<div className="rounded-lg bg-blue-50 border border-blue-200 p-4 space-y-2">
<div className="flex items-center gap-2 text-blue-700 font-medium text-sm">
<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">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect>
<line x1="16" x2="16" y1="2" y2="6"></line>
<line x1="8" x2="8" y1="2" y2="6"></line>
<line x1="3" x2="21" y1="10" y2="10"></line>
</svg>
<span>Повторяющееся событие</span>
</div>
<div className="text-sm text-blue-600 space-y-1">
<div>Частота: <span className="font-medium">
{ruleDetails.frequency === 'DAILY' && 'Каждый день'}
{ruleDetails.frequency === 'WEEKLY' && 'Каждую неделю'}
{ruleDetails.frequency === 'MONTHLY' && 'Каждый месяц'}
{ruleDetails.frequency === 'YEARLY' && 'Каждый год'}
</span></div>
{ruleDetails.params?.interval && ruleDetails.params.interval > 1 && (
<div>Интервал: <span className="font-medium">{ruleDetails.params.interval}</span></div>
)}
{ruleDetails.params?.byweekday && ruleDetails.params.byweekday.length > 0 && (
<div>Дни недели: <span className="font-medium">
{ruleDetails.params.byweekday.map((d: number) => ['ПН','ВТ','СР','ЧТ','ПТ','СБ','ВС'][d]).join(', ')}
</span></div>
)}
{ruleDetails.params?.count && (
<div>Повторений: <span className="font-medium">{ruleDetails.params.count}</span></div>
)}
</div>
<div className="text-xs text-blue-600 mt-2">
💡 Редактирование параметров повторения пока не поддерживается
</div>
</div>
) : (!editingEvent.id || !editingEvent.rule) && (
<>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editingEvent.isRecurring || false}
onChange={(e) => setEditingEvent({
...editingEvent,
isRecurring: e.target.checked,
frequency: e.target.checked ? 'WEEKLY' : undefined,
interval: e.target.checked ? 1 : undefined,
end_recurring_period: e.target.checked && !editingEvent.end_recurring_period
? new Date(new Date().setMonth(new Date().getMonth() + 3)).toISOString().slice(0, 10)
: editingEvent.end_recurring_period
})}
className="h-4 w-4 rounded border-gray-300 text-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<span className="text-sm font-medium text-gray-700">Повторяющееся событие</span>
</label>
</div>
{editingEvent.isRecurring && (
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Частота повторения
</label>
<select
value={editingEvent.frequency || 'WEEKLY'}
onChange={(e) => setEditingEvent({ ...editingEvent, frequency: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="DAILY">Ежедневно</option>
<option value="WEEKLY">Еженедельно</option>
<option value="MONTHLY">Ежемесячно</option>
<option value="YEARLY">Ежегодно</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Интервал
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Каждые</span>
<input
type="number"
min="1"
max="365"
value={editingEvent.interval || 1}
onChange={(e) => setEditingEvent({ ...editingEvent, interval: parseInt(e.target.value) })}
className="w-20 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"
/>
<span className="text-sm text-gray-600">
{editingEvent.frequency === 'DAILY' && 'дней'}
{editingEvent.frequency === 'WEEKLY' && (editingEvent.interval === 1 ? 'неделю' : 'недели')}
{editingEvent.frequency === 'MONTHLY' && (editingEvent.interval === 1 ? 'месяц' : 'месяца')}
{editingEvent.frequency === 'YEARLY' && (editingEvent.interval === 1 ? 'год' : 'года')}
</span>
</div>
</div>
{editingEvent.frequency === 'WEEKLY' && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дни недели
</label>
<div className="grid grid-cols-7 gap-1">
{[
{ value: 0, label: 'ПН' },
{ value: 1, label: 'ВТ' },
{ value: 2, label: 'СР' },
{ value: 3, label: 'ЧТ' },
{ value: 4, label: 'ПТ' },
{ value: 5, label: 'СБ' },
{ value: 6, label: 'ВС' },
].map((day) => {
const byweekday = editingEvent.byweekday || [];
const isSelected = byweekday.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => {
const current = editingEvent.byweekday || [];
const updated = isSelected
? current.filter((d: number) => d !== day.value)
: [...current, day.value].sort();
setEditingEvent({ ...editingEvent, byweekday: updated });
}}
className={`rounded-lg px-2 py-1.5 text-xs font-medium transition ${
isSelected
? 'bg-sky-500 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{day.label}
</button>
);
})}
</div>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Окончание повторения
</label>
<div className="flex items-center gap-2 mb-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={editingEvent.useCount === false}
onChange={() => setEditingEvent({ ...editingEvent, useCount: false, count: undefined })}
className="h-4 w-4 text-sky-500 focus:ring-sky-500"
/>
<span className="text-sm text-gray-700">До даты</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={editingEvent.useCount === true}
onChange={() => setEditingEvent({ ...editingEvent, useCount: true, end_recurring_period: undefined })}
className="h-4 w-4 text-sky-500 focus:ring-sky-500"
/>
<span className="text-sm text-gray-700">Количество раз</span>
</label>
</div>
{editingEvent.useCount ? (
<input
type="number"
min="1"
max="999"
value={editingEvent.count || 10}
onChange={(e) => setEditingEvent({ ...editingEvent, count: parseInt(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="Количество повторений"
/>
) : (
<input
type="date"
value={editingEvent.end_recurring_period || ''}
onChange={(e) => setEditingEvent({ ...editingEvent, end_recurring_period: 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"
min={new Date().toISOString().slice(0, 10)}
/>
)}
</div>
</div>
)}
</>
)}
{/* Event Participants */}
{showParticipants && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">
Участники события
{!eventParticipantsLoading && eventParticipants.length > 0 && (
<span className="ml-1 text-gray-500">({eventParticipants.length})</span>
)}
</label>
<button
type="button"
onClick={() => {
setShowAddParticipants(!showAddParticipants);
if (!showAddParticipants && availableEmployees.length === 0) {
loadAvailableEmployees();
}
}}
className="text-xs text-sky-600 hover:text-sky-700 font-medium"
>
{showAddParticipants ? 'Отмена' : '+ Добавить участников'}
</button>
</div>
{/* Pending участники для нового события */}
{!editingEvent.id && pendingParticipantIds.length > 0 && (
<div className="space-y-2 mb-3">
<p className="text-xs text-blue-600 font-medium">Будут добавлены после создания:</p>
{pendingParticipantIds.map(userId => {
const emp = availableEmployees.find(e => e.id === userId);
return emp ? (
<div
key={userId}
className="flex items-center justify-between rounded-lg bg-blue-50 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-xs font-medium text-blue-700">
{emp.first_name?.[0] || '?'}
</span>
</div>
<div className="text-xs">
<div className="font-medium text-gray-900">{emp.first_name} {emp.last_name}</div>
<div className="text-gray-500">attendee</div>
</div>
</div>
<button
type="button"
onClick={() => setPendingParticipantIds(pendingParticipantIds.filter(id => id !== userId))}
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Удалить"
>
<X size={14} />
</button>
</div>
) : null;
})}
</div>
)}
{eventParticipantsLoading ? (
<p className="text-xs text-gray-500 py-2">Загрузка...</p>
) : editingEvent.id && eventParticipants.length > 0 ? (
<div className="space-y-2 max-h-32 overflow-y-auto">
{eventParticipants.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-sky-100 flex items-center justify-center">
<span className="text-xs font-medium text-sky-700">
{participant.user_name?.[0] || '?'}
</span>
</div>
<div className="text-xs">
<div className="font-medium text-gray-900">{participant.user_name}</div>
<div className="text-gray-500">{participant.distinction || 'attendee'}</div>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveParticipant(participant.id)}
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Удалить участника"
>
<X size={14} />
</button>
</div>
))}
</div>
) : editingEvent.id ? (
<p className="text-xs text-gray-500 py-2">Нет участников</p>
) : !pendingParticipantIds.length ? (
<p className="text-xs text-gray-500 py-2">Добавьте участников после создания или выберите их до сохранения</p>
) : null}
{/* Форма добавления участников */}
{showAddParticipants && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-3">
<input
type="text"
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
{loadingEmployees ? (
<p className="text-xs text-gray-500 py-2">Загрузка сотрудников...</p>
) : (
<>
<div className="max-h-48 overflow-y-auto space-y-1">
{availableEmployees
.filter(emp => {
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase();
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
const notParticipant = !eventParticipants.find(p => p.object_id === emp.id);
const notPending = !pendingParticipantIds.includes(emp.id);
return matchesSearch && notParticipant && notPending;
})
.map(emp => (
<label
key={emp.id}
className="flex items-center gap-2 p-2 rounded hover:bg-white cursor-pointer"
>
<input
type="checkbox"
checked={selectedEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedEmployeeIds([...selectedEmployeeIds, emp.id]);
} else {
setSelectedEmployeeIds(selectedEmployeeIds.filter(id => id !== emp.id));
}
}}
className="h-4 w-4 rounded border-gray-300 text-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<div className="text-xs">
<div className="font-medium text-gray-900">
{emp.first_name} {emp.last_name}
</div>
{emp.email && <div className="text-gray-500">{emp.email}</div>}
</div>
</label>
))}
</div>
{selectedEmployeeIds.length > 0 && (
<button
type="button"
onClick={handleAddParticipants}
disabled={addingParticipants}
className="w-full rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{addingParticipants ? 'Добавление...' : `Добавить выбранных (${selectedEmployeeIds.length})`}
</button>
)}
</>
)}
</div>
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!editingEvent.title?.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 ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
</button>
{editingEvent.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>
);
}