diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index d74e2bb5..957107fb 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 # раскомментируйте импорт -from requests_app.enums import RequestStatus +from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest from communications import comments_helpers from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -27,7 +27,6 @@ from rest_framework.serializers import ( BaseSerializer, ) # <- для типизации get_serializer_class -from ..permissions import IsSelfOrStaff from .permissions import ( CanViewRequest, CommentsPermission, @@ -41,6 +40,40 @@ from .serializers import ( ) +UNPAID_VACATION_MARKERS = ( + "за свой счет", + "за свой счёт", + "без сохранения заработной платы", + "без сохранения зарплаты", + "без сохранения зп", + "неоплачиваем", +) + + +def _request_duration_days(req: EmployeeRequest) -> int: + """Возвращает длительность заявки в днях, включая обе даты.""" + if req.date_from and req.date_to: + return max((req.date_to - req.date_from).days + 1, 0) + if req.date_from: + return 1 + return 0 + + +def _is_unpaid_vacation(req: EmployeeRequest) -> bool: + """Определяет отпуск за свой счёт по текстовым маркерам. + + Явного поля в модели пока нет, поэтому используем временную эвристику + по title/comment. + """ + haystack = " ".join( + [ + str(getattr(req, "title", "") or ""), + str(getattr(req, "comment", "") or ""), + ] + ).lower() + return any(marker in haystack for marker in UNPAID_VACATION_MARKERS) + + class RequestViewSet(viewsets.ModelViewSet): """ViewSet для заявок сотрудников. @@ -80,7 +113,7 @@ class RequestViewSet(viewsets.ModelViewSet): return [IsRecipientOfRequest()] if self.action == "cancel": return [IsRequestAuthor()] - if self.action == "retrieve": + if self.action in {"retrieve", "employee_statistics"}: return [CanViewRequest()] if self.action == "destroy": return [IsRequestAuthor(), NotFinalOrStaff()] @@ -210,6 +243,7 @@ class RequestViewSet(viewsets.ModelViewSet): "comments", "cancel", "retrieve", + "employee_statistics", }: lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] @@ -480,6 +514,70 @@ class RequestViewSet(viewsets.ModelViewSet): ).data ) + @action(detail=True, methods=["get"], url_path="employee-statistics") + def employee_statistics( + self, request: DRFRequest, pk: int | str | None = None + ) -> Response: + """Возвращает сводную статистику по заявлениям сотрудника. + + Доступно администраторам и пользователям с явным правом + ``requests_app.view_statistics`` или ``requests_app.can_view_all_requests``. + """ + obj = self.get_object() + user = request.user + + can_view_statistics = bool( + getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + or user.has_perm("requests_app.view_statistics") + or user.has_perm("requests_app.can_view_all_requests") + ) + if not can_view_statistics: + raise PermissionDenied( + "У вас нет доступа к статистике по заявлениям." + ) + + base_qs = EmployeeRequest.objects.filter(employee=obj.employee).exclude( + status=RequestStatus.DRAFT + ) + approved_qs = base_qs.filter(status=RequestStatus.APPROVED) + + sick_leave_days = 0 + day_off_days = 0 + paid_vacation_days = 0 + unpaid_vacation_days = 0 + + for req in approved_qs.only( + "type", "date_from", "date_to", "title", "comment" + ): + duration = _request_duration_days(req) + if req.type == RequestType.SICK_LEAVE: + sick_leave_days += duration + elif req.type == RequestType.DAY_OFF: + day_off_days += duration + elif req.type == RequestType.VACATION: + if _is_unpaid_vacation(req): + unpaid_vacation_days += duration + else: + paid_vacation_days += duration + + payload = { + "employee_id": obj.employee_id, + "employee_name": str(obj.employee), + "total_submitted_requests": base_qs.count(), + "sick_leave_requests_count": base_qs.filter( + type=RequestType.SICK_LEAVE + ).count(), + "day_off_requests_count": base_qs.filter( + type=RequestType.DAY_OFF + ).count(), + "sick_leave_days": sick_leave_days, + "day_off_days": day_off_days, + "paid_vacation_days": paid_vacation_days, + "unpaid_vacation_days": unpaid_vacation_days, + } + return Response(payload) + def create(self, request, *args, **kwargs): """ Создание заявки: валидируем write-сериализатором, diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 670b912d..616be9c0 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -11,6 +11,7 @@ import { useUser } from "@/contexts/UserContext"; import { Suspense } from "react"; import { useRequestsPage } from "@/hooks/useRequestsPage"; import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen"; +import { canViewRequestStatistics } from "@/lib/permissions"; export default function RequestsPage() { return ( @@ -33,6 +34,7 @@ function RequestsPageFallback() { function RequestsPageContent() { const { user } = useUser(); + const canViewStats = canViewRequestStatistics(user); const h = useRequestsPage(user?.id); const screen = useRequestsPageScreen({ detailsRequestId: h.detailsRequest?.id, @@ -140,6 +142,7 @@ function RequestsPageContent() { (null); + const [stats, setStats] = useState(null); + + useEffect(() => { + setStatsOpen(false); + setStatsLoading(false); + setStatsError(null); + setStats(null); + }, [request?.id]); + + useEffect(() => { + if (!request?.id || !canViewStats || !statsOpen || stats || statsLoading) { + return; + } + + let cancelled = false; + setStatsLoading(true); + setStatsError(null); + + apiClient.getRequestEmployeeStatistics(request.id) + .then((response) => { + if (!cancelled) { + setStats(response); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setStatsError(error instanceof Error ? error.message : "Не удалось загрузить статистику."); + } + }) + .finally(() => { + if (!cancelled) { + setStatsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [canViewStats, request?.id, stats, statsLoading, statsOpen]); + if (!request) return null; const author = request.employee || request.created_by; @@ -69,6 +118,18 @@ export function RequestDetailModal({ ? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение") : ""; const commentCount = request.comments_count ?? comments.length; + const statsItems = useMemo(() => { + if (!stats) return []; + return [ + { label: "Всего заявлений", value: stats.total_submitted_requests }, + { label: "Больничных", value: stats.sick_leave_requests_count }, + { label: "Отгулов", value: stats.day_off_requests_count }, + { label: "Дней на больничном", value: stats.sick_leave_days }, + { label: "Дней на отгулах", value: stats.day_off_days }, + { label: "Оплачиваемый отпуск, дней", value: stats.paid_vacation_days }, + { label: "За свой счёт, дней", value: stats.unpaid_vacation_days }, + ]; + }, [stats]); return (
+ {canViewStats ? ( +
+ + + {statsOpen ? ( +
+ {statsLoading || (!stats && !statsError) ? ( +
+
+ Загрузка статистики... +
+ ) : statsError ? ( +
+ {statsError} +
+ ) : ( +
+ {statsItems.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ )} +
+ ) : null} +
+ ) : null} +
diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index 13128acd..41cd4027 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestEmployeeStatistics } from '@/types/api'; import type { RequestFn, GetTokenFn } from './utils'; export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { @@ -30,6 +31,8 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { return request(`/api/v1/requests/${qs ? '?' + qs : ''}`); }, getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`), + getRequestEmployeeStatistics: (requestId: number): Promise => + request(`/api/v1/requests/${requestId}/employee-statistics/`), createRequest: (data: Record, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs), updateRequest: (requestId: number, data: Record, saveAs?: 'draft' | 'submit') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs), deleteRequest: (requestId: number): Promise => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }), diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index fed452dd..85fded6f 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -83,6 +83,30 @@ export function canManageRequests(user?: User | null): boolean { return false; } +export function canViewRequestStatistics(user?: User | null): boolean { + if (!user) return false; + + const auth = user.auth; + if (!auth) return false; + + if (auth.is_staff || auth.is_superuser) { + return true; + } + + if ( + auth.permissions?.includes("requests_app.view_statistics") + || auth.permissions?.includes("requests_app.can_view_all_requests") + ) { + return true; + } + + const requestPerms = auth.permissions_by_app?.["requests_app"] || []; + return ( + requestPerms.includes("view_statistics") + || requestPerms.includes("can_view_all_requests") + ); +} + /** * Проверяет, может ли пользователь управлять оборудованием. * diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2e98296b..26e506a0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -369,6 +369,18 @@ export interface RequestComment { created_at: string; } +export interface RequestEmployeeStatistics { + employee_id: number; + employee_name: string; + total_submitted_requests: number; + sick_leave_requests_count: number; + day_off_requests_count: number; + sick_leave_days: number; + day_off_days: number; + paid_vacation_days: number; + unpaid_vacation_days: number; +} + // Equipment types export interface EquipmentCategory { id: number;