From 70d2c55348936f1e4a7c0078e5a499d700b1a9c9 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 14 Apr 2026 19:11:17 +0300 Subject: [PATCH] refactor(ui): polish department feed and mobile nav placement --- backend/api/v1/employees/views/departments.py | 5 +- backend/api/v1/feed/serializers.py | 7 + backend/api/v1/feed/views.py | 33 +++- backend/api/v1/permissions.py | 39 +++- .../employees/test_department_user_perms.py | 38 ++++ backend/tests/api/v1/feed/test_posts.py | 52 +++++ frontend/src/app/departments/[id]/page.tsx | 4 +- frontend/src/app/globals.css | 15 ++ frontend/src/app/layout.tsx | 2 + frontend/src/app/messages/page.tsx | 18 +- frontend/src/app/page.tsx | 187 +++++++++++++++++- frontend/src/app/settings/page.tsx | 61 ++++++ frontend/src/components/AppShell.tsx | 45 +++-- .../src/components/NotificationCenter.tsx | 4 +- frontend/src/components/Providers.tsx | 19 +- .../departments/DepartmentFeedSection.tsx | 3 + frontend/src/components/feed/FeedPostCard.tsx | 18 +- .../components/messages/ChatDialogHeader.tsx | 4 +- .../requests/RequestDetailModal.tsx | 2 +- .../components/requests/RequestListItem.tsx | 2 +- .../components/requests/SwipeApprovalMode.tsx | 33 +--- .../contexts/MobileNavPlacementContext.tsx | 67 +++++++ frontend/src/hooks/useRequestsPage.ts | 16 +- .../src/lib/messages/chatSettingsUtils.ts | 3 +- frontend/src/lib/messages/chatUtils.ts | 7 +- frontend/src/lib/mobileNavPlacement.ts | 99 ++++++++++ frontend/src/types/api.ts | 1 + 27 files changed, 690 insertions(+), 94 deletions(-) create mode 100644 frontend/src/contexts/MobileNavPlacementContext.tsx create mode 100644 frontend/src/lib/mobileNavPlacement.ts diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index bf036c6b..0bf96626 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -40,6 +40,7 @@ from rest_framework.response import Response from ...permissions import ( AdminOrActionOrModelPerms, AdminOrDeptAllowed, + can_publish_department_posts, has_dept_perm, ) from ..serializers import ( @@ -500,8 +501,8 @@ class DepartmentViewSet(viewsets.ModelViewSet): "can_assign_roles": has_dept_perm( request.user, dept.id, DeptPerm.ASSIGN_ROLE ), - "can_publish_posts": has_dept_perm( - request.user, dept.id, DeptPerm.CREATE_POST + "can_publish_posts": can_publish_department_posts( + request.user, dept.id ), "can_manage_feed": has_dept_perm( request.user, dept.id, DeptPerm.MANAGE_FEED diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 20a2c185..8b46e34f 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -43,6 +43,11 @@ class PostLikerSerializer(AuthorMiniSerializer): class PostListSerializer(serializers.ModelSerializer): author_id = serializers.IntegerField(read_only=True) author = AuthorMiniSerializer(read_only=True) + department_name = serializers.CharField( + source="department.name", + read_only=True, + allow_null=True, + ) # удoбные представления дат created_at_display = serializers.DateTimeField( @@ -71,6 +76,7 @@ class PostListSerializer(serializers.ModelSerializer): "type", "department", "department_id", + "department_name", "title", "body", "image", @@ -97,6 +103,7 @@ class PostListSerializer(serializers.ModelSerializer): "author_id", "author", "department_id", + "department_name", "is_liked", ) diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index 0d3dd9a6..698da8c4 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -30,6 +30,7 @@ from rest_framework.response import Response from ..permissions import ( AdminOrActionOrModelPerms, AdminOrDeptAllowed, + can_publish_department_posts, ) from .serializers import PostListSerializer, PostLikerSerializer, PostSerializer @@ -57,8 +58,9 @@ class PostViewSet(viewsets.ModelViewSet): - create: * type=company → AdminOrActionOrModelPerms + required_perm_code="feed.publish_company_post" - * type=department → AdminOrDeptAllowed - с кодом DeptPerm.CREATE_POST|publish_department_post + * type=department → staff/superuser, руководитель отдела, + активный участник отдела или любой сотрудник + с активной ролью в этом отделе - update/partial_update/destroy: * company → как для company-create * department → как для department-create @@ -102,6 +104,23 @@ class PostViewSet(viewsets.ModelViewSet): return True # пропускаем к валидации сериализатора return super().has_permission(request, view) + class DepartmentPostCreatePerm(IsAuthenticated): + """Упрощённое право на создание новости отдела.""" + + def has_permission(self, request, view) -> bool: + if not super().has_permission(request, view): + return False + + dept_id = _to_id( + request.data.get("department") + or request.data.get("department_id") + ) + if dept_id is None: + # Даём сериализатору вернуть 400 по отсутствующему отделу. + return True + + return can_publish_department_posts(request.user, dept_id) + def get_permissions(self) -> List[Any]: """Возвращает DRF-пермишены под текущий action. @@ -118,13 +137,9 @@ class PostViewSet(viewsets.ModelViewSet): t = (self.request.data.get("type") or "").strip() if t == TYPE_COMPANY: return [IsAuthenticated(), AdminOrActionOrModelPerms()] - # department → департаментное право на создание - dept_code = getattr( - DeptPerm, "CREATE_POST", "publish_department_post" - ) - perm = self.PostDeptPerm() - perm.required_code = DeptPerm.CREATE_POST - return [IsAuthenticated(), perm] + if t == TYPE_DEPARTMENT: + return [self.DepartmentPostCreatePerm()] + return [IsAuthenticated()] # --- UPDATE/PATCH/DELETE: определяем тип поста из БД, # но без get_object() --- diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index d1ac7170..6d76678e 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -5,11 +5,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple from django.contrib.auth.models import AbstractBaseUser from employees.models import Department, EmployeeDepartment, RoleAssignment -from rest_framework.permissions import ( - SAFE_METHODS, - BasePermission, - DjangoModelPermissions, -) +from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions from rest_framework.request import Request MANAGE_PERM = "manage_department" @@ -141,6 +137,39 @@ def has_dept_perm( return link.role.scoped_permissions.filter(code=code).exists() +def can_publish_department_posts( + user: AbstractBaseUser, department_id: int +) -> bool: + """Возвращает True, если пользователь может публиковать новости отдела. + + Упрощённый продуктовый контракт: + - staff/superuser → всегда True + - руководитель отдела → True + - любой активный физический участник отдела → True + - любой сотрудник с активной ролью в отделе → True + """ + if not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False): + return True + + if Department.objects.filter(id=department_id, head_id=user.id).exists(): + return True + + if EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=department_id, + is_active=True, + ).exists(): + return True + + return RoleAssignment.objects.filter( + employee_id=user.id, + role__department_id=department_id, + is_active=True, + ).exists() + + class AdminOrDeptAllowed(BasePermission): """Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел. diff --git a/backend/tests/api/v1/employees/test_department_user_perms.py b/backend/tests/api/v1/employees/test_department_user_perms.py index d4a0306b..c2fdac0b 100644 --- a/backend/tests/api/v1/employees/test_department_user_perms.py +++ b/backend/tests/api/v1/employees/test_department_user_perms.py @@ -9,6 +9,7 @@ from employees.models import ( Department, DepartmentPermission, DepartmentRole, + EmployeeDepartment, RoleAssignment, ) from tests.conftest import _unique_phone @@ -78,3 +79,40 @@ def test_department_user_perms_resolves_role_based_feed_rights(api_client: APICl assert resp.status_code == status.HTTP_200_OK assert resp.json()["can_publish_posts"] is True assert resp.json()["can_manage_feed"] is False + + +@pytest.mark.django_db +def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient): + employee = make_user() + dept = Department.objects.create(name="Склад") + EmployeeDepartment.objects.create( + employee=employee, + department=dept, + is_active=True, + ) + + api_client.force_authenticate(user=employee) + url = reverse("api:v1:departments-user-perms", args=[dept.id]) + resp = api_client.get(url) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["can_publish_posts"] is True + assert resp.json()["can_manage_feed"] is False + + +@pytest.mark.django_db +def test_department_user_perms_allows_role_only_to_publish_without_scoped_perm( + api_client: APIClient, +): + employee = make_user() + dept = Department.objects.create(name="Логистика") + role = DepartmentRole.objects.create(department=dept, name="Наблюдатель") + RoleAssignment.objects.create(employee=employee, role=role, is_active=True) + + api_client.force_authenticate(user=employee) + url = reverse("api:v1:departments-user-perms", args=[dept.id]) + resp = api_client.get(url) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["can_publish_posts"] is True + assert resp.json()["can_manage_feed"] is False diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index 5a6611c2..34010492 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -173,6 +173,30 @@ def test_department_post_requires_publish_permission( assert response.status_code == status.HTTP_403_FORBIDDEN +def test_active_department_member_can_create_department_post( + auth_client_factory, + make_user, + department_factory, +): + user = make_user("member-publisher@example.com") + department = department_factory(name="Operations") + EmployeeDepartment.objects.create( + employee=user, + department=department, + is_active=True, + ) + + client = auth_client_factory(user) + response = client.post( + API_POSTS_URL, + _department_post_payload(department.id), + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert Post.objects.filter(id=response.data["id"], department=department).exists() + + def test_role_assignment_can_create_department_post( auth_client_factory, make_user, @@ -193,6 +217,34 @@ def test_role_assignment_can_create_department_post( assert Post.objects.filter(id=response.data["id"], department=department).exists() +def test_any_role_assignment_can_create_department_post( + auth_client_factory, + make_user, + department_factory, +): + user = make_user("roleonly-publisher@example.com") + department = department_factory(name="Support") + role = DepartmentRole.objects.create( + department=department, + name="Observer", + ) + RoleAssignment.objects.create( + employee=user, + role=role, + is_active=True, + ) + + client = auth_client_factory(user) + response = client.post( + API_POSTS_URL, + _department_post_payload(department.id), + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert Post.objects.filter(id=response.data["id"], department=department).exists() + + def test_list_department_posts_filtered_by_department( auth_client_factory, make_user, diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index e37aedbf..ae57f351 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -11,7 +11,7 @@ import { ChevronRight, Crown, Link2, - MessageCircle, + MessageSquare, PencilLine, Plus, Search, @@ -996,7 +996,7 @@ export default function DepartmentDetailPage() { disabled={openingDepartmentChat} className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50" > - + Перейти в чат отдела diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 35656186..6f1386ba 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -322,6 +322,12 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { color: var(--accent-primary-strong); } +.app-badge-danger { + border-color: color-mix(in srgb, #ef4444 24%, var(--border-subtle)); + background: var(--danger-soft); + color: #dc2626; +} + .app-status-pill { display: inline-flex; align-items: center; @@ -363,6 +369,15 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { color: #ffffff; } +.app-counter-danger { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: #ef4444; + color: #ffffff; +} + .app-action-primary { border: 1px solid transparent; background: var(--accent-primary); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index bce41828..9c831f2a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/Providers"; import { getThemeInitScript } from "@/lib/theme"; +import { getMobileNavPlacementInitScript } from "@/lib/mobileNavPlacement"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -36,6 +37,7 @@ export default function RootLayout({