From 78b86158710c9097a3b52fca32506e792feaf22e Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 16 Apr 2026 17:22:06 +0300 Subject: [PATCH] feat: refine department calendar behavior and UI --- backend/api/v1/schedule/serializers.py | 11 + backend/api/v1/schedule/views.py | 68 +-- backend/scheduling/services.py | 2 +- .../v1/employees/test_department_calendar.py | 106 ++++ .../test_department_permission_migration.py | 59 ++- .../test_calendar_binding_migration.py | 52 ++ backend/tests/test_schedule_permissions.py | 145 ++++++ frontend/src/app/calendar/calendar.css | 201 ++++++-- frontend/src/app/departments/[id]/page.tsx | 8 + frontend/src/components/BigCalendar.tsx | 463 ++++++++++++++---- .../components/CalendarParticipantsModal.tsx | 33 +- .../src/components/calendar/CalendarCard.tsx | 293 +++++++---- .../components/calendar/CalendarSidebar.tsx | 9 +- .../src/components/layout/CalendarModals.tsx | 3 + .../src/components/layout/MobileDrawers.tsx | 9 +- frontend/src/contexts/CalendarContext.tsx | 33 +- .../src/hooks/calendar/useCalendarEvents.ts | 28 +- frontend/src/hooks/useCalendarModals.ts | 27 +- frontend/src/lib/api/calendar.ts | 13 +- frontend/src/services/calendarService.ts | 14 +- 20 files changed, 1262 insertions(+), 315 deletions(-) create mode 100644 backend/tests/scheduling/test_calendar_binding_migration.py diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index 84dbe40f..3f3b980d 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -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 diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index 781a0f07..7fb74fe2 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -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) # Результат diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py index 2e5cafd8..107acc3f 100644 --- a/backend/scheduling/services.py +++ b/backend/scheduling/services.py @@ -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: diff --git a/backend/tests/api/v1/employees/test_department_calendar.py b/backend/tests/api/v1/employees/test_department_calendar.py index 636c8077..40c0b5c6 100644 --- a/backend/tests/api/v1/employees/test_department_calendar.py +++ b/backend/tests/api/v1/employees/test_department_calendar.py @@ -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 diff --git a/backend/tests/employees/test_department_permission_migration.py b/backend/tests/employees/test_department_permission_migration.py index 84b2a641..fd0c766e 100644 --- a/backend/tests/employees/test_department_permission_migration.py +++ b/backend/tests/employees/test_department_permission_migration.py @@ -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() diff --git a/backend/tests/scheduling/test_calendar_binding_migration.py b/backend/tests/scheduling/test_calendar_binding_migration.py new file mode 100644 index 00000000..4838fd2d --- /dev/null +++ b/backend/tests/scheduling/test_calendar_binding_migration.py @@ -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() diff --git a/backend/tests/test_schedule_permissions.py b/backend/tests/test_schedule_permissions.py index f653beb4..dcc0a08d 100644 --- a/backend/tests/test_schedule_permissions.py +++ b/backend/tests/test_schedule_permissions.py @@ -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): """Создание правила повторения.""" diff --git a/frontend/src/app/calendar/calendar.css b/frontend/src/app/calendar/calendar.css index 56ca5965..8a6883cd 100644 --- a/frontend/src/app/calendar/calendar.css +++ b/frontend/src/app/calendar/calendar.css @@ -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 { diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index c6c2b2c7..f554d8c8 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -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("Не удалось открыть календарь отдела"); diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 7c1da2fc..855114e6 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -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 + + {showCalendarActions && ( + <> +
setShowCalendarActions(false)} + /> +
+
+ + + {selectedCalendar ? ( + <> +
+ + + + + + {!isDepartmentCalendar ? ( + <> +
+ + + + ) : null} + + ) : null} +
+
+ + )} +
+ )} + + + {calendars.length === 0 ? ( -
+

Календарей нет.

) : ( -
- +
+
+ +
+ + +
+
- - - {selectedCalendarId && selectedCalendar?.can_edit_calendar && ( - - )} +
+ {canUseAllCalendarsMode && ( +
+ + +
+ )} +
)}
- - {loading && ( -
-
- Загрузка... -
- )}
{/* Календарь */} -
- 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, - }} - /> +
+
+ 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, + }} + /> +
@@ -572,6 +842,19 @@ export function BigCalendar() { }} calendar={editingCalendar} /> + + {selectedCalendar && ( + 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} + /> + )} ); } diff --git a/frontend/src/components/CalendarParticipantsModal.tsx b/frontend/src/components/CalendarParticipantsModal.tsx index 2764c252..ede66b39 100644 --- a/frontend/src/components/CalendarParticipantsModal.tsx +++ b/frontend/src/components/CalendarParticipantsModal.tsx @@ -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([]); const [employees, setEmployees] = useState([]); @@ -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({
- {/* Add Participant Section - только для владельца */} - {isOwner && ( + {isDepartmentCalendar && ( +
+

+ Состав участников календаря отдела синхронизируется автоматически + из состава отдела и назначенных ролей. Управление через эту + модалку отключено для всех пользователей, включая администраторов. +

+
+ )} + + {/* Add Participant Section */} + {canManage && (

@@ -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 (
{firstName} {lastName}
-
{user.email || `@${username}`}
+ {secondaryLabel ? ( +
{secondaryLabel}
+ ) : null}

@@ -392,7 +413,7 @@ export default function CalendarParticipantsModal({ participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'} - {isOwner && participant.distinction !== 'owner' && ( + {canManage && participant.distinction !== 'owner' && ( -
- {calendars.length === 0 ? ( -
-

Календарей пока нет

+
-
- ) : ( -
- - {selectedCalendar && ( -
- - {showCalendarMenu && ( - <> -
setShowCalendarMenu(false)} - /> -
-
+ {showCalendarMenu && ( + <> +
setShowCalendarMenu(false)} + /> +
+
+ + + {selectedCalendar ? ( + <> +
-
-
- - )} + {!isDepartmentCalendar ? ( + <> +
+ + + + ) : null} + + ) : null} +
+
+ + )} +
+
+ {calendars.length === 0 ? ( +
+

Календарей пока нет

+ +
+ ) : ( +
+ {canUseAllCalendarsMode && ( +
+ +
)} + +
+ +
)}
@@ -465,14 +582,14 @@ export const CalendarCard = memo(function CalendarCard({ {/* События — неделя / месяц */}
-
+