diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 957107fb..cedd4487 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -1,11 +1,14 @@ from __future__ import annotations +import calendar from typing import Any # было: Any, Dict, List, Type +from django.contrib.auth import get_user_model 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 django.utils import timezone from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest from communications import comments_helpers @@ -74,6 +77,106 @@ def _is_unpaid_vacation(req: EmployeeRequest) -> bool: return any(marker in haystack for marker in UNPAID_VACATION_MARKERS) +def _resolve_stats_window(period: str) -> tuple[timezone.datetime.date, timezone.datetime.date] | tuple[None, None]: + """Возвращает границы окна статистики для month/year/all.""" + today = timezone.localdate() + if period == "month": + _, last_day = calendar.monthrange(today.year, today.month) + return today.replace(day=1), today.replace(day=last_day) + if period == "year": + return today.replace(month=1, day=1), today.replace(month=12, day=31) + return None, None + + +def _request_duration_days_in_window( + req: EmployeeRequest, + start_date, + end_date, +) -> int: + """Возвращает число дней заявки внутри заданного окна.""" + if start_date is None or end_date is None: + return _request_duration_days(req) + + if req.date_from and req.date_to: + overlap_start = max(req.date_from, start_date) + overlap_end = min(req.date_to, end_date) + if overlap_start > overlap_end: + return 0 + return (overlap_end - overlap_start).days + 1 + + if req.date_from and start_date <= req.date_from <= end_date: + return 1 + + return 0 + + +def _can_view_request_statistics(user) -> bool: + return 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") + ) + + +def _build_employee_statistics(employee_id: int, employee_name: str, period: str): + start_date, end_date = _resolve_stats_window(period) + + base_qs = EmployeeRequest.objects.filter(employee_id=employee_id).exclude( + status=RequestStatus.DRAFT + ) + if start_date and end_date: + base_qs = base_qs.filter( + created_at__date__gte=start_date, + created_at__date__lte=end_date, + ) + + approved_qs = EmployeeRequest.objects.filter( + employee_id=employee_id, + 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_in_window( + req, + start_date, + end_date, + ) + 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 + + return { + "employee_id": employee_id, + "employee_name": employee_name, + "period": period, + "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, + } + + class RequestViewSet(viewsets.ModelViewSet): """ViewSet для заявок сотрудников. @@ -113,6 +216,10 @@ class RequestViewSet(viewsets.ModelViewSet): return [IsRecipientOfRequest()] if self.action == "cancel": return [IsRequestAuthor()] + if self.action == "statistics": + from rest_framework.permissions import IsAuthenticated + + return [IsAuthenticated()] if self.action in {"retrieve", "employee_statistics"}: return [CanViewRequest()] if self.action == "destroy": @@ -526,57 +633,52 @@ class RequestViewSet(viewsets.ModelViewSet): 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: + if not _can_view_request_statistics(user): raise PermissionDenied( "У вас нет доступа к статистике по заявлениям." ) - base_qs = EmployeeRequest.objects.filter(employee=obj.employee).exclude( - status=RequestStatus.DRAFT + period = (request.query_params.get("period") or "all").strip().lower() + if period not in {"all", "year", "month"}: + raise ValidationError( + "Параметр period должен быть one of: all, year, month." + ) + + return Response( + _build_employee_statistics( + employee_id=obj.employee_id, + employee_name=str(obj.employee), + period=period, + ) ) - approved_qs = base_qs.filter(status=RequestStatus.APPROVED) - sick_leave_days = 0 - day_off_days = 0 - paid_vacation_days = 0 - unpaid_vacation_days = 0 + @action(detail=False, methods=["get"], url_path="statistics") + def statistics(self, request: DRFRequest) -> Response: + """Возвращает статистику по выбранному сотруднику для страницы.""" + if not _can_view_request_statistics(request.user): + raise PermissionDenied( + "У вас нет доступа к статистике по заявлениям." + ) - 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 + employee_id_raw = (request.query_params.get("employee_id") or "").strip() + if not employee_id_raw.isdigit(): + raise ValidationError("Параметр employee_id обязателен.") - 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) + period = (request.query_params.get("period") or "all").strip().lower() + if period not in {"all", "year", "month"}: + raise ValidationError( + "Параметр period должен быть one of: all, year, month." + ) + + UserModel = get_user_model() + employee = get_object_or_404(UserModel, pk=int(employee_id_raw)) + return Response( + _build_employee_statistics( + employee_id=employee.id, + employee_name=str(employee), + period=period, + ) + ) def create(self, request, *args, **kwargs): """ diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 616be9c0..c4fd89b7 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -6,6 +6,7 @@ import { RequestComposeModal } from "@/components/requests/RequestComposeModal"; import { RequestDetailModal } from "@/components/requests/RequestDetailModal"; import { RequestListControls } from "@/components/requests/RequestListControls"; import { RequestListSection } from "@/components/requests/RequestListSection"; +import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsPanel"; import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel"; import { useUser } from "@/contexts/UserContext"; import { Suspense } from "react"; @@ -100,6 +101,10 @@ 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, statsOpen]); - if (!request) return null; const author = request.employee || request.created_by; @@ -118,15 +71,6 @@ export function RequestDetailModal({ ? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение") : ""; const commentCount = request.comments_count ?? comments.length; - const statsItems = stats ? [ - { 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 }, - ] : []; return (
- {canViewStats ? ( -
- - - {statsOpen ? ( -
- {statsLoading || (!stats && !statsError) ? ( -
-
- Загрузка статистики... -
- ) : statsError ? ( -
- {statsError} -
- ) : ( -
- {statsItems.map((item) => ( -
-

{item.label}

-

{item.value}

-
- ))} -
- )} -
- ) : null} -
- ) : null} -
diff --git a/frontend/src/components/requests/RequestStatisticsPanel.tsx b/frontend/src/components/requests/RequestStatisticsPanel.tsx new file mode 100644 index 00000000..ab51d889 --- /dev/null +++ b/frontend/src/components/requests/RequestStatisticsPanel.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { apiClient } from "@/lib/api"; +import { displayUserName } from "@/lib/shared"; +import type { RequestEmployeeStatistics, User } from "@/types/api"; +import { ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; + +const statsPeriods = [ + { value: "all", label: "За всё время" }, + { value: "year", label: "За год" }, + { value: "month", label: "За месяц" }, +] as const; + +type StatsPeriod = (typeof statsPeriods)[number]["value"]; + +type RequestStatisticsPanelProps = { + canView: boolean; + employees: User[]; +}; + +export function RequestStatisticsPanel({ + canView, + employees, +}: RequestStatisticsPanelProps) { + const [open, setOpen] = useState(false); + const [employeeId, setEmployeeId] = useState(null); + const [period, setPeriod] = useState("all"); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!canView || !open || !employeeId) { + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + apiClient.getEmployeeRequestStatistics(employeeId, period) + .then((response) => { + if (!cancelled) { + setStats(response); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Не удалось загрузить статистику."); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [canView, employeeId, open, period]); + + if (!canView) return null; + + const employeeOptions = employees.map((employee) => ({ + id: employee.id, + name: displayUserName(employee), + })); + + const statsItems = stats ? [ + { 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 }, + ] : []; + + return ( +
+ + + {open ? ( +
+
+ { + setEmployeeId(id); + setStats(null); + setError(null); + }} + placeholder="Выберите сотрудника" + /> + +
+ {statsPeriods.map((periodOption) => { + const isActive = period === periodOption.value; + return ( + + ); + })} +
+
+ + {!employeeId ? ( +

+ Выберите сотрудника, чтобы посмотреть статистику. +

+ ) : loading || (!stats && !error) ? ( +
+
+ Загрузка статистики... +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+

+ {period === "all" + ? "Период: за всё время" + : period === "year" + ? "Период: текущий календарный год" + : "Период: текущий календарный месяц"} +

+
+ {statsItems.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index 41cd4027..dae2f4f7 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -31,8 +31,10 @@ 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/`), + getRequestEmployeeStatistics: (requestId: number, period: 'all' | 'year' | 'month' = 'all'): Promise => + request(`/api/v1/requests/${requestId}/employee-statistics/?period=${period}`), + getEmployeeRequestStatistics: (employeeId: number, period: 'all' | 'year' | 'month' = 'all'): Promise => + request(`/api/v1/requests/statistics/?employee_id=${employeeId}&period=${period}`), 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/types/api.ts b/frontend/src/types/api.ts index 26e506a0..5b8f409a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -372,6 +372,7 @@ export interface RequestComment { export interface RequestEmployeeStatistics { employee_id: number; employee_name: string; + period: "all" | "year" | "month"; total_submitted_requests: number; sick_leave_requests_count: number; day_off_requests_count: number;