feat: refine department calendar behavior and UI
This commit is contained in:
parent
5700b53f13
commit
78b8615871
@ -31,6 +31,8 @@ class RelatedUserSummarySerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
first_name = serializers.CharField(required=False, allow_blank=True)
|
||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
||||
email = serializers.EmailField(required=False, allow_blank=True, allow_null=True)
|
||||
avatar = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
full_name = serializers.CharField(required=False)
|
||||
role = serializers.CharField(required=False)
|
||||
|
||||
@ -63,6 +65,13 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
|
||||
def get_user(self, obj):
|
||||
"""Информация о связанном пользователе."""
|
||||
if obj.content_object:
|
||||
avatar = getattr(obj.content_object, "avatar", None)
|
||||
avatar_url = None
|
||||
if avatar:
|
||||
try:
|
||||
avatar_url = avatar.url
|
||||
except Exception:
|
||||
avatar_url = str(avatar)
|
||||
return {
|
||||
"id": obj.content_object.id,
|
||||
"username": getattr(
|
||||
@ -70,6 +79,8 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
|
||||
),
|
||||
"first_name": getattr(obj.content_object, "first_name", ""),
|
||||
"last_name": getattr(obj.content_object, "last_name", ""),
|
||||
"email": getattr(obj.content_object, "email", ""),
|
||||
"avatar": avatar_url,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@ -46,6 +46,24 @@ from scheduling.services import (
|
||||
)
|
||||
|
||||
|
||||
def _admin_requested_all_scope(request) -> bool:
|
||||
user = request.user
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return False
|
||||
return request.query_params.get("scope") == "all"
|
||||
|
||||
|
||||
def _user_accessible_calendar_ids(user):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
return CalendarRelation.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
).values_list("calendar_id", flat=True)
|
||||
|
||||
|
||||
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD для django-scheduler Calendar с системой прав доступа.
|
||||
|
||||
@ -78,26 +96,20 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Фильтрация календарей по доступу пользователя."""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
qs = super().get_queryset().select_related(
|
||||
"binding",
|
||||
"binding__context_content_type",
|
||||
)
|
||||
user = self.request.user
|
||||
show_all_scope = _admin_requested_all_scope(self.request)
|
||||
|
||||
# Админы видят все
|
||||
if user.is_staff or user.is_superuser:
|
||||
# Для detail/admin actions доступ админа остаётся полным.
|
||||
if (user.is_staff or user.is_superuser) and (
|
||||
self.action != "list" or show_all_scope
|
||||
):
|
||||
filtered = qs
|
||||
else:
|
||||
# Остальные видят только календари, где они участники
|
||||
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)
|
||||
|
||||
calendar_ids = _user_accessible_calendar_ids(user)
|
||||
visibility_filter = Q(id__in=calendar_ids)
|
||||
if self.action not in {"list", "create"}:
|
||||
visibility_filter |= Q(
|
||||
@ -411,23 +423,21 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
user = self.request.user
|
||||
calendar_id = self.request.query_params.get("calendar")
|
||||
show_all_scope = _admin_requested_all_scope(self.request)
|
||||
|
||||
# Админы видят все события
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
# Полный обзор для админа включается только явным scope=all
|
||||
# либо на detail actions, где нужен прямой доступ.
|
||||
if not ((user.is_staff or user.is_superuser) and show_all_scope):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
# ID календарей где пользователь участник
|
||||
accessible_calendar_ids = CalendarRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
).values_list("calendar_id", flat=True)
|
||||
|
||||
# ID событий где пользователь участник
|
||||
accessible_calendar_ids = _user_accessible_calendar_ids(user)
|
||||
participant_event_ids = EventRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
).values_list("event_id", flat=True)
|
||||
|
||||
# Объединяем: события из доступных календарей ИЛИ где пользователь
|
||||
# участник
|
||||
visibility_filter = (
|
||||
Q(calendar_id__in=accessible_calendar_ids)
|
||||
| Q(id__in=participant_event_ids)
|
||||
@ -524,18 +534,12 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
# Если календарь не указан - берем все доступные пользователю
|
||||
# календари
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = request.user
|
||||
|
||||
if user.is_staff or user.is_superuser:
|
||||
if _admin_requested_all_scope(request):
|
||||
calendars = Calendar.objects.all()
|
||||
else:
|
||||
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)
|
||||
calendar_ids = _user_accessible_calendar_ids(user)
|
||||
calendars = Calendar.objects.filter(id__in=calendar_ids)
|
||||
|
||||
# Результат
|
||||
|
||||
@ -51,7 +51,7 @@ def get_department_calendar_binding(
|
||||
|
||||
|
||||
def department_calendar_name(department: Department) -> str:
|
||||
return f"Календарь отдела: {department.name}"
|
||||
return department.name
|
||||
|
||||
|
||||
def department_calendar_slug(department: Department) -> str:
|
||||
|
||||
@ -244,3 +244,109 @@ def test_department_calendar_membership_sync_removes_inactive_member(
|
||||
content_type=user_ct,
|
||||
object_id=member.id,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_department_calendar_rejects_manual_participant_changes(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Audit", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
add_response = api_client.post(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/add-participant/",
|
||||
{"user_id": outsider.id, "role": "viewer"},
|
||||
format="json",
|
||||
)
|
||||
assert add_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "синхронизируются" in add_response.data["detail"]
|
||||
|
||||
remove_response = api_client.delete(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/remove-participant/{member.id}/"
|
||||
)
|
||||
assert remove_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "синхронизируются" in remove_response.data["detail"]
|
||||
|
||||
|
||||
def test_department_calendar_participants_include_avatar_and_email(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com", first_name="Head", last_name="User")
|
||||
head.avatar = "avatars/head-calendar.jpg"
|
||||
head.save(update_fields=["avatar"])
|
||||
|
||||
department = Department.objects.create(name="HR", head=head)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
response = api_client.get(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/participants/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
participant = response.data[0]
|
||||
assert participant["user"]["id"] == head.id
|
||||
assert participant["user"]["email"] == head.email
|
||||
assert participant["user"]["avatar"] == head.avatar.url
|
||||
assert participant["user"]["first_name"] == "Head"
|
||||
assert participant["user"]["last_name"] == "User"
|
||||
|
||||
|
||||
def test_department_calendar_occurrences_allow_outsider_but_keep_permissions(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Treasury", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
start = timezone.now().replace(microsecond=0)
|
||||
member_event = Event.objects.create(
|
||||
calendar_id=calendar_id,
|
||||
title="Member-only event",
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=member,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
response = api_client.get(
|
||||
"/api/v1/schedule/events/occurrences/",
|
||||
{
|
||||
"calendar": calendar_id,
|
||||
"start": (start - timedelta(minutes=5)).isoformat(),
|
||||
"end": (start + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
occurrence = response.data[0]
|
||||
assert occurrence["event_id"] == member_event.id
|
||||
assert occurrence["can_edit"] is False
|
||||
assert occurrence["can_delete"] is False
|
||||
|
||||
@ -15,30 +15,45 @@ def _migrate_to(target):
|
||||
return executor.loader.project_state(target).apps
|
||||
|
||||
|
||||
def _migrate_to_latest():
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(executor.loader.graph.leaf_nodes())
|
||||
|
||||
|
||||
def test_migration_0047_removes_obsolete_department_permissions():
|
||||
old_apps = _migrate_to([("employees", "0046_alter_ldapsyncqueue_operation")])
|
||||
DepartmentPermission = old_apps.get_model("employees", "DepartmentPermission")
|
||||
try:
|
||||
old_apps = _migrate_to(
|
||||
[("employees", "0046_alter_ldapsyncqueue_operation")]
|
||||
)
|
||||
DepartmentPermission = old_apps.get_model(
|
||||
"employees", "DepartmentPermission"
|
||||
)
|
||||
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department_events",
|
||||
name="Управлять календарём отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="publish_department_post",
|
||||
name="Публиковать новости на странице отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department",
|
||||
name="Управлять отделом",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department_events",
|
||||
name="Управлять календарём отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="publish_department_post",
|
||||
name="Публиковать новости на странице отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department",
|
||||
name="Управлять отделом",
|
||||
)
|
||||
|
||||
_migrate_to([("employees", "0047_alter_departmentpermission_code")])
|
||||
_migrate_to([("employees", "0047_alter_departmentpermission_code")])
|
||||
|
||||
from employees.models import DepartmentPermission as CurrentDepartmentPermission
|
||||
from employees.models import (
|
||||
DepartmentPermission as CurrentDepartmentPermission,
|
||||
)
|
||||
|
||||
codes = set(
|
||||
CurrentDepartmentPermission.objects.values_list("code", flat=True)
|
||||
)
|
||||
assert "manage_department_events" not in codes
|
||||
assert "publish_department_post" not in codes
|
||||
assert "manage_department" in codes
|
||||
codes = set(
|
||||
CurrentDepartmentPermission.objects.values_list("code", flat=True)
|
||||
)
|
||||
assert "manage_department_events" not in codes
|
||||
assert "publish_department_post" not in codes
|
||||
assert "manage_department" in codes
|
||||
finally:
|
||||
_migrate_to_latest()
|
||||
|
||||
52
backend/tests/scheduling/test_calendar_binding_migration.py
Normal file
52
backend/tests/scheduling/test_calendar_binding_migration.py
Normal file
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def _migrate_to(target):
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(target)
|
||||
return executor.loader.project_state(target).apps
|
||||
|
||||
|
||||
def _migrate_to_latest():
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(executor.loader.graph.leaf_nodes())
|
||||
|
||||
|
||||
def test_migration_0002_backfills_department_binding_by_slug():
|
||||
try:
|
||||
old_apps = _migrate_to(
|
||||
[
|
||||
("employees", "0047_alter_departmentpermission_code"),
|
||||
("schedule", "0015_rename_indexes"),
|
||||
("scheduling", "0001_initial"),
|
||||
]
|
||||
)
|
||||
Department = old_apps.get_model("employees", "Department")
|
||||
Calendar = old_apps.get_model("schedule", "Calendar")
|
||||
|
||||
department = Department.objects.create(name="Finance")
|
||||
calendar = Calendar.objects.create(
|
||||
name="Finance",
|
||||
slug=f"department-{department.id}",
|
||||
)
|
||||
|
||||
_migrate_to([("scheduling", "0002_calendarbinding")])
|
||||
|
||||
from scheduling.models import CalendarBinding
|
||||
|
||||
binding = CalendarBinding.objects.get(calendar_id=calendar.id)
|
||||
assert binding.type == CalendarBinding.BindingType.DEPARTMENT
|
||||
assert binding.context_object_id == department.id
|
||||
assert binding.context_content_type.app_label == "employees"
|
||||
assert binding.context_content_type.model == "department"
|
||||
finally:
|
||||
_migrate_to_latest()
|
||||
@ -2,9 +2,12 @@
|
||||
"""
|
||||
Тесты для системы прав доступа календарей django-scheduler.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from schedule.models import Calendar, CalendarRelation, Event, Rule
|
||||
@ -57,6 +60,22 @@ def user3(db):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Администратор для проверки scope календарей."""
|
||||
return User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="testpass123",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
phone_number="+79991234570",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
email_verified=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCalendarPermissions:
|
||||
"""Тесты прав доступа к календарям."""
|
||||
@ -254,10 +273,136 @@ class TestCalendarPermissions:
|
||||
assert roles[user2.id] == 'editor'
|
||||
assert roles[user3.id] == 'viewer'
|
||||
|
||||
def test_admin_list_scope_defaults_to_accessible_only(
|
||||
self, api_client, admin_user, user1
|
||||
):
|
||||
"""По умолчанию админ видит свои доступные календари, а не весь реестр."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
admin_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Admin Calendar'}
|
||||
)
|
||||
assert admin_calendar_response.status_code == status.HTTP_201_CREATED
|
||||
admin_calendar_id = admin_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=user1)
|
||||
other_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Other Calendar'}
|
||||
)
|
||||
assert other_calendar_response.status_code == status.HTTP_201_CREATED
|
||||
other_calendar_id = other_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
default_response = api_client.get('/api/v1/schedule/calendars/')
|
||||
default_calendars = default_response.data
|
||||
assert {cal['id'] for cal in default_calendars} == {admin_calendar_id}
|
||||
|
||||
full_response = api_client.get('/api/v1/schedule/calendars/?scope=all')
|
||||
full_calendars = full_response.data
|
||||
assert {cal['id'] for cal in full_calendars} == {
|
||||
admin_calendar_id,
|
||||
other_calendar_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRecurringEvents:
|
||||
"""Тесты повторяющихся событий."""
|
||||
|
||||
def test_admin_event_scope_matches_calendar_scope(
|
||||
self, api_client, admin_user, user1
|
||||
):
|
||||
"""У admin default list/occurrences не должны тянуть чужие календари."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
admin_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Admin Calendar'}
|
||||
)
|
||||
admin_calendar_id = admin_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=user1)
|
||||
other_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Other Calendar'}
|
||||
)
|
||||
other_calendar_id = other_calendar_response.data['id']
|
||||
|
||||
start = timezone.now().replace(microsecond=0, second=0)
|
||||
range_end = start + timedelta(hours=2)
|
||||
Event.objects.create(
|
||||
calendar_id=admin_calendar_id,
|
||||
title='Admin event',
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=admin_user,
|
||||
)
|
||||
Event.objects.create(
|
||||
calendar_id=other_calendar_id,
|
||||
title='Other event',
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=user1,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
default_events = api_client.get(
|
||||
'/api/v1/schedule/events/',
|
||||
{
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert default_events.status_code == status.HTTP_200_OK
|
||||
default_events_list = (
|
||||
default_events.data['results']
|
||||
if isinstance(default_events.data, dict)
|
||||
else default_events.data
|
||||
)
|
||||
assert {event['title'] for event in default_events_list} == {'Admin event'}
|
||||
|
||||
all_events = api_client.get(
|
||||
'/api/v1/schedule/events/',
|
||||
{
|
||||
'scope': 'all',
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert all_events.status_code == status.HTTP_200_OK
|
||||
all_events_list = (
|
||||
all_events.data['results']
|
||||
if isinstance(all_events.data, dict)
|
||||
else all_events.data
|
||||
)
|
||||
assert {event['title'] for event in all_events_list} == {
|
||||
'Admin event',
|
||||
'Other event',
|
||||
}
|
||||
|
||||
default_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert default_occurrences.status_code == status.HTTP_200_OK
|
||||
assert {event['title'] for event in default_occurrences.data} == {'Admin event'}
|
||||
|
||||
all_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'scope': 'all',
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert all_occurrences.status_code == status.HTTP_200_OK
|
||||
assert {event['title'] for event in all_occurrences.data} == {
|
||||
'Admin event',
|
||||
'Other event',
|
||||
}
|
||||
|
||||
def test_create_rule(self, api_client, user1):
|
||||
"""Создание правила повторения."""
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
/* Кастомные стили для React Big Calendar в едином стиле с проектом */
|
||||
|
||||
.calendar-shell {
|
||||
background: color-mix(in srgb, var(--surface-secondary) 82%, var(--surface-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle) 92%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-calendar {
|
||||
font-family: inherit;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Панель инструментов */
|
||||
@ -9,8 +19,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 0 1rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@ -22,33 +32,33 @@
|
||||
.rbc-toolbar button {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 1px 2px 0 color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar button:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-secondary);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 20%, var(--border-strong));
|
||||
box-shadow: 0 1px 3px 0 color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar button.rbc-active {
|
||||
background: #0ea5e9;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: #0ea5e9;
|
||||
box-shadow: 0 1px 3px 0 rgba(14, 165, 233, 0.3);
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 1px 3px 0 color-mix(in srgb, var(--accent-primary) 32%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar-label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -59,27 +69,36 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
letter-spacing: 0.025em;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-header + .rbc-header {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Ячейки дней */
|
||||
.rbc-day-bg {
|
||||
border: 1px solid #f3f4f6;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
}
|
||||
|
||||
.rbc-day-bg + .rbc-day-bg {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-day-bg.rbc-today {
|
||||
background-color: #f0f9ff !important;
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-primary)) !important;
|
||||
}
|
||||
|
||||
.rbc-day-bg.rbc-off-range-bg {
|
||||
background-color: #fafafa;
|
||||
background-color: color-mix(in srgb, var(--surface-secondary) 88%, var(--surface-primary));
|
||||
}
|
||||
|
||||
.rbc-date-cell {
|
||||
padding: 0.25rem;
|
||||
padding: 0.35rem 0.35rem 0.15rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@ -89,7 +108,7 @@
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@ -97,26 +116,28 @@
|
||||
}
|
||||
|
||||
.rbc-date-cell button:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.rbc-date-cell.rbc-now button {
|
||||
background: #0ea5e9;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent-primary) 24%, transparent);
|
||||
}
|
||||
|
||||
.rbc-date-cell.rbc-off-range button {
|
||||
color: #9ca3af;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 68%, transparent);
|
||||
}
|
||||
|
||||
/* События */
|
||||
.rbc-event {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 14px -10px color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
}
|
||||
|
||||
.rbc-event:hover {
|
||||
@ -134,30 +155,61 @@
|
||||
|
||||
/* Селектор временных слотов */
|
||||
.rbc-slot-selection {
|
||||
background-color: rgba(14, 165, 233, 0.15);
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
/* Неделя и день */
|
||||
.rbc-time-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-time-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-time-header.rbc-overflowing {
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-header > .rbc-row:first-child,
|
||||
.rbc-time-header > .rbc-row.rbc-row-resource,
|
||||
.rbc-time-header-content > .rbc-row.rbc-row-resource {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-header-content {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-content {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-content > * + * > * {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-timeslot-group {
|
||||
border-left: 1px solid #e5e7eb;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 40px;
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
}
|
||||
|
||||
.rbc-time-slot {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 82%, transparent);
|
||||
}
|
||||
|
||||
.rbc-day-slot .rbc-time-slot {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 82%, transparent);
|
||||
}
|
||||
|
||||
.rbc-time-view .rbc-allday-cell + .rbc-allday-cell {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-current-time-indicator {
|
||||
@ -165,45 +217,79 @@
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.rbc-time-gutter,
|
||||
.rbc-label {
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-time-view-resources .rbc-time-gutter,
|
||||
.rbc-time-view-resources .rbc-time-header-gutter {
|
||||
background-color: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Список (agenda) */
|
||||
.rbc-agenda-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table {
|
||||
border-spacing: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-content {
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table tbody > tr:hover {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.rbc-agenda-date-cell {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-time-cell {
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rbc-agenda-event-cell {
|
||||
color: #111827;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-empty {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-height: 16rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Попап "показать ещё" */
|
||||
.rbc-overlay {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.95rem;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
@ -211,42 +297,53 @@
|
||||
.rbc-overlay-header {
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: var(--foreground);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Показать больше */
|
||||
.rbc-show-more {
|
||||
background: transparent;
|
||||
color: #0ea5e9;
|
||||
color: var(--accent-primary-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rbc-show-more:hover {
|
||||
background: #f0f9ff;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Месячный вид */
|
||||
.rbc-month-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-month-row {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-month-row + .rbc-month-row {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-month-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rbc-month-view,
|
||||
.rbc-time-view,
|
||||
.rbc-agenda-view {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.rbc-toolbar {
|
||||
|
||||
@ -848,11 +848,19 @@ export default function DepartmentDetailPage() {
|
||||
const response = await apiClient.getDepartmentCalendar(h.department.id) as {
|
||||
calendar_id: number;
|
||||
name: string;
|
||||
calendar_type?: string;
|
||||
context_type?: string | null;
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
calendar: String(response.calendar_id),
|
||||
calendarName: response.name,
|
||||
});
|
||||
if (response.calendar_type) {
|
||||
params.set("calendarType", response.calendar_type);
|
||||
}
|
||||
if (response.context_type) {
|
||||
params.set("contextType", response.context_type);
|
||||
}
|
||||
router.push(`/calendar?${params.toString()}`);
|
||||
} catch {
|
||||
toast.error("Не удалось открыть календарь отдела");
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from "react-big-calendar";
|
||||
import { Calendar, dateFnsLocalizer, View, ToolbarProps } from "react-big-calendar";
|
||||
import { format, parse, startOfWeek, getDay } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
|
||||
import { CalendarModal } from "@/components/CalendarModal";
|
||||
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
|
||||
import { EventModal } from "@/components/EventModal";
|
||||
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
|
||||
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
|
||||
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import { calendarService } from "@/services/calendarService";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import "../app/calendar/calendar.css";
|
||||
|
||||
@ -84,20 +86,20 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onNavigate("TODAY")}
|
||||
className="app-action-secondary px-3 py-2"
|
||||
className="app-action-secondary rounded-xl px-4 py-2"
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate("PREV")}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||||
title="Назад"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate("NEXT")}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||||
title="Вперёд"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
@ -109,17 +111,18 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
</div>
|
||||
|
||||
{/* Выбор вида */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={view}
|
||||
onChange={(e) => onView(e.target.value as View)}
|
||||
className="app-select w-full"
|
||||
className="app-select w-full appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||||
>
|
||||
<option value="month">{viewLabels.month}</option>
|
||||
<option value="week">{viewLabels.week}</option>
|
||||
<option value="day">{viewLabels.day}</option>
|
||||
<option value="agenda">{viewLabels.agenda}</option>
|
||||
</select>
|
||||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -129,7 +132,15 @@ export function BigCalendar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
|
||||
const {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
reloadCalendars,
|
||||
} = useCalendar();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<View>("month");
|
||||
@ -140,15 +151,21 @@ export function BigCalendar() {
|
||||
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [showCalendarActions, setShowCalendarActions] = useState(false);
|
||||
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Новые модалы для просмотра
|
||||
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const eventsRequestIdRef = useRef(0);
|
||||
const linkedEventId = Number(searchParams.get("event") || "");
|
||||
const linkedCalendarId = Number(searchParams.get("calendar") || "");
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
const linkedCalendarType = searchParams.get("calendarType") || "";
|
||||
const linkedContextType = searchParams.get("contextType") || "";
|
||||
|
||||
const selectedCalendar = useMemo(() => {
|
||||
const calendar = calendars.find((item) => item.id === selectedCalendarId);
|
||||
@ -158,13 +175,15 @@ export function BigCalendar() {
|
||||
id: selectedCalendarId,
|
||||
name: linkedCalendarName,
|
||||
slug: `linked-calendar-${selectedCalendarId}`,
|
||||
type: linkedCalendarType || null,
|
||||
context_type: linkedContextType || null,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, selectedCalendarId]);
|
||||
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
@ -174,6 +193,20 @@ export function BigCalendar() {
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
const canOpenParticipantsModal = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.can_manage_participants ||
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
const isDepartmentCalendar = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
|
||||
const clearEventParam = useCallback(() => {
|
||||
if (!searchParams.get("event")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
@ -188,6 +221,9 @@ export function BigCalendar() {
|
||||
|
||||
// Загрузка событий
|
||||
const loadEvents = useCallback(async () => {
|
||||
const requestId = ++eventsRequestIdRef.current;
|
||||
const requestedCalendarId = selectedCalendarId;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@ -207,12 +243,14 @@ export function BigCalendar() {
|
||||
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
|
||||
const [eventsResult, occurrencesResult] = await Promise.all([
|
||||
apiClient.getCalendarEvents({
|
||||
calendar: selectedCalendarId || undefined,
|
||||
calendar: requestedCalendarId || undefined,
|
||||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
}),
|
||||
apiClient.getOccurrences({
|
||||
calendar: selectedCalendarId || undefined,
|
||||
calendar: requestedCalendarId || undefined,
|
||||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
}),
|
||||
@ -256,13 +294,29 @@ export function BigCalendar() {
|
||||
}));
|
||||
|
||||
// Объединяем обычные события и occurrences
|
||||
setEvents([...regularEvents, ...occurrenceEvents]);
|
||||
let nextEvents = [...regularEvents, ...occurrenceEvents];
|
||||
|
||||
if (requestedCalendarId !== null) {
|
||||
nextEvents = nextEvents.filter((event) => event.calendar === requestedCalendarId);
|
||||
}
|
||||
|
||||
// Если параллельно ушёл более новый запрос, его ответ приоритетнее.
|
||||
if (requestId !== eventsRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents(nextEvents);
|
||||
} catch (err) {
|
||||
if (requestId !== eventsRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
console.error("Ошибка загрузки событий:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestId === eventsRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [selectedCalendarId, currentDate]);
|
||||
}, [calendarScope, canUseAllCalendarsMode, currentDate, selectedCalendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
@ -367,6 +421,84 @@ export function BigCalendar() {
|
||||
setShowEventModal(true);
|
||||
}, [viewingEvent]);
|
||||
|
||||
const handleCreateCalendar = useCallback(() => {
|
||||
setEditingCalendar({ name: "" });
|
||||
setShowCalendarModal(true);
|
||||
setShowCalendarActions(false);
|
||||
}, []);
|
||||
|
||||
const handleEditCalendar = useCallback(() => {
|
||||
if (!selectedCalendar?.can_edit_calendar || isDepartmentCalendar) {
|
||||
return;
|
||||
}
|
||||
setEditingCalendar({ id: selectedCalendar.id, name: selectedCalendar.name });
|
||||
setShowCalendarModal(true);
|
||||
setShowCalendarActions(false);
|
||||
}, [isDepartmentCalendar, selectedCalendar]);
|
||||
|
||||
const handleDeleteCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar || isDepartmentCalendar) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.deleteCalendar(selectedCalendar.id);
|
||||
calendarService.clearCache();
|
||||
setSelectedCalendarId(null);
|
||||
setShowCalendarActions(false);
|
||||
await reloadCalendars();
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
console.error("Не удалось удалить календарь:", error);
|
||||
alert("Не удалось удалить календарь");
|
||||
}
|
||||
}, [isDepartmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
|
||||
|
||||
const handleExportCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id) return;
|
||||
|
||||
try {
|
||||
const blob = await calendarService.exportToICS(selectedCalendar.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `calendar-${selectedCalendar.id}.ics`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("Не удалось экспортировать календарь:", error);
|
||||
alert("Не удалось экспортировать календарь");
|
||||
} finally {
|
||||
setShowCalendarActions(false);
|
||||
}
|
||||
}, [selectedCalendar]);
|
||||
|
||||
const handleImportCalendar = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedCalendar?.id || !event.target.files || event.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.target.files[0];
|
||||
try {
|
||||
const result = await calendarService.importFromICS(selectedCalendar.id, file);
|
||||
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
|
||||
loadEvents();
|
||||
} catch (error: any) {
|
||||
console.error("Не удалось импортировать календарь:", error);
|
||||
alert(`Ошибка импорта: ${error.message}`);
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setShowCalendarActions(false);
|
||||
}
|
||||
}, [loadEvents, selectedCalendar]);
|
||||
|
||||
// Клик на событие из модала просмотра дня
|
||||
const handleEventClickFromDay = useCallback(async (event: any) => {
|
||||
setShowDayEventsModal(false);
|
||||
@ -410,11 +542,146 @@ export function BigCalendar() {
|
||||
<>
|
||||
<div className="app-surface rounded-2xl">
|
||||
{/* Панель выбора календаря */}
|
||||
<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-[var(--foreground)]">Календарь:</label>
|
||||
<div className="app-divider relative z-20 border-b px-6 py-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="app-card-caption">Календарь</p>
|
||||
<p className="app-text-muted text-sm">
|
||||
{selectedCalendar
|
||||
? `События календаря «${selectedCalendar.name}»`
|
||||
: "Просмотр всех доступных событий"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end">
|
||||
{loading && (
|
||||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendars.length > 0 && (
|
||||
<div className="relative z-30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCalendarActions((prev) => !prev)}
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с календарем"
|
||||
aria-label="Действия с календарем"
|
||||
aria-expanded={showCalendarActions}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${showCalendarActions ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showCalendarActions && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[50]"
|
||||
onClick={() => setShowCalendarActions(false)}
|
||||
/>
|
||||
<div className="app-menu pointer-events-auto absolute right-0 top-full z-[80] mt-1 w-52 rounded-xl">
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCalendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать календарь
|
||||
</button>
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (canOpenParticipantsModal) {
|
||||
setShowParticipantsModal(true);
|
||||
}
|
||||
setShowCalendarActions(false);
|
||||
}}
|
||||
disabled={!canOpenParticipantsModal}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Users size={16} />
|
||||
Участники
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCalendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<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={handleImportCalendar}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!isDepartmentCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditCalendar}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteCalendar()}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendars.length === 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="app-text-muted text-sm">Календарей нет.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -427,84 +694,87 @@ export function BigCalendar() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select max-w-xs flex-1"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<label className="app-text-muted shrink-0 text-sm font-medium">Календарь</label>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select w-full min-w-0 appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCalendar({ name: "" });
|
||||
setShowCalendarModal(true);
|
||||
}}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
title="Создать календарь"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
{selectedCalendarId && selectedCalendar?.can_edit_calendar && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = selectedCalendar;
|
||||
if (cal) {
|
||||
setEditingCalendar({ id: cal.id, name: cal.name });
|
||||
setShowCalendarModal(true);
|
||||
}
|
||||
}}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
title="Настройки календаря"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canUseAllCalendarsMode && (
|
||||
<div className="grid min-w-[12rem] grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("accessible")}
|
||||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||||
calendarScope === "accessible"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Доступные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("all")}
|
||||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||||
calendarScope === "all"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Календарь */}
|
||||
<div className="overflow-hidden px-6 pb-6" style={{ height: "750px" }}>
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
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 className="relative z-0 overflow-hidden px-6 pb-6 pt-5" style={{ height: "750px" }}>
|
||||
<div className="calendar-shell h-full rounded-[1.4rem]">
|
||||
<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>
|
||||
|
||||
@ -572,6 +842,19 @@ export function BigCalendar() {
|
||||
}}
|
||||
calendar={editingCalendar}
|
||||
/>
|
||||
|
||||
{selectedCalendar && (
|
||||
<CalendarParticipantsModal
|
||||
isOpen={showParticipantsModal}
|
||||
onClose={() => setShowParticipantsModal(false)}
|
||||
calendarId={selectedCalendar.id}
|
||||
calendarName={selectedCalendar.name}
|
||||
userRole={(selectedCalendar as any).user_role}
|
||||
canManageParticipants={selectedCalendar.can_manage_participants}
|
||||
calendarType={selectedCalendar.type}
|
||||
contextType={selectedCalendar.context_type}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ interface CalendarParticipantsModalProps {
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
userRole?: string; // owner, editor, viewer
|
||||
canManageParticipants?: boolean;
|
||||
calendarType?: string | null;
|
||||
contextType?: string | null;
|
||||
}
|
||||
|
||||
export default function CalendarParticipantsModal({
|
||||
@ -52,6 +55,9 @@ export default function CalendarParticipantsModal({
|
||||
calendarId,
|
||||
calendarName,
|
||||
userRole,
|
||||
canManageParticipants = false,
|
||||
calendarType,
|
||||
contextType,
|
||||
}: CalendarParticipantsModalProps) {
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
@ -62,7 +68,9 @@ export default function CalendarParticipantsModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const isOwner = userRole === 'owner';
|
||||
const isDepartmentCalendar =
|
||||
calendarType === 'department' || contextType === 'department';
|
||||
const canManage = canManageParticipants && !isDepartmentCalendar;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@ -204,8 +212,18 @@ export default function CalendarParticipantsModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Add Participant Section - только для владельца */}
|
||||
{isOwner && (
|
||||
{isDepartmentCalendar && (
|
||||
<div className="app-surface-muted rounded-lg p-4">
|
||||
<p className="app-text-muted text-sm">
|
||||
Состав участников календаря отдела синхронизируется автоматически
|
||||
из состава отдела и назначенных ролей. Управление через эту
|
||||
модалку отключено для всех пользователей, включая администраторов.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Participant Section */}
|
||||
{canManage && (
|
||||
<div className="app-surface-muted space-y-3 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||
@ -351,7 +369,8 @@ export default function CalendarParticipantsModal({
|
||||
const user = participant.user || {};
|
||||
const firstName = user.first_name || 'N';
|
||||
const lastName = user.last_name || 'A';
|
||||
const username = user.username || 'unknown';
|
||||
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
||||
const secondaryLabel = user.email || (username ? `@${username}` : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -376,7 +395,9 @@ export default function CalendarParticipantsModal({
|
||||
<div className="text-sm font-medium text-[var(--foreground)]">
|
||||
{firstName} {lastName}
|
||||
</div>
|
||||
<div className="app-text-muted text-xs">{user.email || `@${username}`}</div>
|
||||
{secondaryLabel ? (
|
||||
<div className="app-text-muted text-xs">{secondaryLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -392,7 +413,7 @@ export default function CalendarParticipantsModal({
|
||||
participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}
|
||||
</span>
|
||||
|
||||
{isOwner && participant.distinction !== 'owner' && (
|
||||
{canManage && participant.distinction !== 'owner' && (
|
||||
<button
|
||||
onClick={() => handleRemoveParticipant(participant.user.id)}
|
||||
disabled={loading}
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Pencil, Trash2, Users } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
|
||||
import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService";
|
||||
@ -12,7 +13,14 @@ import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-col
|
||||
interface CalendarCardProps {
|
||||
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
|
||||
onOpenEventModal: (event: any, date?: Date) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
@ -38,9 +46,19 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
setSidebarEvents,
|
||||
onCalendarChange,
|
||||
}: CalendarCardProps) {
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId } = useCalendar();
|
||||
const {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
reloadCalendars,
|
||||
} = useCalendar();
|
||||
const searchParams = useSearchParams();
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
const linkedCalendarType = searchParams.get("calendarType") || "";
|
||||
const linkedContextType = searchParams.get("contextType") || "";
|
||||
|
||||
const [monthDate, setMonthDate] = useState(() => {
|
||||
const now = new Date();
|
||||
@ -60,13 +78,15 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
id: selectedCalendarId,
|
||||
name: linkedCalendarName,
|
||||
slug: `linked-calendar-${selectedCalendarId}`,
|
||||
type: linkedCalendarType || null,
|
||||
context_type: linkedContextType || null,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, selectedCalendarId]);
|
||||
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
@ -76,10 +96,26 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
const canOpenParticipantsModal = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.can_manage_participants ||
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
const isDepartmentCalendar = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
|
||||
// Загружаем события через хук
|
||||
const { events, loading, error } = useCalendarEvents({
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded: setSidebarEvents,
|
||||
});
|
||||
@ -183,6 +219,33 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
setShowCalendarMenu(false);
|
||||
}, [selectedCalendarId]);
|
||||
|
||||
const handleDeleteCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.deleteCalendar(selectedCalendar.id);
|
||||
calendarService.clearCache();
|
||||
setSelectedCalendarId(null);
|
||||
setEventsRefreshTrigger((prev) => prev + 1);
|
||||
await reloadCalendars();
|
||||
} catch (error) {
|
||||
console.error("Не удалось удалить календарь:", error);
|
||||
alert("Не удалось удалить календарь");
|
||||
} finally {
|
||||
setShowCalendarMenu(false);
|
||||
}
|
||||
}, [
|
||||
reloadCalendars,
|
||||
selectedCalendar,
|
||||
setEventsRefreshTrigger,
|
||||
setSelectedCalendarId,
|
||||
]);
|
||||
|
||||
// Дни с событиями
|
||||
const eventDays = useMemo(() => {
|
||||
const days = new Set<string>();
|
||||
@ -275,71 +338,56 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<div className="app-surface rounded-2xl p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="app-text-muted text-[11px] font-semibold uppercase tracking-wide">Календарь</p>
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-icon-button flex h-6 w-6 items-center justify-center rounded-full transition"
|
||||
title="Создать календарь"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-3">
|
||||
<p className="mb-2 text-xs font-medium text-[var(--foreground)]">Календарей пока нет</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-action-primary w-full rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||||
className="app-icon-button flex h-6 w-6 items-center justify-center rounded-full transition"
|
||||
title="Меню календаря"
|
||||
aria-expanded={showCalendarMenu}
|
||||
>
|
||||
Создать первый календарь
|
||||
{showCalendarMenu ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select flex-1 rounded-lg px-2.5 py-2 text-xs"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedCalendar && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||||
className="app-icon-button flex h-9 w-9 items-center justify-center rounded-lg 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="app-menu absolute right-0 top-full z-[60] mt-1 w-48 rounded-lg">
|
||||
<div className="py-1">
|
||||
{showCalendarMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[50]"
|
||||
onClick={() => setShowCalendarMenu(false)}
|
||||
/>
|
||||
<div className="app-menu absolute right-0 top-full z-[60] mt-1 w-48 rounded-lg">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCreateCalendar();
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<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="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>
|
||||
Создать календарь
|
||||
</button>
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCalendar?.can_manage_participants) {
|
||||
onOpenParticipantsModal({ id: selectedCalendar.id, name: selectedCalendar.name, user_role: (selectedCalendar as any).user_role });
|
||||
if (canOpenParticipantsModal) {
|
||||
onOpenParticipantsModal({
|
||||
id: selectedCalendar.id,
|
||||
name: selectedCalendar.name,
|
||||
user_role: (selectedCalendar as any).user_role,
|
||||
can_manage_participants: selectedCalendar.can_manage_participants,
|
||||
type: selectedCalendar.type,
|
||||
context_type: selectedCalendar.context_type,
|
||||
});
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!selectedCalendar?.can_manage_participants}
|
||||
disabled={!canOpenParticipantsModal}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Users size={16} />
|
||||
@ -381,29 +429,98 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCalendar?.can_edit_calendar) {
|
||||
onOpenCalendarModal(selectedCalendar);
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!selectedCalendar?.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{!isDepartmentCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCalendar.can_edit_calendar) {
|
||||
onOpenCalendarModal(selectedCalendar);
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleDeleteCalendar();
|
||||
}}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-3">
|
||||
<p className="mb-2 text-xs font-medium text-[var(--foreground)]">Календарей пока нет</p>
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-action-primary w-full rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Создать первый календарь
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{canUseAllCalendarsMode && (
|
||||
<div className="grid w-full grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("accessible")}
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
calendarScope === "accessible"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Доступные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("all")}
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
calendarScope === "all"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select flex-1 rounded-lg px-2.5 py-2 text-xs"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -465,14 +582,14 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
|
||||
{/* События — неделя / месяц */}
|
||||
<div className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-3 flex items-center gap-1 rounded-lg bg-[var(--surface-secondary)] p-0.5">
|
||||
<div className="mb-3 grid w-full grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventsViewMode("week")}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
eventsViewMode === "week"
|
||||
? "bg-[var(--surface-elevated)] text-[var(--foreground)] shadow-[var(--shadow-card)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Неделя
|
||||
@ -480,10 +597,10 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventsViewMode("month")}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
eventsViewMode === "month"
|
||||
? "bg-[var(--surface-elevated)] text-[var(--foreground)] shadow-[var(--shadow-card)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Месяц
|
||||
|
||||
@ -10,7 +10,14 @@ interface CalendarSidebarProps {
|
||||
event: Partial<CalendarEvent> & { id?: number; calendar?: number | null },
|
||||
date?: Date,
|
||||
) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
|
||||
@ -25,6 +25,9 @@ export function CalendarModals(props: CalendarModalsProps) {
|
||||
calendarId={props.participantsCalendar.id}
|
||||
calendarName={props.participantsCalendar.name}
|
||||
userRole={props.participantsCalendar.user_role}
|
||||
canManageParticipants={props.participantsCalendar.can_manage_participants}
|
||||
calendarType={props.participantsCalendar.type}
|
||||
contextType={props.participantsCalendar.context_type}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -104,7 +104,14 @@ interface CalendarDrawerProps {
|
||||
onClose: () => void;
|
||||
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
|
||||
onOpenEventModal: (event: Partial<CalendarEvent> & { id?: number }, date?: Date) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
|
||||
@ -5,10 +5,15 @@ import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import type { Calendar } from "@/types/api";
|
||||
|
||||
export type CalendarScope = "accessible" | "all";
|
||||
|
||||
type CalendarContextType = {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarId: number | null;
|
||||
setSelectedCalendarId: (id: number | null) => void;
|
||||
calendarScope: CalendarScope;
|
||||
setCalendarScope: (scope: CalendarScope) => void;
|
||||
canUseAllCalendarsMode: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reloadCalendars: () => Promise<void>;
|
||||
@ -20,23 +25,29 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useUser();
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([]);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
const [calendarScope, setCalendarScope] = useState<CalendarScope>("accessible");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
const canUseAllCalendarsMode = Boolean(
|
||||
user?.auth?.is_staff || user?.auth?.is_superuser
|
||||
);
|
||||
|
||||
const loadCalendars = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await apiClient.getCalendars();
|
||||
|
||||
const result = await apiClient.getCalendars({
|
||||
scope:
|
||||
canUseAllCalendarsMode && calendarScope === "all"
|
||||
? "all"
|
||||
: undefined,
|
||||
});
|
||||
// API возвращает пагинированный ответ: {count, results}
|
||||
const cals = Array.isArray(result) ? result : (result?.results || []);
|
||||
setCalendars(cals);
|
||||
|
||||
// По умолчанию показываем "Все события" (selectedCalendarId === null)
|
||||
// Не выбираем автоматически первый календарь
|
||||
|
||||
|
||||
initialLoadDone.current = true;
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки календарей:", err);
|
||||
@ -44,13 +55,18 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadCalendars();
|
||||
} else {
|
||||
setCalendars([]);
|
||||
setSelectedCalendarId(null);
|
||||
setCalendarScope("accessible");
|
||||
initialLoadDone.current = false;
|
||||
}
|
||||
}, [user, loadCalendars]);
|
||||
@ -61,6 +77,9 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
loading,
|
||||
error,
|
||||
reloadCalendars: loadCalendars,
|
||||
|
||||
@ -9,6 +9,8 @@ import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/servic
|
||||
interface UseCalendarEventsOptions {
|
||||
monthDate: Date;
|
||||
selectedCalendarId: number | null;
|
||||
calendarScope: "accessible" | "all";
|
||||
canUseAllCalendarsMode: boolean;
|
||||
eventsRefreshTrigger: number;
|
||||
onEventsLoaded?: (events: CalendarEvent[]) => void;
|
||||
}
|
||||
@ -22,13 +24,15 @@ interface UseCalendarEventsResult {
|
||||
export function useCalendarEvents({
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded,
|
||||
}: UseCalendarEventsOptions): UseCalendarEventsResult {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -45,9 +49,7 @@ export function useCalendarEvents({
|
||||
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
|
||||
|
||||
async function loadEvents() {
|
||||
// Защита от множественных одновременных запросов
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -57,6 +59,8 @@ export function useCalendarEvents({
|
||||
fetchStart,
|
||||
fetchEnd,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger
|
||||
);
|
||||
|
||||
@ -65,6 +69,10 @@ export function useCalendarEvents({
|
||||
onEventsLoaded?.(loadedEvents);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
|
||||
@ -77,9 +85,8 @@ export function useCalendarEvents({
|
||||
setEvents([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,7 +96,14 @@ export function useCalendarEvents({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [monthDate, selectedCalendarId, eventsRefreshTrigger, onEventsLoaded]);
|
||||
}, [
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded,
|
||||
]);
|
||||
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
||||
@ -13,7 +13,14 @@ export interface CalendarModalsState {
|
||||
showEventDetailsModal: boolean;
|
||||
editingCalendar: { id?: number; name: string } | null;
|
||||
editingEvent: CalendarEvent | null;
|
||||
participantsCalendar: { id: number; name: string; user_role?: string } | null;
|
||||
participantsCalendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
} | null;
|
||||
selectedDateForModal: Date | null;
|
||||
viewingEvent: CalendarEvent | null;
|
||||
sidebarEvents: CalendarEvent[];
|
||||
@ -30,7 +37,14 @@ export function useCalendarModals() {
|
||||
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
} | null>(null);
|
||||
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
|
||||
@ -123,7 +137,14 @@ export function useCalendarModals() {
|
||||
setEventsRefreshTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => {
|
||||
const handleOpenParticipantsModal = useCallback((calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => {
|
||||
setParticipantsCalendar(calendar);
|
||||
setShowParticipantsModal(true);
|
||||
}, []);
|
||||
|
||||
@ -3,11 +3,12 @@ import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return {
|
||||
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number }) => {
|
||||
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number; scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.start) qp.append('start', params.start);
|
||||
if (params?.end) qp.append('end', params.end);
|
||||
if (params?.calendar) qp.append('calendar', params.calendar.toString());
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
@ -18,7 +19,12 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/my-events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getCalendars: () => request('/api/v1/schedule/calendars/'),
|
||||
getCalendars: (params?: { scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/calendars/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getCalendar: (id: number) => request(`/api/v1/schedule/calendars/${id}/`),
|
||||
createCalendar: (data: { name: string; slug?: string }) => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCalendar: (id: number, data: { name?: string; slug?: string }) => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
@ -63,11 +69,12 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
request(`/api/v1/schedule/rules/${ruleId}/`, { method: 'PUT', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
|
||||
getRules: () => request('/api/v1/schedule/rules/'),
|
||||
getRule: (ruleId: number) => request(`/api/v1/schedule/rules/${ruleId}/`),
|
||||
getOccurrences: (params: { start: string; end: string; calendar?: number }) => {
|
||||
getOccurrences: (params: { start: string; end: string; calendar?: number; scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('start', params.start);
|
||||
qp.append('end', params.end);
|
||||
if (params.calendar) qp.append('calendar', params.calendar.toString());
|
||||
if (params.scope) qp.append('scope', params.scope);
|
||||
return request(`/api/v1/schedule/events/occurrences/?${qp.toString()}`);
|
||||
},
|
||||
};
|
||||
|
||||
@ -62,9 +62,13 @@ class CalendarService {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
calendarId: number | null,
|
||||
calendarScope: "accessible" | "all",
|
||||
canUseAllCalendarsMode: boolean,
|
||||
refreshTrigger: number
|
||||
): Promise<CalendarEvent[]> {
|
||||
const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${refreshTrigger}`;
|
||||
const apiScope =
|
||||
canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined;
|
||||
const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${calendarScope}_${refreshTrigger}`;
|
||||
|
||||
// Проверяем кэш
|
||||
if (eventsCache && eventsCache.key === cacheKey && Date.now() - eventsCache.timestamp < CACHE_TTL) {
|
||||
@ -77,11 +81,13 @@ class CalendarService {
|
||||
start: formatDateKey(startDate),
|
||||
end: formatDateKey(endDate),
|
||||
calendar: calendarId || undefined,
|
||||
scope: apiScope,
|
||||
}),
|
||||
apiClient.getOccurrences({
|
||||
start: formatDateKey(startDate),
|
||||
end: formatDateKey(endDate),
|
||||
calendar: calendarId || undefined,
|
||||
scope: apiScope,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -93,7 +99,11 @@ class CalendarService {
|
||||
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
|
||||
|
||||
// Объединяем оба типа событий
|
||||
const allEvents = [...regularEvents, ...recurringOccurrences];
|
||||
let allEvents = [...regularEvents, ...recurringOccurrences];
|
||||
|
||||
if (calendarId !== null) {
|
||||
allEvents = allEvents.filter((event) => event.calendar === calendarId);
|
||||
}
|
||||
|
||||
// Сохраняем в кэш
|
||||
eventsCache = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user