feat: refine department calendar behavior and UI

This commit is contained in:
lizerk 2026-04-16 17:22:06 +03:00
parent 5700b53f13
commit 78b8615871
20 changed files with 1262 additions and 315 deletions

View File

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

View File

@ -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)
# Результат

View File

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

View File

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

View File

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

View 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()

View File

@ -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):
"""Создание правила повторения."""

View File

@ -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 {

View File

@ -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("Не удалось открыть календарь отдела");

View File

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

View File

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

View File

@ -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)]"
}`}
>
Месяц

View File

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

View File

@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

@ -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);
}, []);

View File

@ -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()}`);
},
};

View File

@ -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 = {