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:
parent
0dd3ac7cc1
commit
45870fba86
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
510
backend/tests/test_schedule_permissions.py
Normal file
510
backend/tests/test_schedule_permissions.py
Normal 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
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
402
frontend/src/components/CalendarParticipantsModal.tsx
Normal file
402
frontend/src/components/CalendarParticipantsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
725
frontend/src/components/EventModal.tsx
Normal file
725
frontend/src/components/EventModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user